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//! Federated subject-token verification (token-exchange) is deliberately *not*
10//! a caller: it resolves keys from an external issuer's JWKS rather than this
11//! deployment's signing authority, so it is a genuinely different operation.
12
13use jsonwebtoken::{Algorithm, Validation, decode, decode_header};
14use systemprompt_models::auth::{JwtAudience, JwtClaims};
15
16use crate::error::{AuthError, AuthResult};
17use crate::keys::authority;
18
19/// Clock-skew tolerance (seconds) for `exp`/`nbf`/`iat`. Pinned explicitly so
20/// deployments see the value in review rather than inheriting the
21/// `jsonwebtoken` default.
22pub const JWT_LEEWAY_SECONDS: u64 = 30;
23
24/// The claim checks applied on top of the always-on signature, RS256, and
25/// `kid` enforcement. An empty `audiences` slice disables the `aud` check.
26#[derive(Debug, Clone, Default)]
27pub struct ValidationPolicy<'a> {
28    pub validate_exp: bool,
29    pub validate_nbf: bool,
30    pub leeway_seconds: u64,
31    pub issuer: Option<&'a str>,
32    pub audiences: &'a [JwtAudience],
33}
34
35impl<'a> ValidationPolicy<'a> {
36    /// Stateless decode for request-context middleware that performs its own
37    /// DB-backed session and user checks after decode. Validates `exp` and
38    /// `nbf` (with leeway); issuer and audience are enforced by the stateful
39    /// validators that hold deployment config, not here.
40    #[must_use]
41    pub const fn session_context() -> Self {
42        Self {
43            validate_exp: true,
44            validate_nbf: true,
45            leeway_seconds: JWT_LEEWAY_SECONDS,
46            issuer: None,
47            audiences: &[],
48        }
49    }
50
51    /// Full first-party validation: `exp` + `nbf` + issuer pinning + audience
52    /// membership, with the standard leeway.
53    #[must_use]
54    pub const fn issuer_scoped(issuer: &'a str, audiences: &'a [JwtAudience]) -> Self {
55        Self {
56            validate_exp: true,
57            validate_nbf: true,
58            leeway_seconds: JWT_LEEWAY_SECONDS,
59            issuer: Some(issuer),
60            audiences,
61        }
62    }
63}
64
65pub fn decode_rs256_claims(token: &str, policy: &ValidationPolicy<'_>) -> AuthResult<JwtClaims> {
66    let header = decode_header(token).map_err(AuthError::InvalidToken)?;
67    if header.alg != Algorithm::RS256 {
68        return Err(AuthError::UnsupportedAlgorithm {
69            got: format!("{:?}", header.alg),
70        });
71    }
72    let kid = header.kid.as_deref().ok_or(AuthError::MissingKid)?;
73    let key = authority::decoding_key_for_kid(kid)
74        .map_err(|e| AuthError::KeyLookup(e.to_string()))?
75        .ok_or_else(|| AuthError::UnknownKid(kid.to_owned()))?;
76
77    let mut validation = Validation::new(Algorithm::RS256);
78    validation.validate_exp = policy.validate_exp;
79    validation.validate_nbf = policy.validate_nbf;
80    validation.leeway = policy.leeway_seconds;
81    if let Some(issuer) = policy.issuer {
82        validation.set_issuer(&[issuer]);
83    }
84    if policy.audiences.is_empty() {
85        validation.validate_aud = false;
86    } else {
87        let audience_strs: Vec<&str> = policy.audiences.iter().map(JwtAudience::as_str).collect();
88        validation.set_audience(&audience_strs);
89    }
90
91    decode::<JwtClaims>(token, key, &validation)
92        .map(|data| data.claims)
93        .map_err(AuthError::InvalidToken)
94}