Skip to main content

systemprompt_security/auth/
hook_token.rs

1//! Validator for plugin-scoped hook JWTs.
2//!
3//! Hook tokens are minted by the OAuth `client_credentials` grant with
4//! `audience=hook`, `scope=hook:govern hook:track`, and a custom `plugin_id`
5//! claim. Cowork hook subprocesses present them on `Authorization: Bearer …`
6//! when `POSTing` to the gateway's `/api/public/hooks/{govern,track}`
7//! endpoints.
8//!
9//! [`HookTokenValidator`] enforces, in this order:
10//!
11//! 1. JWT signature, issuer, and `aud` contains `hook`.
12//! 2. `scope` contains the required permission for the endpoint
13//!    ([`Permission::HookGovern`] or [`Permission::HookTrack`]).
14//! 3. `plugin_id` claim is present.
15//! 4. (Optional) `plugin_id` claim equals the `plugin_id` query parameter on
16//!    the request, so a token issued for plugin A can't drive an event into
17//!    plugin B.
18
19use 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/// Successfully-validated hook token claims, projected to the bits the
27/// caller needs to dispatch a govern/track decision.
28#[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    /// Validate a hook token for the `/api/public/hooks/govern` endpoint.
47    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    /// Validate a hook token for the `/api/public/hooks/track` endpoint.
61    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}