Skip to main content

systemprompt_security/jwt/
validate.rs

1//! The single RS256 decode primitive shared by every JWT validation path.
2//!
3//! Request-context middleware, session validation, hook-token validation, and
4//! the OAuth/MCP/agent domains all route through [`decode_rs256_claims`]. The
5//! `kid` lookup, RS256 enforcement, and the `exp`/`nbf`/issuer/audience policy
6//! live here and nowhere else, so the validators cannot drift apart. The only
7//! per-call knob is [`ValidationPolicy`], and every policy pins an issuer.
8//!
9//! Session tokens โ€” the main API middleware and the A2A server alike โ€” go
10//! through [`decode_session_claims`], which adds the act-chain depth limit and
11//! re-derives `user_type` from `scope`, so a forged or mis-minted type claim or
12//! an unbounded delegation chain cannot ride either path.
13//!
14//! Audience validation is always on: every policy carries a non-empty expected
15//! audience set, and a policy that reaches the decoder with an empty set is
16//! rejected as [`AuthError::EmptyAudiencePolicy`] rather than silently
17//! accepting any `aud`. [`ValidationPolicy::session_context`] pins
18//! [`JwtAudience::FIRST_PARTY`], so a token minted for a narrower surface
19//! (`hook`, a custom resource) cannot ride the session middleware.
20//!
21//! Federated subject-token verification (token-exchange) is deliberately *not*
22//! a caller: it resolves keys from an external issuer's JWKS rather than this
23//! deployment's signing authority, so it is a genuinely different operation.
24//!
25//! Copyright (c) systemprompt.io โ€” Business Source License 1.1.
26//! See <https://systemprompt.io> for licensing details.
27
28use jsonwebtoken::errors::{Error as JwtError, ErrorKind};
29use jsonwebtoken::{Algorithm, Header, Validation, decode, decode_header};
30use systemprompt_models::auth::{JwtAudience, JwtClaims, MAX_ACT_CHAIN_DEPTH, UserType};
31
32use crate::error::{AuthError, AuthResult};
33use crate::keys::authority;
34
35pub const JWT_LEEWAY_SECONDS: u64 = 30;
36
37#[derive(Debug, Clone)]
38pub struct ValidationPolicy<'a> {
39    leeway_seconds: u64,
40    issuer: &'a str,
41    audiences: &'a [JwtAudience],
42}
43
44impl<'a> ValidationPolicy<'a> {
45    #[must_use]
46    pub const fn session_context(issuer: &'a str) -> Self {
47        Self::issuer_scoped(issuer, JwtAudience::FIRST_PARTY)
48    }
49
50    #[must_use]
51    pub const fn issuer_scoped(issuer: &'a str, audiences: &'a [JwtAudience]) -> Self {
52        Self {
53            leeway_seconds: JWT_LEEWAY_SECONDS,
54            issuer,
55            audiences,
56        }
57    }
58}
59
60// Why: RFC 7515 ยง4.1.11: a `crit` header names extensions the recipient must
61// understand, and no verifier here understands any, so its presence alone
62// rejects the token (an empty list is itself malformed).
63pub fn reject_critical_header(header: &Header) -> Result<(), JwtError> {
64    if header.crit.is_some() {
65        return Err(ErrorKind::InvalidToken.into());
66    }
67    Ok(())
68}
69
70pub fn decode_rs256_claims(token: &str, policy: &ValidationPolicy<'_>) -> AuthResult<JwtClaims> {
71    if policy.audiences.is_empty() {
72        return Err(AuthError::EmptyAudiencePolicy);
73    }
74
75    let header = decode_header(token).map_err(AuthError::InvalidToken)?;
76    reject_critical_header(&header).map_err(AuthError::InvalidToken)?;
77    if header.alg != Algorithm::RS256 {
78        return Err(AuthError::UnsupportedAlgorithm {
79            got: format!("{:?}", header.alg),
80        });
81    }
82    let kid = header.kid.as_deref().ok_or(AuthError::MissingKid)?;
83    let key = authority::decoding_key_for_kid(kid)
84        .map_err(AuthError::KeyLookup)?
85        .ok_or_else(|| AuthError::UnknownKid(kid.to_owned()))?;
86
87    let mut validation = Validation::new(Algorithm::RS256);
88    validation.validate_exp = true;
89    validation.validate_nbf = true;
90    validation.leeway = policy.leeway_seconds;
91    validation.set_required_spec_claims(&["exp", "iss", "aud"]);
92    validation.set_issuer(&[policy.issuer]);
93    let audience_strs: Vec<&str> = policy.audiences.iter().map(JwtAudience::as_str).collect();
94    validation.set_audience(&audience_strs);
95
96    decode::<JwtClaims>(token, key, &validation)
97        .map(|data| data.claims)
98        .map_err(AuthError::InvalidToken)
99}
100
101pub fn decode_session_claims(token: &str, policy: &ValidationPolicy<'_>) -> AuthResult<JwtClaims> {
102    let claims = decode_rs256_claims(token, policy)?;
103
104    if let Some(act) = claims.act.as_ref() {
105        let depth = act.depth();
106        if depth > MAX_ACT_CHAIN_DEPTH {
107            return Err(AuthError::ActChainTooDeep {
108                depth,
109                max: MAX_ACT_CHAIN_DEPTH,
110            });
111        }
112    }
113
114    let derived_type = UserType::from_permissions(&claims.scope);
115    if derived_type != claims.user_type {
116        return Err(AuthError::UserTypeMismatch {
117            claimed: claims.user_type,
118            derived: derived_type,
119        });
120    }
121
122    Ok(claims)
123}