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#[derive(Debug, Clone)]
26pub struct ValidatedHookClaims {
27    pub plugin_id: PluginId,
28    pub subject: UserId,
29    pub scopes: Vec<Permission>,
30}
31
32#[derive(Debug)]
33pub struct HookTokenValidator {
34    issuer: String,
35}
36
37impl HookTokenValidator {
38    #[must_use]
39    pub const fn new(issuer: String) -> Self {
40        Self { issuer }
41    }
42
43    pub fn validate_govern(
44        &self,
45        token: &str,
46        request_plugin_id: Option<&str>,
47    ) -> AuthResult<ValidatedHookClaims> {
48        self.validate(
49            token,
50            Permission::HookGovern,
51            "hook:govern",
52            request_plugin_id,
53        )
54    }
55
56    pub fn validate_track(
57        &self,
58        token: &str,
59        request_plugin_id: Option<&str>,
60    ) -> AuthResult<ValidatedHookClaims> {
61        self.validate(
62            token,
63            Permission::HookTrack,
64            "hook:track",
65            request_plugin_id,
66        )
67    }
68
69    fn validate(
70        &self,
71        token: &str,
72        required_scope: Permission,
73        required_scope_name: &'static str,
74        request_plugin_id: Option<&str>,
75    ) -> AuthResult<ValidatedHookClaims> {
76        let policy = ValidationPolicy::issuer_scoped(&self.issuer, &[JwtAudience::Hook]);
77        let claims = decode_rs256_claims(token, &policy)?;
78
79        if !claims.scope.contains(&required_scope) {
80            return Err(AuthError::HookScopeMissing(required_scope_name));
81        }
82        let plugin_id = claims
83            .plugin_id
84            .clone()
85            .ok_or(AuthError::HookPluginIdMissing)?;
86        if let Some(expected) = request_plugin_id
87            && expected != plugin_id.as_str()
88        {
89            return Err(AuthError::HookPluginIdMismatch {
90                expected: expected.to_owned(),
91                actual: plugin_id,
92            });
93        }
94
95        Ok(ValidatedHookClaims {
96            plugin_id: PluginId::new(plugin_id),
97            subject: UserId::new(claims.sub),
98            scopes: claims.scope,
99        })
100    }
101}