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 systemprompt_identifiers::{PluginId, UserId};
20use systemprompt_models::auth::{JwtAudience, Permission};
21
22use crate::error::{AuthError, AuthResult};
23use crate::jwt::{ValidationPolicy, decode_rs256_claims};
24
25/// Successfully-validated hook token claims, projected to the bits the
26/// caller needs to dispatch a govern/track decision.
27#[derive(Debug, Clone)]
28pub struct ValidatedHookClaims {
29    pub plugin_id: PluginId,
30    pub subject: UserId,
31    pub scopes: Vec<Permission>,
32}
33
34#[derive(Debug)]
35pub struct HookTokenValidator {
36    issuer: String,
37}
38
39impl HookTokenValidator {
40    #[must_use]
41    pub const fn new(issuer: String) -> Self {
42        Self { 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 policy = ValidationPolicy::issuer_scoped(&self.issuer, &[JwtAudience::Hook]);
81        let claims = decode_rs256_claims(token, &policy)?;
82
83        if !claims.scope.contains(&required_scope) {
84            return Err(AuthError::HookScopeMissing(required_scope_name));
85        }
86        let plugin_id = claims
87            .plugin_id
88            .clone()
89            .ok_or(AuthError::HookPluginIdMissing)?;
90        if let Some(expected) = request_plugin_id
91            && expected != plugin_id.as_str()
92        {
93            return Err(AuthError::HookPluginIdMismatch {
94                expected: expected.to_owned(),
95                actual: plugin_id,
96            });
97        }
98
99        Ok(ValidatedHookClaims {
100            plugin_id: PluginId::new(plugin_id),
101            subject: UserId::new(claims.sub),
102            scopes: claims.scope,
103        })
104    }
105}