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//!
19//! Copyright (c) systemprompt.io — Business Source License 1.1.
20//! See <https://systemprompt.io> for licensing details.
21
22use systemprompt_identifiers::{PluginId, UserId};
23use systemprompt_models::auth::{JwtAudience, Permission};
24
25use crate::error::{AuthError, AuthResult};
26use crate::jwt::{ValidationPolicy, decode_rs256_claims};
27
28#[derive(Debug, Clone)]
29pub struct ValidatedHookClaims {
30    pub plugin_id: PluginId,
31    pub subject: UserId,
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(
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    pub fn validate_track(
60        &self,
61        token: &str,
62        request_plugin_id: Option<&str>,
63    ) -> AuthResult<ValidatedHookClaims> {
64        self.validate(
65            token,
66            Permission::HookTrack,
67            "hook:track",
68            request_plugin_id,
69        )
70    }
71
72    fn validate(
73        &self,
74        token: &str,
75        required_scope: Permission,
76        required_scope_name: &'static str,
77        request_plugin_id: Option<&str>,
78    ) -> AuthResult<ValidatedHookClaims> {
79        let policy = ValidationPolicy::issuer_scoped(&self.issuer, &[JwtAudience::Hook]);
80        let claims = decode_rs256_claims(token, &policy)?;
81
82        if !claims.scope.contains(&required_scope) {
83            return Err(AuthError::HookScopeMissing(required_scope_name));
84        }
85        let plugin_id = claims
86            .plugin_id
87            .clone()
88            .ok_or(AuthError::HookPluginIdMissing)?;
89        if let Some(expected) = request_plugin_id
90            && expected != plugin_id.as_str()
91        {
92            return Err(AuthError::HookPluginIdMismatch {
93                expected: expected.to_owned(),
94                actual: plugin_id,
95            });
96        }
97
98        Ok(ValidatedHookClaims {
99            plugin_id: PluginId::new(plugin_id),
100            subject: UserId::new(claims.sub),
101            scopes: claims.scope,
102        })
103    }
104}