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::{Algorithm, Validation, decode, decode_header};
29use systemprompt_models::auth::{JwtAudience, JwtClaims, MAX_ACT_CHAIN_DEPTH, UserType};
30
31use crate::error::{AuthError, AuthResult};
32use crate::keys::authority;
33
34pub const JWT_LEEWAY_SECONDS: u64 = 30;
35
36#[derive(Debug, Clone)]
37pub struct ValidationPolicy<'a> {
38    leeway_seconds: u64,
39    issuer: &'a str,
40    audiences: &'a [JwtAudience],
41}
42
43impl<'a> ValidationPolicy<'a> {
44    #[must_use]
45    pub const fn session_context(issuer: &'a str) -> Self {
46        Self::issuer_scoped(issuer, JwtAudience::FIRST_PARTY)
47    }
48
49    #[must_use]
50    pub const fn issuer_scoped(issuer: &'a str, audiences: &'a [JwtAudience]) -> Self {
51        Self {
52            leeway_seconds: JWT_LEEWAY_SECONDS,
53            issuer,
54            audiences,
55        }
56    }
57}
58
59pub fn decode_rs256_claims(token: &str, policy: &ValidationPolicy<'_>) -> AuthResult<JwtClaims> {
60    if policy.audiences.is_empty() {
61        return Err(AuthError::EmptyAudiencePolicy);
62    }
63
64    let header = decode_header(token).map_err(AuthError::InvalidToken)?;
65    if header.alg != Algorithm::RS256 {
66        return Err(AuthError::UnsupportedAlgorithm {
67            got: format!("{:?}", header.alg),
68        });
69    }
70    let kid = header.kid.as_deref().ok_or(AuthError::MissingKid)?;
71    let key = authority::decoding_key_for_kid(kid)
72        .map_err(AuthError::KeyLookup)?
73        .ok_or_else(|| AuthError::UnknownKid(kid.to_owned()))?;
74
75    let mut validation = Validation::new(Algorithm::RS256);
76    validation.validate_exp = true;
77    validation.validate_nbf = true;
78    validation.leeway = policy.leeway_seconds;
79    validation.set_issuer(&[policy.issuer]);
80    let audience_strs: Vec<&str> = policy.audiences.iter().map(JwtAudience::as_str).collect();
81    validation.set_audience(&audience_strs);
82
83    decode::<JwtClaims>(token, key, &validation)
84        .map(|data| data.claims)
85        .map_err(AuthError::InvalidToken)
86}
87
88pub fn decode_session_claims(token: &str, policy: &ValidationPolicy<'_>) -> AuthResult<JwtClaims> {
89    let claims = decode_rs256_claims(token, policy)?;
90
91    if let Some(act) = claims.act.as_ref() {
92        let depth = act.depth();
93        if depth > MAX_ACT_CHAIN_DEPTH {
94            return Err(AuthError::ActChainTooDeep {
95                depth,
96                max: MAX_ACT_CHAIN_DEPTH,
97            });
98        }
99    }
100
101    let derived_type = UserType::from_permissions(&claims.scope);
102    if derived_type != claims.user_type {
103        return Err(AuthError::UserTypeMismatch {
104            claimed: claims.user_type,
105            derived: derived_type,
106        });
107    }
108
109    Ok(claims)
110}