systemprompt_security/auth/
hook_token.rs1use jsonwebtoken::{Algorithm, Validation, decode, decode_header};
20use systemprompt_models::auth::{JwtAudience, JwtClaims, Permission};
21
22use super::validation::JWT_LEEWAY_SECONDS;
23use crate::error::{AuthError, AuthResult};
24use crate::keys::authority;
25
26#[derive(Debug, Clone)]
29pub struct ValidatedHookClaims {
30 pub plugin_id: String,
31 pub subject: String,
32 pub scopes: Vec<Permission>,
33}
34
35#[derive(Debug)]
36pub struct HookTokenValidator {
37 issuer: String,
38}
39
40impl HookTokenValidator {
41 #[must_use]
42 pub const fn new(issuer: String) -> Self {
43 Self { issuer }
44 }
45
46 pub fn validate_govern(
48 &self,
49 token: &str,
50 request_plugin_id: Option<&str>,
51 ) -> AuthResult<ValidatedHookClaims> {
52 self.validate(
53 token,
54 Permission::HookGovern,
55 "hook:govern",
56 request_plugin_id,
57 )
58 }
59
60 pub fn validate_track(
62 &self,
63 token: &str,
64 request_plugin_id: Option<&str>,
65 ) -> AuthResult<ValidatedHookClaims> {
66 self.validate(
67 token,
68 Permission::HookTrack,
69 "hook:track",
70 request_plugin_id,
71 )
72 }
73
74 fn validate(
75 &self,
76 token: &str,
77 required_scope: Permission,
78 required_scope_name: &'static str,
79 request_plugin_id: Option<&str>,
80 ) -> AuthResult<ValidatedHookClaims> {
81 let header = decode_header(token).map_err(AuthError::InvalidToken)?;
82 if header.alg != Algorithm::RS256 {
83 return Err(AuthError::UnsupportedAlgorithm);
84 }
85 let kid = header.kid.as_deref().ok_or(AuthError::MissingKid)?;
86 let key = authority::decoding_key_for_kid(kid)
87 .map_err(|e| AuthError::KeyLookup(e.to_string()))?
88 .ok_or_else(|| AuthError::UnknownKid(kid.to_owned()))?;
89
90 let mut validation = Validation::new(Algorithm::RS256);
91 validation.leeway = JWT_LEEWAY_SECONDS;
92 validation.validate_nbf = true;
93 validation.set_issuer(&[&self.issuer]);
94 validation.set_audience(&[JwtAudience::Hook.as_str()]);
95
96 let token_data =
97 decode::<JwtClaims>(token, key, &validation).map_err(AuthError::InvalidToken)?;
98
99 let claims = token_data.claims;
100
101 if !claims.aud.iter().any(|a| matches!(a, JwtAudience::Hook)) {
102 return Err(AuthError::HookAudienceMissing);
103 }
104 if !claims.scope.contains(&required_scope) {
105 return Err(AuthError::HookScopeMissing(required_scope_name));
106 }
107 let plugin_id = claims
108 .plugin_id
109 .clone()
110 .ok_or(AuthError::HookPluginIdMissing)?;
111 if let Some(expected) = request_plugin_id
112 && expected != plugin_id
113 {
114 return Err(AuthError::HookPluginIdMismatch {
115 expected: expected.to_owned(),
116 actual: plugin_id,
117 });
118 }
119
120 Ok(ValidatedHookClaims {
121 plugin_id,
122 subject: claims.sub,
123 scopes: claims.scope,
124 })
125 }
126}