Skip to main content

systemprompt_api/routes/oauth/endpoints/token/generation/
client_credentials.rs

1//! `client_credentials` grant token generation (RFC 6749 §4.4).
2//!
3//! Mints an access token for a client acting as itself, intersecting the
4//! requested scopes with both the client's static grant and (for delegated
5//! user-tier roles) the owner's permissions. [`ClientCredentialsError`]
6//! partitions failures so the route maps recoverable client mistakes to 4xx.
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use std::str::FromStr;
12use systemprompt_identifiers::{ClientId, SessionId, SessionSource, UserId};
13use systemprompt_models::Config;
14use systemprompt_models::auth::{
15    AuthenticatedUser, JwtAudience, Permission, parse_permissions, permissions_to_string,
16};
17use systemprompt_oauth::OAuthState;
18use systemprompt_oauth::repository::OAuthRepository;
19use systemprompt_oauth::services::{JwtConfig, JwtSigningParams, generate_jwt};
20use systemprompt_traits::{CreateSessionInput, ExtractSignals};
21use thiserror::Error;
22
23use super::super::TokenResponse;
24use super::RequestOrigin;
25
26#[derive(Debug, Default)]
27pub struct ClientTokenOptions<'a> {
28    pub scope: Option<&'a str>,
29    pub plugin_id: Option<&'a str>,
30    pub audience: Option<&'a str>,
31}
32
33/// Failure modes of the `client_credentials` grant.
34///
35/// Variants partition by RFC 6749 §5.2 error code so the route handler can map
36/// each to the right HTTP status. Recoverable client mistakes (unknown client,
37/// orphaned or inactive owner, bad scope/audience) must surface as 4xx, never
38/// 5xx — the latter masks operator-visible misconfiguration as gateway
39/// failures and triggers spurious paging.
40#[derive(Debug, Error)]
41pub enum ClientCredentialsError {
42    #[error("Client not found")]
43    ClientNotFound,
44    #[error("Client owner not found")]
45    OwnerNotFound,
46    #[error("Client owner is not active")]
47    OwnerInactive,
48    #[error("Client owner has a non-uuid id ({0})")]
49    OwnerIdMalformed(String),
50    #[error("Invalid scope: {0}")]
51    InvalidScope(String),
52    #[error("Invalid audience: {0}")]
53    InvalidAudience(String),
54    #[error("Hook scopes require audience=hook on the token request")]
55    HookScopeRequiresHookAudience,
56    #[error("Failed to load client owner: {0}")]
57    UserProviderUnavailable(#[source] Box<dyn std::error::Error + Send + Sync>),
58    #[error("Failed to create session: {0}")]
59    SessionCreate(#[source] Box<dyn std::error::Error + Send + Sync>),
60    #[error("JWT signing failed: {0}")]
61    JwtSign(#[source] Box<dyn std::error::Error + Send + Sync>),
62    #[error("Config unavailable: {0}")]
63    ConfigUnavailable(#[source] Box<dyn std::error::Error + Send + Sync>),
64}
65
66#[cfg(feature = "test-api")]
67pub mod test_api {
68    pub use super::{authorize_client_grant, resolve_audience, scope_permissions};
69}
70
71struct OwnerProfile {
72    name: String,
73    email: String,
74    permissions: Vec<Permission>,
75}
76
77async fn load_active_owner(
78    state: &OAuthState,
79    owner_user_id: &UserId,
80) -> Result<OwnerProfile, ClientCredentialsError> {
81    let owner = state
82        .user_provider()
83        .find_by_id(owner_user_id)
84        .await
85        .map_err(|e| ClientCredentialsError::UserProviderUnavailable(e.into()))?
86        .ok_or(ClientCredentialsError::OwnerNotFound)?;
87    if !owner.is_active {
88        return Err(ClientCredentialsError::OwnerInactive);
89    }
90    Ok(OwnerProfile {
91        permissions: scope_permissions(&owner.roles),
92        name: owner.name,
93        email: owner.email,
94    })
95}
96
97async fn create_client_session(
98    state: &OAuthState,
99    origin: RequestOrigin<'_>,
100    owner_user_id: &UserId,
101    expires_in: i64,
102) -> Result<SessionId, ClientCredentialsError> {
103    let session_id = SessionId::new(format!("sess_{}", uuid::Uuid::new_v4().simple()));
104    let expires_at = chrono::Utc::now() + chrono::Duration::seconds(expires_in);
105    let analytics = state.analytics_provider().extract_analytics(
106        origin.headers,
107        ExtractSignals {
108            caller_ip: origin.caller_ip,
109            ..Default::default()
110        },
111    );
112
113    state
114        .analytics_provider()
115        .create_session(CreateSessionInput {
116            session_id: &session_id,
117            user_id: Some(owner_user_id),
118            analytics: &analytics,
119            session_source: SessionSource::Oauth,
120            is_bot: false,
121            is_ai_crawler: false,
122            expires_at,
123        })
124        .await
125        .map_err(|e| ClientCredentialsError::SessionCreate(e.into()))?;
126    Ok(session_id)
127}
128
129pub async fn generate_client_tokens(
130    repo: &OAuthRepository,
131    client_id: &ClientId,
132    origin: RequestOrigin<'_>,
133    state: &OAuthState,
134    options: ClientTokenOptions<'_>,
135) -> Result<TokenResponse, ClientCredentialsError> {
136    let global_config =
137        Config::get().map_err(|e| ClientCredentialsError::ConfigUnavailable(e.into()))?;
138    let expires_in = global_config.jwt_access_token_expiration;
139
140    let client = repo
141        .find_client_by_id(client_id)
142        .await
143        .map_err(|e| ClientCredentialsError::UserProviderUnavailable(e.into()))?
144        .ok_or(ClientCredentialsError::ClientNotFound)?;
145
146    let requested_permissions = match options.scope {
147        Some(scope_str) => parse_permissions(scope_str)
148            .map_err(|e| ClientCredentialsError::InvalidScope(e.to_string()))?,
149        None => scope_permissions(&client.scopes),
150    };
151
152    let owner = load_active_owner(state, &client.owner_user_id).await?;
153
154    let permissions =
155        authorize_client_grant(&requested_permissions, &client.scopes, &owner.permissions)?;
156
157    let audience = resolve_audience(options.audience, global_config)?;
158
159    if permissions.iter().any(Permission::is_hook_scope)
160        && !audience.iter().any(|a| matches!(a, JwtAudience::Hook))
161    {
162        return Err(ClientCredentialsError::HookScopeRequiresHookAudience);
163    }
164
165    let owner_uuid = uuid::Uuid::parse_str(client.owner_user_id.as_str())
166        .map_err(|e| ClientCredentialsError::OwnerIdMalformed(e.to_string()))?;
167    let authenticated =
168        AuthenticatedUser::new(owner_uuid, owner.name, owner.email, permissions.clone());
169
170    let config = JwtConfig {
171        permissions: permissions.clone(),
172        audience,
173        expires_in_hours: Some(global_config.jwt_access_token_expiration / 3600),
174        plugin_id: options.plugin_id.map(str::to_owned),
175        ..Default::default()
176    };
177    let session_id =
178        create_client_session(state, origin, &client.owner_user_id, expires_in).await?;
179
180    let signing = JwtSigningParams {
181        issuer: &global_config.jwt_issuer,
182    };
183    let jwt_token = generate_jwt(
184        &authenticated,
185        config,
186        uuid::Uuid::new_v4().to_string(),
187        &session_id,
188        &signing,
189    )
190    .map_err(|e| ClientCredentialsError::JwtSign(e.into()))?;
191
192    Ok(TokenResponse {
193        access_token: jwt_token,
194        token_type: "Bearer".to_owned(),
195        expires_in,
196        refresh_token: None,
197        scope: Some(
198            permissions
199                .iter()
200                .map(ToString::to_string)
201                .collect::<Vec<_>>()
202                .join(" "),
203        ),
204        issued_token_type: None,
205    })
206}
207
208#[cfg_attr(
209    not(feature = "test-api"),
210    expect(
211        unreachable_pub,
212        reason = "items are re-exported via `test_api` only when the feature is on"
213    )
214)]
215pub fn scope_permissions(scopes: &[String]) -> Vec<Permission> {
216    scopes
217        .iter()
218        .filter_map(|s| Permission::from_str(s).ok())
219        .collect()
220}
221
222// Why: service-tier scopes ([`Permission::is_service_scope`]) need only the
223// client grant, but user-tier roles are delegated authority and require both
224// the client *and* its owner to hold the permission — the RFC 6749 §4.4
225// owner is audit attribution, never authorization by itself.
226#[cfg_attr(
227    not(feature = "test-api"),
228    expect(
229        unreachable_pub,
230        reason = "items are re-exported via `test_api` only when the feature is on"
231    )
232)]
233pub fn authorize_client_grant(
234    requested: &[Permission],
235    client_scopes: &[String],
236    owner_permissions: &[Permission],
237) -> Result<Vec<Permission>, ClientCredentialsError> {
238    let client_allowed = scope_permissions(client_scopes);
239
240    let mut granted: Vec<Permission> = Vec::with_capacity(requested.len());
241    let mut missing_from_client: Vec<Permission> = Vec::new();
242    let mut missing_from_owner: Vec<Permission> = Vec::new();
243
244    for &perm in requested {
245        if !client_allowed.contains(&perm) {
246            missing_from_client.push(perm);
247            continue;
248        }
249        match perm {
250            Permission::HookGovern
251            | Permission::HookTrack
252            | Permission::Service
253            | Permission::A2a
254            | Permission::Mcp => granted.push(perm),
255            Permission::Admin | Permission::User | Permission::Anonymous => {
256                if owner_permissions.contains(&perm) {
257                    granted.push(perm);
258                } else {
259                    missing_from_owner.push(perm);
260                }
261            },
262        }
263    }
264
265    granted.sort_by_key(|p| std::cmp::Reverse(p.hierarchy_level()));
266    granted.dedup();
267
268    if granted.is_empty() {
269        let reason = if !missing_from_client.is_empty() {
270            format!(
271                "requested scopes not in client grant: {}",
272                permissions_to_string(&missing_from_client)
273            )
274        } else if !missing_from_owner.is_empty() {
275            format!(
276                "delegated scopes not held by owner: {}",
277                permissions_to_string(&missing_from_owner)
278            )
279        } else {
280            "no scopes requested".to_owned()
281        };
282        return Err(ClientCredentialsError::InvalidScope(reason));
283    }
284
285    Ok(granted)
286}
287
288#[cfg_attr(
289    not(feature = "test-api"),
290    expect(
291        unreachable_pub,
292        reason = "items are re-exported via `test_api` only when the feature is on"
293    )
294)]
295pub fn resolve_audience(
296    requested: Option<&str>,
297    global_config: &Config,
298) -> Result<Vec<JwtAudience>, ClientCredentialsError> {
299    let Some(value) = requested else {
300        return Ok(global_config.jwt_audiences.clone());
301    };
302
303    if !global_config
304        .allowed_resource_audiences
305        .iter()
306        .any(|allowed| allowed == value)
307    {
308        return Err(ClientCredentialsError::InvalidAudience(format!(
309            "'{value}' not in allowed audiences"
310        )));
311    }
312
313    JwtAudience::from_str(value)
314        .map(|aud| vec![aud])
315        .map_err(|e| ClientCredentialsError::InvalidAudience(format!("'{value}': {e}")))
316}