1use crate::error::{AuthError, Result};
7use agentguard_core::auth_keys::{parse_alg, Algorithm, KeyMaterial, KeyRegistry};
8use agentguard_core::jwk::thumbprint_ed25519 as jwk_thumbprint_ed25519;
9use base64::Engine as _;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::sync::Arc;
13use std::time::Duration;
14
15#[derive(Debug, Clone)]
17pub struct JwtConfig {
18 pub issuer: String,
20 pub audience: String,
22 pub algorithms: Vec<Algorithm>,
28 pub jwks_uri: Option<String>,
30 pub jwks_refresh: Duration,
33 pub clock_skew: Duration,
35}
36
37impl JwtConfig {
38 pub fn new(issuer: impl Into<String>, audience: impl Into<String>) -> Self {
39 Self {
40 issuer: issuer.into(),
41 audience: audience.into(),
42 algorithms: vec![Algorithm::EdDSA],
43 jwks_uri: None,
44 jwks_refresh: Duration::from_secs(30),
45 clock_skew: Duration::from_secs(60),
46 }
47 }
48
49 pub fn with_algorithms(
59 mut self,
60 algs: Vec<Algorithm>,
61 ) -> std::result::Result<Self, crate::AuthError> {
62 for alg in &algs {
63 if !crate::SUPPORTED_ALGORITHMS.contains(alg) {
64 return Err(crate::AuthError::JwtInvalid(format!(
65 "algorithm {:?} is not verifiable in this release; supported: {:?}",
66 alg,
67 crate::SUPPORTED_ALGORITHMS
68 )));
69 }
70 }
71 self.algorithms = algs;
72 Ok(self)
73 }
74
75 pub fn with_algorithms_lossy(mut self, algs: Vec<Algorithm>) -> Self {
81 let supported: Vec<Algorithm> = algs
82 .into_iter()
83 .filter(|a| crate::SUPPORTED_ALGORITHMS.contains(a))
84 .collect();
85 if supported.is_empty() {
86 self.algorithms = vec![Algorithm::EdDSA];
87 } else {
88 self.algorithms = supported;
89 }
90 self
91 }
92
93 pub fn with_jwks_uri(mut self, uri: impl Into<String>) -> Self {
94 self.jwks_uri = Some(uri.into());
95 self
96 }
97
98 pub fn with_jwks_refresh(mut self, d: Duration) -> Self {
99 self.jwks_refresh = d;
100 self
101 }
102
103 pub fn with_jwks_refresh_from_env(mut self) -> Self {
106 if let Ok(s) = std::env::var("AGENTGUARD_JWKS_REFRESH") {
107 match humantime::parse_duration(&s) {
108 Ok(d) => self.jwks_refresh = d,
109 Err(e) => tracing::warn!(
110 env = %s,
111 error = %e,
112 "AGENTGUARD_JWKS_REFRESH is not a valid duration; using default"
113 ),
114 }
115 }
116 self
117 }
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct ValidatedJwt {
123 pub header: HashMap<String, serde_json::Value>,
124 pub claims: serde_json::Value,
125}
126
127#[derive(Clone)]
129pub struct JwtValidator {
130 config: Arc<JwtConfig>,
131 keys: Arc<KeyRegistry>,
132}
133
134impl JwtValidator {
135 pub fn new(config: JwtConfig) -> Self {
138 Self {
139 config: Arc::new(config),
140 keys: Arc::new(KeyRegistry::new()),
141 }
142 }
143
144 pub fn add_key(&self, kid: impl Into<String>, alg: Algorithm, key: KeyMaterial) {
146 self.keys.add(kid, alg, key)
147 }
148
149 pub fn validate(&self, token: &str) -> Result<ValidatedJwt> {
156 let (header, claims, signing_input, signature) = Self::parse_token(token)?;
157 let (alg, kid) = self.check_header(&header)?;
158 self.check_signature(&signing_input, &signature, &kid, alg)?;
159 self.check_claims(&claims)?;
160 Ok(ValidatedJwt { header, claims })
161 }
162
163 #[allow(clippy::type_complexity)]
166 fn parse_token(
167 token: &str,
168 ) -> Result<(
169 HashMap<String, serde_json::Value>,
170 serde_json::Value,
171 Vec<u8>,
172 Vec<u8>,
173 )> {
174 let parts: Vec<&str> = token.split('.').collect();
175 if parts.len() != 3 {
176 return Err(AuthError::JwtInvalid("expected 3 parts".into()));
177 }
178 let header_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
179 .decode(parts[0])
180 .map_err(|e| AuthError::JwtInvalid(format!("header b64: {}", e)))?;
181 let header: HashMap<String, serde_json::Value> = serde_json::from_slice(&header_bytes)
182 .map_err(|e| AuthError::JwtInvalid(format!("header json: {}", e)))?;
183
184 let claims_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
185 .decode(parts[1])
186 .map_err(|e| AuthError::JwtInvalid(format!("claims b64: {}", e)))?;
187 let claims: serde_json::Value = serde_json::from_slice(&claims_bytes)
188 .map_err(|e| AuthError::JwtInvalid(format!("claims json: {}", e)))?;
189
190 let signing_input = format!("{}.{}", parts[0], parts[1]).into_bytes();
191 let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD
192 .decode(parts[2])
193 .map_err(|e| AuthError::JwtInvalid(format!("sig b64: {}", e)))?;
194 Ok((header, claims, signing_input, signature))
195 }
196
197 fn check_header(
199 &self,
200 header: &HashMap<String, serde_json::Value>,
201 ) -> Result<(Algorithm, String)> {
202 let alg_str = header
203 .get("alg")
204 .and_then(|v| v.as_str())
205 .ok_or_else(|| AuthError::JwtInvalid("missing alg".into()))?;
206 let alg = parse_alg(alg_str)
207 .ok_or_else(|| AuthError::JwtInvalid(format!("unsupported alg: {}", alg_str)))?;
208 if !self.config.algorithms.contains(&alg) {
209 return Err(AuthError::JwtInvalid(format!(
210 "algorithm {:?} not in whitelist",
211 alg
212 )));
213 }
214 let kid = header
215 .get("kid")
216 .and_then(|v| v.as_str())
217 .ok_or_else(|| AuthError::JwtInvalid("missing kid".into()))?
218 .to_string();
219 Ok((alg, kid))
220 }
221
222 fn check_signature(
224 &self,
225 signing_input: &[u8],
226 signature: &[u8],
227 kid: &str,
228 alg: Algorithm,
229 ) -> Result<()> {
230 let keys = self.keys.get(kid, alg);
231 if keys.is_empty() {
232 return Err(AuthError::JwtUnknownKid(kid.to_string()));
233 }
234 for key in keys {
235 if verify_signature(alg, &key, signing_input, signature).is_ok() {
236 return Ok(());
237 }
238 }
239 Err(AuthError::JwtInvalid(
240 "signature verification failed".into(),
241 ))
242 }
243
244 fn check_claims(&self, claims: &serde_json::Value) -> Result<()> {
247 let iss = claims.get("iss").and_then(|v| v.as_str()).ok_or_else(|| {
249 AuthError::JwtIssuerMismatch {
250 expected: self.config.issuer.clone(),
251 actual: "<missing>".into(),
252 }
253 })?;
254 if iss != self.config.issuer {
255 return Err(AuthError::JwtIssuerMismatch {
256 expected: self.config.issuer.clone(),
257 actual: iss.to_string(),
258 });
259 }
260
261 let aud_ok = match claims.get("aud") {
263 Some(serde_json::Value::String(s)) => *s == self.config.audience,
264 Some(serde_json::Value::Array(arr)) => arr
265 .iter()
266 .any(|v| v.as_str() == Some(self.config.audience.as_str())),
267 _ => false,
268 };
269 if !aud_ok {
270 return Err(AuthError::JwtAudienceMismatch {
271 expected: self.config.audience.clone(),
272 actual: format!("{:?}", claims.get("aud")),
273 });
274 }
275
276 let exp = claims
278 .get("exp")
279 .and_then(|v| v.as_i64())
280 .ok_or(AuthError::JwtExpired)?;
281 let now = chrono::Utc::now().timestamp();
282 let skew = self.config.clock_skew.as_secs() as i64;
283 if exp + skew < now {
284 return Err(AuthError::JwtExpired);
285 }
286
287 if let Some(nbf) = claims.get("nbf").and_then(|v| v.as_i64()) {
289 if nbf > now + skew {
290 return Err(AuthError::JwtInvalid("token not yet valid".into()));
291 }
292 }
293
294 if let Some(iat) = claims.get("iat").and_then(|v| v.as_i64()) {
296 if iat > now + skew {
297 return Err(AuthError::JwtInvalid("iat is in the future".into()));
298 }
299 }
300
301 Ok(())
302 }
303
304 pub async fn refresh_jwks(&self) -> Result<()> {
309 use base64::Engine as _;
310 let uri = self
311 .config
312 .jwks_uri
313 .as_ref()
314 .ok_or_else(|| AuthError::Other("no jwks_uri configured".into()))?;
315 let client = reqwest::Client::builder()
318 .timeout(std::time::Duration::from_secs(10))
319 .connect_timeout(std::time::Duration::from_secs(5))
320 .redirect(reqwest::redirect::Policy::none())
321 .build()
322 .map_err(|e| AuthError::JwksFetch(format!("client build: {}", e)))?;
323 let resp = client
324 .get(uri)
325 .send()
326 .await
327 .map_err(|e| AuthError::JwksFetch(e.to_string()))?;
328 if !resp.status().is_success() {
329 return Err(AuthError::JwksFetch(format!("HTTP {}", resp.status())));
330 }
331 let body = resp
332 .text()
333 .await
334 .map_err(|e| AuthError::JwksFetch(e.to_string()))?;
335 if body.len() > 1_048_576 {
336 return Err(AuthError::JwksFetch("JWKS document exceeds 1 MiB".into()));
337 }
338 let jwks: JwksDoc = serde_json::from_str(&body)
339 .map_err(|e| AuthError::JwksFetch(format!("parse: {}", e)))?;
340 if jwks.keys.len() > 64 {
341 return Err(AuthError::JwksFetch(format!(
342 "JWKS document has {} keys; refusing to register more than 64",
343 jwks.keys.len()
344 )));
345 }
346 for k in jwks.keys {
347 let alg = match parse_alg(&k.alg) {
348 Some(a) => a,
349 None => {
350 tracing::warn!(alg = %k.alg, "skipping unknown JWKS alg");
351 continue;
352 }
353 };
354 if matches!(alg, Algorithm::EdDSA) {
356 let x = match k.x.as_deref() {
357 Some(s) => s,
358 None => continue,
359 };
360 let raw = match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(x) {
361 Ok(b) => b,
362 Err(e) => {
363 tracing::warn!(error = %e, "skipping Ed25519 JWKS key: bad x");
364 continue;
365 }
366 };
367 if raw.len() != 32 {
368 tracing::warn!("skipping Ed25519 JWKS key: x is not 32 bytes");
369 continue;
370 }
371 let kid = match k.kid {
376 Some(s) if !s.is_empty() => s,
377 _ => jwk_thumbprint_ed25519(x, k.crv.as_deref().unwrap_or("Ed25519")),
378 };
379 self.keys
380 .add(&kid, Algorithm::EdDSA, KeyMaterial::Ed25519(raw));
381 } else {
382 tracing::debug!(alg = ?alg, "skipping non-Ed25519 JWKS key");
384 }
385 }
386 Ok(())
387 }
388
389 pub fn spawn_jwks_refresher(self: Arc<Self>, interval: Duration) {
391 tokio::spawn(async move {
392 let mut ticker = tokio::time::interval(interval);
393 loop {
394 ticker.tick().await;
395 if let Err(e) = self.refresh_jwks().await {
396 tracing::warn!(error = %e, "jwks refresh failed");
397 }
398 }
399 });
400 }
401}
402
403#[derive(Debug, Deserialize)]
404struct JwksDoc {
405 keys: Vec<JwksKey>,
406}
407
408#[derive(Debug, Deserialize)]
409#[allow(dead_code)]
410struct JwksKey {
411 #[serde(default)]
412 kty: Option<String>,
413 #[serde(default)]
414 key_type: Option<String>,
415 alg: String,
416 #[serde(default)]
417 kid: Option<String>,
418 #[serde(default)]
419 n: Option<String>,
420 #[serde(default)]
421 e: Option<String>,
422 #[serde(default)]
423 x: Option<String>,
424 #[serde(default)]
425 y: Option<String>,
426 #[serde(default)]
427 crv: Option<String>,
428}
429
430fn verify_signature(
431 alg: Algorithm,
432 key: &KeyMaterial,
433 signing_input: &[u8],
434 signature: &[u8],
435) -> Result<()> {
436 use ed25519_dalek::{Signature, Verifier, VerifyingKey};
437 match (alg, key) {
438 (Algorithm::EdDSA, KeyMaterial::Ed25519(bytes)) => {
439 if bytes.len() != 32 {
440 return Err(AuthError::JwtInvalid("ed25519 key must be 32 bytes".into()));
441 }
442 let vk = VerifyingKey::from_bytes(
443 bytes
444 .as_slice()
445 .try_into()
446 .map_err(|_| AuthError::JwtInvalid("bad ed25519 key".into()))?,
447 )
448 .map_err(|e| AuthError::JwtInvalid(format!("ed25519 key: {}", e)))?;
449 let sig = Signature::from_slice(signature)
450 .map_err(|e| AuthError::JwtInvalid(format!("ed25519 sig: {}", e)))?;
451 vk.verify(signing_input, &sig)
452 .map_err(|_| AuthError::JwtInvalid("ed25519 verify failed".into()))?;
453 Ok(())
454 }
455 _ => Err(AuthError::JwtInvalid(format!(
456 "alg {:?} not implemented in this release",
457 alg
458 ))),
459 }
460}
461
462#[allow(dead_code)]
464type _Unused = ();
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469 use ed25519_dalek::{Signer, SigningKey};
470 use rand::rngs::OsRng;
471
472 fn sign_token(signer: &SigningKey, kid: &str, claims: serde_json::Value) -> String {
473 let header = serde_json::json!({"alg": "EdDSA", "typ": "JWT", "kid": kid});
474 let h = base64::engine::general_purpose::URL_SAFE_NO_PAD
475 .encode(serde_json::to_vec(&header).unwrap());
476 let p = base64::engine::general_purpose::URL_SAFE_NO_PAD
477 .encode(serde_json::to_vec(&claims).unwrap());
478 let signing_input = format!("{}.{}", h, p);
479 let sig = signer.sign(signing_input.as_bytes());
480 let s = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig.to_bytes());
481 format!("{}.{}.{}", h, p, s)
482 }
483
484 #[test]
485 fn eddsa_token_validates() {
486 let mut csprng = OsRng;
487 let signing_key = SigningKey::generate(&mut csprng);
488 let pub_bytes = signing_key.verifying_key().to_bytes().to_vec();
489
490 let v = JwtValidator::new(JwtConfig::new("https://idp.example.com", "agentguard"));
491 v.add_key("kid1", Algorithm::EdDSA, KeyMaterial::Ed25519(pub_bytes));
492
493 let claims = serde_json::json!({
494 "iss": "https://idp.example.com",
495 "aud": "agentguard",
496 "sub": "alice",
497 "exp": chrono::Utc::now().timestamp() + 3600,
498 });
499 let token = sign_token(&signing_key, "kid1", claims);
500 let res = v.validate(&token);
501 assert!(res.is_ok(), "expected valid token, got {:?}", res);
502 }
503
504 #[test]
505 fn with_algorithms_rejects_unsupported() {
506 let res = JwtConfig::new("https://idp.example.com", "agentguard")
511 .with_algorithms(vec![Algorithm::RS256]);
512 assert!(res.is_err(), "RS256 must be rejected at construction");
513 let msg = format!("{}", res.unwrap_err());
514 assert!(
515 msg.contains("RS256"),
516 "error must name the bad algorithm: {msg}"
517 );
518 }
519
520 #[test]
521 fn with_algorithms_lossy_filters_unsupported() {
522 let cfg = JwtConfig::new("https://idp.example.com", "agentguard")
527 .with_algorithms_lossy(vec![Algorithm::RS256, Algorithm::EdDSA]);
528 assert_eq!(cfg.algorithms, vec![Algorithm::EdDSA]);
529
530 let cfg = JwtConfig::new("https://idp.example.com", "agentguard")
531 .with_algorithms_lossy(vec![Algorithm::RS256]);
532 assert_eq!(cfg.algorithms, vec![Algorithm::EdDSA]);
533 }
534
535 #[test]
536 fn expired_token_rejected() {
537 let mut csprng = OsRng;
538 let signing_key = SigningKey::generate(&mut csprng);
539 let pub_bytes = signing_key.verifying_key().to_bytes().to_vec();
540 let v = JwtValidator::new(JwtConfig::new("https://idp.example.com", "agentguard"));
541 v.add_key("kid1", Algorithm::EdDSA, KeyMaterial::Ed25519(pub_bytes));
542 let claims = serde_json::json!({
543 "iss": "https://idp.example.com",
544 "aud": "agentguard",
545 "exp": chrono::Utc::now().timestamp() - 100,
546 });
547 let token = sign_token(&signing_key, "kid1", claims);
548 let res = v.validate(&token);
549 assert!(matches!(res, Err(AuthError::JwtExpired)));
550 }
551
552 #[test]
553 fn missing_exp_rejected() {
554 let mut csprng = OsRng;
557 let signing_key = SigningKey::generate(&mut csprng);
558 let pub_bytes = signing_key.verifying_key().to_bytes().to_vec();
559 let v = JwtValidator::new(JwtConfig::new("https://idp.example.com", "agentguard"));
560 v.add_key("kid1", Algorithm::EdDSA, KeyMaterial::Ed25519(pub_bytes));
561 let claims = serde_json::json!({
562 "iss": "https://idp.example.com",
563 "aud": "agentguard",
564 });
566 let token = sign_token(&signing_key, "kid1", claims);
567 let res = v.validate(&token);
568 assert!(matches!(res, Err(AuthError::JwtExpired)));
569 }
570
571 #[test]
572 fn iat_in_future_rejected() {
573 let mut csprng = OsRng;
576 let signing_key = SigningKey::generate(&mut csprng);
577 let pub_bytes = signing_key.verifying_key().to_bytes().to_vec();
578 let v = JwtValidator::new(JwtConfig::new("https://idp.example.com", "agentguard"));
579 v.add_key("kid1", Algorithm::EdDSA, KeyMaterial::Ed25519(pub_bytes));
580 let claims = serde_json::json!({
581 "iss": "https://idp.example.com",
582 "aud": "agentguard",
583 "exp": chrono::Utc::now().timestamp() + 3600,
584 "iat": chrono::Utc::now().timestamp() + 7200, });
586 let token = sign_token(&signing_key, "kid1", claims);
587 let res = v.validate(&token);
588 assert!(matches!(res, Err(AuthError::JwtInvalid(_))));
589 }
590
591 #[test]
592 fn wrong_audience_rejected() {
593 let mut csprng = OsRng;
594 let signing_key = SigningKey::generate(&mut csprng);
595 let pub_bytes = signing_key.verifying_key().to_bytes().to_vec();
596 let v = JwtValidator::new(JwtConfig::new("https://idp.example.com", "agentguard"));
597 v.add_key("kid1", Algorithm::EdDSA, KeyMaterial::Ed25519(pub_bytes));
598 let claims = serde_json::json!({
599 "iss": "https://idp.example.com",
600 "aud": "wrong-audience",
601 "exp": chrono::Utc::now().timestamp() + 3600,
602 });
603 let token = sign_token(&signing_key, "kid1", claims);
604 let res = v.validate(&token);
605 assert!(matches!(res, Err(AuthError::JwtAudienceMismatch { .. })));
606 }
607
608 #[test]
609 fn unknown_kid_rejected() {
610 let v = JwtValidator::new(JwtConfig::new("iss", "aud"));
611 let res = v.validate("aaa.bbb.ccc");
612 assert!(matches!(res, Err(AuthError::JwtInvalid(_))));
613 }
614
615 #[test]
616 fn hs256_falsified_token_rejected() {
617 let mut csprng = OsRng;
621 let signing_key = SigningKey::generate(&mut csprng);
622 let pub_bytes = signing_key.verifying_key().to_bytes().to_vec();
623 let v = JwtValidator::new(JwtConfig::new("iss", "aud"));
624 v.add_key("kid1", Algorithm::EdDSA, KeyMaterial::Ed25519(pub_bytes));
625
626 let header_json = serde_json::json!({"alg": "HS256", "typ": "JWT", "kid": "kid1"});
629 let header_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
630 .encode(serde_json::to_vec(&header_json).unwrap());
631 let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
632 .encode(serde_json::to_vec(&serde_json::json!({"iss": "iss", "aud": "aud", "exp": chrono::Utc::now().timestamp() + 3600})).unwrap());
633 let signing_input = format!("{}.{}", header_b64, payload_b64);
634 use hmac::{Hmac, Mac};
635 use sha2::Sha256;
636 let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(b"hmac-secret").expect("hmac key");
637 mac.update(signing_input.as_bytes());
638 let sig_b64 =
639 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
640 let forged = format!("{}.{}.{}", header_b64, payload_b64, sig_b64);
641 let res = v.validate(&forged);
642 assert!(matches!(res, Err(AuthError::JwtInvalid(_))));
643 }
644
645 #[test]
646 fn tampered_payload_signature_check() {
647 let mut csprng = OsRng;
649 let signing_key = SigningKey::generate(&mut csprng);
650 let pub_bytes = signing_key.verifying_key().to_bytes().to_vec();
651 let v = JwtValidator::new(JwtConfig::new("https://idp.example.com", "agentguard"));
652 v.add_key("kid1", Algorithm::EdDSA, KeyMaterial::Ed25519(pub_bytes));
653 let claims = serde_json::json!({
654 "iss": "https://idp.example.com",
655 "aud": "agentguard",
656 "exp": chrono::Utc::now().timestamp() + 3600,
657 });
658 let token = sign_token(&signing_key, "kid1", claims);
659 let parts: Vec<&str> = token.split('.').collect();
662 let mut sig_chars: Vec<char> = parts[2].chars().collect();
663 let c0 = sig_chars[0];
664 sig_chars[0] = if c0 == 'A' { 'B' } else { 'A' };
665 let tampered_sig: String = sig_chars.into_iter().collect();
666 let tampered = format!("{}.{}.{}", parts[0], parts[1], tampered_sig);
667 let res = v.validate(&tampered);
668 assert!(res.is_err());
669 }
670
671 #[test]
672 fn empty_jwt_rejected() {
673 let v = JwtValidator::new(JwtConfig::new("iss", "aud"));
674 assert!(matches!(v.validate(""), Err(AuthError::JwtInvalid(_))));
675 assert!(matches!(v.validate("."), Err(AuthError::JwtInvalid(_))));
676 }
677
678 #[test]
679 fn nbf_in_future_rejected() {
680 let mut csprng = OsRng;
684 let signing_key = SigningKey::generate(&mut csprng);
685 let pub_bytes = signing_key.verifying_key().to_bytes().to_vec();
686 let v = JwtValidator::new(JwtConfig::new("iss", "aud"));
687 v.add_key("kid1", Algorithm::EdDSA, KeyMaterial::Ed25519(pub_bytes));
688 let now = chrono::Utc::now().timestamp();
689 let claims = serde_json::json!({
690 "iss": "iss",
691 "aud": "aud",
692 "exp": now + 7200,
693 "nbf": now + 3600,
694 });
695 let token = sign_token(&signing_key, "kid1", claims);
696 let res = v.validate(&token);
697 assert!(matches!(res, Err(AuthError::JwtInvalid(_))));
698 }
699
700 #[test]
701 fn array_aud_accepted() {
702 let mut csprng = OsRng;
706 let signing_key = SigningKey::generate(&mut csprng);
707 let pub_bytes = signing_key.verifying_key().to_bytes().to_vec();
708 let v = JwtValidator::new(JwtConfig::new("iss", "agentguard"));
709 v.add_key("kid1", Algorithm::EdDSA, KeyMaterial::Ed25519(pub_bytes));
710 let claims = serde_json::json!({
711 "iss": "iss",
712 "aud": ["agentguard", "other"],
713 "exp": chrono::Utc::now().timestamp() + 3600,
714 });
715 let token = sign_token(&signing_key, "kid1", claims);
716 assert!(v.validate(&token).is_ok());
717 }
718
719 #[test]
720 fn default_whitelist_is_eddsa_only() {
721 let cfg = JwtConfig::new("iss", "aud");
726 assert_eq!(cfg.algorithms, vec![Algorithm::EdDSA]);
727 }
728
729 #[test]
730 fn jwk_thumbprint_is_deterministic() {
731 let x1 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([1u8; 32]);
734 let x2 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([2u8; 32]);
735 let t1 = jwk_thumbprint_ed25519(&x1, "Ed25519");
736 let t2 = jwk_thumbprint_ed25519(&x2, "Ed25519");
737 assert_ne!(t1, t2);
738 assert_eq!(t1, jwk_thumbprint_ed25519(&x1, "Ed25519"));
740 }
741
742 #[test]
743 fn refresh_interval_from_env_overrides_default() {
744 std::env::set_var("AGENTGUARD_JWKS_REFRESH", "5s");
747 let cfg = JwtConfig::new("iss", "aud").with_jwks_refresh_from_env();
748 std::env::remove_var("AGENTGUARD_JWKS_REFRESH");
749 assert_eq!(cfg.jwks_refresh, Duration::from_secs(5));
750 }
751
752 #[test]
753 fn alg_none_rejected() {
754 let header = base64::engine::general_purpose::URL_SAFE_NO_PAD
758 .encode(br#"{"alg":"none","typ":"JWT"}"#);
759 let claims = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!(
760 r#"{{"iss":"iss","aud":"aud","exp":{}}}"#,
761 chrono::Utc::now().timestamp() + 3600
762 ));
763 let token = format!("{}.{}.", header, claims);
764 let v = JwtValidator::new(JwtConfig::new("iss", "aud"));
765 let err = v.validate(&token).unwrap_err();
766 assert!(matches!(err, AuthError::JwtInvalid(_)));
770 }
771
772 #[test]
773 fn missing_kid_rejected() {
774 let signing_key = ed25519_dalek::SigningKey::generate(&mut OsRng);
777 let pub_bytes = signing_key.verifying_key().to_bytes().to_vec();
778 let header_json = r#"{"alg":"EdDSA","typ":"JWT"}"#.to_string();
779 let header =
780 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(header_json.as_bytes());
781 let claims = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!(
782 r#"{{"iss":"iss","aud":"aud","exp":{}}}"#,
783 chrono::Utc::now().timestamp() + 3600
784 ));
785 let signing_input = format!("{header}.{claims}");
786 let sig = signing_key.sign(signing_input.as_bytes());
787 let sig_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig.to_bytes());
788 let token = format!("{signing_input}.{sig_b64}");
789 let v = JwtValidator::new(JwtConfig::new("iss", "aud"));
790 v.add_key("anykid", Algorithm::EdDSA, KeyMaterial::Ed25519(pub_bytes));
791 assert!(matches!(v.validate(&token), Err(AuthError::JwtInvalid(_))));
792 }
793
794 #[test]
795 fn wrong_alg_in_whitelist_rejected() {
796 let signing_key = ed25519_dalek::SigningKey::generate(&mut OsRng);
799 let pub_bytes = signing_key.verifying_key().to_bytes().to_vec();
800 let v = JwtValidator::new(JwtConfig::new("iss", "aud"));
801 v.add_key("kid1", Algorithm::EdDSA, KeyMaterial::Ed25519(pub_bytes));
802 let cfg = v.config.clone();
803 let claims = serde_json::json!({
808 "iss": "iss",
809 "aud": "aud",
810 "exp": chrono::Utc::now().timestamp() + 3600,
811 });
812 let token = sign_token(&signing_key, "wrong-kid", claims);
813 assert!(matches!(
814 v.validate(&token),
815 Err(AuthError::JwtUnknownKid(_))
816 ));
817 drop(cfg);
818 }
819}