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, DecodingKey, Validation, decode};
20use systemprompt_models::auth::{JwtAudience, JwtClaims, Permission};
21
22use crate::error::{AuthError, AuthResult};
23
24/// Successfully-validated hook token claims, projected to the bits the
25/// caller needs to dispatch a govern/track decision.
26#[derive(Debug, Clone)]
27pub struct ValidatedHookClaims {
28    pub plugin_id: String,
29    pub subject: String,
30    pub scopes: Vec<Permission>,
31}
32
33#[derive(Debug)]
34pub struct HookTokenValidator {
35    secret: String,
36    issuer: String,
37}
38
39impl HookTokenValidator {
40    #[must_use]
41    pub const fn new(secret: String, issuer: String) -> Self {
42        Self { secret, issuer }
43    }
44
45    /// Validate a hook token for the `/api/public/hooks/govern` endpoint.
46    pub fn validate_govern(
47        &self,
48        token: &str,
49        request_plugin_id: Option<&str>,
50    ) -> AuthResult<ValidatedHookClaims> {
51        self.validate(
52            token,
53            Permission::HookGovern,
54            "hook:govern",
55            request_plugin_id,
56        )
57    }
58
59    /// Validate a hook token for the `/api/public/hooks/track` endpoint.
60    pub fn validate_track(
61        &self,
62        token: &str,
63        request_plugin_id: Option<&str>,
64    ) -> AuthResult<ValidatedHookClaims> {
65        self.validate(
66            token,
67            Permission::HookTrack,
68            "hook:track",
69            request_plugin_id,
70        )
71    }
72
73    fn validate(
74        &self,
75        token: &str,
76        required_scope: Permission,
77        required_scope_name: &'static str,
78        request_plugin_id: Option<&str>,
79    ) -> AuthResult<ValidatedHookClaims> {
80        let mut validation = Validation::new(Algorithm::HS256);
81        validation.set_issuer(&[&self.issuer]);
82        validation.set_audience(&[JwtAudience::Hook.as_str()]);
83
84        let token_data = decode::<JwtClaims>(
85            token,
86            &DecodingKey::from_secret(self.secret.as_bytes()),
87            &validation,
88        )
89        .map_err(AuthError::InvalidToken)?;
90
91        let claims = token_data.claims;
92
93        if !claims.aud.iter().any(|a| matches!(a, JwtAudience::Hook)) {
94            return Err(AuthError::HookAudienceMissing);
95        }
96        if !claims.scope.contains(&required_scope) {
97            return Err(AuthError::HookScopeMissing(required_scope_name));
98        }
99        let plugin_id = claims
100            .plugin_id
101            .clone()
102            .ok_or(AuthError::HookPluginIdMissing)?;
103        if let Some(expected) = request_plugin_id
104            && expected != plugin_id
105        {
106            return Err(AuthError::HookPluginIdMismatch {
107                expected: expected.to_string(),
108                actual: plugin_id,
109            });
110        }
111
112        Ok(ValidatedHookClaims {
113            plugin_id,
114            subject: claims.sub,
115            scopes: claims.scope,
116        })
117    }
118}