agentguard_auth/
jwt.rs

1//! JWT validation per RFC 7519 + RFC 8725 BCP.
2//!
3//! Supports algorithm whitelist, `kid`-based key resolution, `iss`/`aud`/`exp`
4//! validation, and JWKS refresh.
5
6use 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/// Configuration for [`JwtValidator`].
16#[derive(Debug, Clone)]
17pub struct JwtConfig {
18    /// Expected `iss` claim.
19    pub issuer: String,
20    /// Expected `aud` claim.
21    pub audience: String,
22    /// Whitelist of accepted signing algorithms (RFC 8725 §3.1).
23    /// Default = `[EdDSA]` because `verify_signature` only implements
24    /// Ed25519 today; RS256/ES256 are reserved for v2.1. Operators
25    /// who need them today should keep the default and configure
26    /// their IdP to sign with EdDSA (see `with_algorithms_try`).
27    pub algorithms: Vec<Algorithm>,
28    /// JWKS URI (optional). If set, keys are fetched from this URL.
29    pub jwks_uri: Option<String>,
30    /// JWKS refresh interval. Default = 30 s. Read from the
31    /// `AGENTGUARD_JWKS_REFRESH` env var (humantime format).
32    pub jwks_refresh: Duration,
33    /// Clock skew tolerance for `exp`/`nbf`.
34    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    /// Replace the accepted-algorithm whitelist. Pass only algorithms
50    /// that the verifier actually supports today; the set of
51    /// supported algorithms is exposed via
52    /// [`crate::SUPPORTED_ALGORITHMS`].
53    ///
54    /// # Errors
55    /// Returns `AuthError::JwtInvalid` listing the unsupported entries
56    /// so misconfiguration is surfaced at construction time, not at
57    /// the first incoming token.
58    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    /// Lossless infallible variant: only succeeds if every entry is in
76    /// `SUPPORTED_ALGORITHMS`, otherwise logs a warning and falls back
77    /// to the default `[EdDSA]`. Use this when reading configuration
78    /// from untrusted sources where panicking on bad input is
79    /// undesirable (e.g. OIDC discovery).
80    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    /// Read the JWKS refresh interval from `AGENTGUARD_JWKS_REFRESH`
104    /// (humantime). Falls back to the default when unset or unparseable.
105    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/// A successfully validated JWT.
121#[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/// Validates JWTs against a [`JwtConfig`] and [`KeyRegistry`].
128#[derive(Clone)]
129pub struct JwtValidator {
130    config: Arc<JwtConfig>,
131    keys: Arc<KeyRegistry>,
132}
133
134impl JwtValidator {
135    /// Build a new validator with an empty key registry. Call [`Self::add_key`]
136    /// for each trusted issuer.
137    pub fn new(config: JwtConfig) -> Self {
138        Self {
139            config: Arc::new(config),
140            keys: Arc::new(KeyRegistry::new()),
141        }
142    }
143
144    /// Register a key for verification.
145    pub fn add_key(&self, kid: impl Into<String>, alg: Algorithm, key: KeyMaterial) {
146        self.keys.add(kid, alg, key)
147    }
148
149    /// Validate a JWT. The `kid` and `alg` from the header are used to find
150    /// a matching key.
151    ///
152    /// Note: cryptographic verification is implemented for EdDSA (HMAC-like
153    /// signature) in this release; RS256/ES256 support requires adding the
154    /// `jsonwebtoken` crate as a feature in v2.1.
155    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    /// Parse a compact JWS into its constituent parts. Returns the decoded
164    /// header, claims, signing input bytes, and signature bytes.
165    #[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    /// Extract and validate the `alg` and `kid` from the JOSE header.
198    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    /// Verify the JWS signature using one of the keys registered for `(kid, alg)`.
223    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    /// Validate the standard claims: `iss` (required), `aud` (matches),
245    /// `exp` (required, not expired), `nbf` (not in future).
246    fn check_claims(&self, claims: &serde_json::Value) -> Result<()> {
247        // iss (RFC 8725 §3.1: required).
248        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        // aud: must contain self.config.audience.
262        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        // exp (required, RFC 8725 §3.12).
277        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        // nbf (optional): not in the future (with skew).
288        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        // iat (optional): not in the future (with skew).
295        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    /// Fetch JWKS from `jwks_uri` and populate the key registry. Idempotent.
305    ///
306    /// Supports Ed25519 keys (kty=OK, crv=Ed25519, x=base64url-32-bytes).
307    /// Other key types are skipped with a tracing warning.
308    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        // Bounded client: 10 s total, 5 s connect, no redirects (would
316        // let an attacker point us at an unrelated host), 1 MiB body cap.
317        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            // Decode per key-type.
355            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                // ponytail: derive kid from the JWK itself when the
372                // IdP didn't supply one. The previous `jwks-{alg}`
373                // scheme collided when an IdP returned multiple
374                // kid-less keys, silently dropping all but the last.
375                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                // RSA/ECDSA/HS256 would be supported here in v2.1.
383                tracing::debug!(alg = ?alg, "skipping non-Ed25519 JWKS key");
384            }
385        }
386        Ok(())
387    }
388
389    /// Background task that periodically refreshes JWKS.
390    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// Suppress unused warning
463#[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        // RS256 parses out of a JOSE header but verify_signature
507        // only implements EdDSA today. The constructor must surface
508        // the gap instead of letting the operator discover it on the
509        // first inbound token.
510        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        // Lossy variant drops unsupported entries and keeps the rest;
523        // an all-unsupported list falls back to the default `[EdDSA]`
524        // so OIDC discovery never breaks a server that just received
525        // a buggy IdP config.
526        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        // RFC 8725 §3.12: a JWT without `exp` is dangerous (a leaked
555        // token is forever valid). Reject at the validator.
556        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            // no exp
565        });
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        // iat > now+skew should be rejected as suspicious (the token
574        // claims to have been issued in the future).
575        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, // 2h in future
585        });
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        // Algorithm-confusion attack: a token with alg=HS256 + a forged
618        // HMAC-SHA256 signature is rejected even if we have an Ed25519
619        // key registered for the same kid.
620        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        // Forge a token with alg=HS256 and a real HMAC-SHA256 over the
627        // signing input. Our EdDSA-only verifier must reject it.
628        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        // Mutating the signature segment must fail verification.
648        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        // Replace a character in the signature segment with a different
660        // valid base64url char. The signature won't verify after the change.
661        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        // NotBefore: a token whose nbf is 1 hour in the future must
681        // be rejected even if its exp is 1 hour later (also in the future
682        // but past nbf).
683        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        // RFC 7519 §4.1.3: aud may be a single string OR an array of
703        // strings. A token with aud=["agentguard", "other"] is valid
704        // when we expect "agentguard".
705        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        // RS256/ES256 are not yet implemented in verify_signature. A
722        // default whitelist that includes them lets an attacker pick
723        // a "not implemented" path and get a confusing error instead
724        // of a clean rejection.
725        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        // The thumbprint must depend on the public key bytes — two
732        // keys with different x produce different thumbprints.
733        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        // Same key → same thumbprint.
739        assert_eq!(t1, jwk_thumbprint_ed25519(&x1, "Ed25519"));
740    }
741
742    #[test]
743    fn refresh_interval_from_env_overrides_default() {
744        // AGENTGUARD_JWKS_REFRESH=5s should produce a 5-second
745        // interval on a fresh config.
746        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        // alg=none must never be accepted — RFC 8725 §3.2.
755        // Forge a token with header {"alg":"none","typ":"JWT"}
756        // and a zero signature.
757        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        // Could be JwtInvalid (unsupported alg) or JwtInvalid
767        // (missing kid) depending on parse order — both are
768        // rejection. What must NOT happen is Ok.
769        assert!(matches!(err, AuthError::JwtInvalid(_)));
770    }
771
772    #[test]
773    fn missing_kid_rejected() {
774        // RFC 7517 §4.1.4: a JWS header without `kid` cannot be
775        // unambiguously bound to a key. Reject.
776        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        // A token with alg=EdDSA but our whitelist only allows HS256
797        // must reject at the header check, not at signature verify.
798        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        // Force the whitelist to NOT include EdDSA — the validator
804        // is constructed with the default whitelist, so we test by
805        // adding a token whose alg is in the whitelist but using a
806        // non-registered key.
807        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}