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`].
8//!
9//! Audience validation is always on: every policy carries a non-empty expected
10//! audience set, and a policy that reaches the decoder with an empty set is
11//! rejected as [`AuthError::EmptyAudiencePolicy`] rather than silently
12//! accepting any `aud`. [`ValidationPolicy::session_context`] pins
13//! [`JwtAudience::FIRST_PARTY`], so a token minted for a narrower surface
14//! (`hook`, a custom resource) cannot ride the session middleware.
15//!
16//! Federated subject-token verification (token-exchange) is deliberately *not*
17//! a caller: it resolves keys from an external issuer's JWKS rather than this
18//! deployment's signing authority, so it is a genuinely different operation.
19//!
20//! Copyright (c) systemprompt.io — Business Source License 1.1.
21//! See <https://systemprompt.io> for licensing details.
22
23use jsonwebtoken::{Algorithm, Validation, decode, decode_header};
24use systemprompt_models::auth::{JwtAudience, JwtClaims};
25
26use crate::error::{AuthError, AuthResult};
27use crate::keys::authority;
28
29pub const JWT_LEEWAY_SECONDS: u64 = 30;
30
31#[derive(Debug, Clone)]
32pub struct ValidationPolicy<'a> {
33    validate_exp: bool,
34    validate_nbf: bool,
35    leeway_seconds: u64,
36    issuer: Option<&'a str>,
37    audiences: &'a [JwtAudience],
38}
39
40impl<'a> ValidationPolicy<'a> {
41    #[must_use]
42    pub const fn session_context() -> Self {
43        Self {
44            validate_exp: true,
45            validate_nbf: true,
46            leeway_seconds: JWT_LEEWAY_SECONDS,
47            issuer: None,
48            audiences: JwtAudience::FIRST_PARTY,
49        }
50    }
51
52    #[must_use]
53    pub const fn issuer_scoped(issuer: &'a str, audiences: &'a [JwtAudience]) -> Self {
54        Self {
55            validate_exp: true,
56            validate_nbf: true,
57            leeway_seconds: JWT_LEEWAY_SECONDS,
58            issuer: Some(issuer),
59            audiences,
60        }
61    }
62}
63
64pub fn decode_rs256_claims(token: &str, policy: &ValidationPolicy<'_>) -> AuthResult<JwtClaims> {
65    if policy.audiences.is_empty() {
66        return Err(AuthError::EmptyAudiencePolicy);
67    }
68
69    let header = decode_header(token).map_err(AuthError::InvalidToken)?;
70    if header.alg != Algorithm::RS256 {
71        return Err(AuthError::UnsupportedAlgorithm {
72            got: format!("{:?}", header.alg),
73        });
74    }
75    let kid = header.kid.as_deref().ok_or(AuthError::MissingKid)?;
76    let key = authority::decoding_key_for_kid(kid)
77        .map_err(|e| AuthError::KeyLookup(e.to_string()))?
78        .ok_or_else(|| AuthError::UnknownKid(kid.to_owned()))?;
79
80    let mut validation = Validation::new(Algorithm::RS256);
81    validation.validate_exp = policy.validate_exp;
82    validation.validate_nbf = policy.validate_nbf;
83    validation.leeway = policy.leeway_seconds;
84    if let Some(issuer) = policy.issuer {
85        validation.set_issuer(&[issuer]);
86    }
87    let audience_strs: Vec<&str> = policy.audiences.iter().map(JwtAudience::as_str).collect();
88    validation.set_audience(&audience_strs);
89
90    decode::<JwtClaims>(token, key, &validation)
91        .map(|data| data.claims)
92        .map_err(AuthError::InvalidToken)
93}