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 axum::http::HeaderMap;
12use std::str::FromStr;
13use systemprompt_identifiers::{ClientId, SessionId, SessionSource, UserId};
14use systemprompt_models::Config;
15use systemprompt_models::auth::{
16    AuthenticatedUser, JwtAudience, Permission, parse_permissions, permissions_to_string,
17};
18use systemprompt_oauth::OAuthState;
19use systemprompt_oauth::repository::OAuthRepository;
20use systemprompt_oauth::services::{JwtConfig, JwtSigningParams, generate_jwt};
21use systemprompt_traits::CreateSessionInput;
22use thiserror::Error;
23
24use super::super::TokenResponse;
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
66struct OwnerProfile {
67    name: String,
68    email: String,
69    permissions: Vec<Permission>,
70}
71
72async fn load_active_owner(
73    state: &OAuthState,
74    owner_user_id: &UserId,
75) -> Result<OwnerProfile, ClientCredentialsError> {
76    let owner = state
77        .user_provider()
78        .find_by_id(owner_user_id)
79        .await
80        .map_err(|e| ClientCredentialsError::UserProviderUnavailable(e.into()))?
81        .ok_or(ClientCredentialsError::OwnerNotFound)?;
82    if !owner.is_active {
83        return Err(ClientCredentialsError::OwnerInactive);
84    }
85    Ok(OwnerProfile {
86        permissions: scope_permissions(&owner.roles),
87        name: owner.name,
88        email: owner.email,
89    })
90}
91
92async fn create_client_session(
93    state: &OAuthState,
94    headers: &HeaderMap,
95    owner_user_id: &UserId,
96    expires_in: i64,
97) -> Result<SessionId, ClientCredentialsError> {
98    let session_id = SessionId::new(format!("sess_{}", uuid::Uuid::new_v4().simple()));
99    let expires_at = chrono::Utc::now() + chrono::Duration::seconds(expires_in);
100    let analytics = state.analytics_provider().extract_analytics(headers, None);
101
102    state
103        .analytics_provider()
104        .create_session(CreateSessionInput {
105            session_id: &session_id,
106            user_id: Some(owner_user_id),
107            analytics: &analytics,
108            session_source: SessionSource::Oauth,
109            is_bot: false,
110            is_ai_crawler: false,
111            expires_at,
112        })
113        .await
114        .map_err(|e| ClientCredentialsError::SessionCreate(e.into()))?;
115    Ok(session_id)
116}
117
118pub async fn generate_client_tokens(
119    repo: &OAuthRepository,
120    client_id: &ClientId,
121    headers: &HeaderMap,
122    state: &OAuthState,
123    options: ClientTokenOptions<'_>,
124) -> Result<TokenResponse, ClientCredentialsError> {
125    let global_config =
126        Config::get().map_err(|e| ClientCredentialsError::ConfigUnavailable(e.into()))?;
127    let expires_in = global_config.jwt_access_token_expiration;
128
129    let client = repo
130        .find_client_by_id(client_id)
131        .await
132        .map_err(|e| ClientCredentialsError::UserProviderUnavailable(e.into()))?
133        .ok_or(ClientCredentialsError::ClientNotFound)?;
134
135    let requested_permissions = match options.scope {
136        Some(scope_str) => parse_permissions(scope_str)
137            .map_err(|e| ClientCredentialsError::InvalidScope(e.to_string()))?,
138        None => scope_permissions(&client.scopes),
139    };
140
141    let owner = load_active_owner(state, &client.owner_user_id).await?;
142
143    let permissions =
144        authorize_client_grant(&requested_permissions, &client.scopes, &owner.permissions)?;
145
146    let audience = resolve_audience(options.audience, global_config)?;
147
148    if permissions.iter().any(Permission::is_hook_scope)
149        && !audience.iter().any(|a| matches!(a, JwtAudience::Hook))
150    {
151        return Err(ClientCredentialsError::HookScopeRequiresHookAudience);
152    }
153
154    let owner_uuid = uuid::Uuid::parse_str(client.owner_user_id.as_str())
155        .map_err(|e| ClientCredentialsError::OwnerIdMalformed(e.to_string()))?;
156    let authenticated =
157        AuthenticatedUser::new(owner_uuid, owner.name, owner.email, permissions.clone());
158
159    let config = JwtConfig {
160        permissions: permissions.clone(),
161        audience,
162        expires_in_hours: Some(global_config.jwt_access_token_expiration / 3600),
163        plugin_id: options.plugin_id.map(str::to_owned),
164        ..Default::default()
165    };
166    let session_id =
167        create_client_session(state, headers, &client.owner_user_id, expires_in).await?;
168
169    let signing = JwtSigningParams {
170        issuer: &global_config.jwt_issuer,
171    };
172    let jwt_token = generate_jwt(
173        &authenticated,
174        config,
175        uuid::Uuid::new_v4().to_string(),
176        &session_id,
177        &signing,
178    )
179    .map_err(|e| ClientCredentialsError::JwtSign(e.into()))?;
180
181    Ok(TokenResponse {
182        access_token: jwt_token,
183        token_type: "Bearer".to_owned(),
184        expires_in,
185        refresh_token: None,
186        scope: Some(
187            permissions
188                .iter()
189                .map(ToString::to_string)
190                .collect::<Vec<_>>()
191                .join(" "),
192        ),
193        issued_token_type: None,
194    })
195}
196
197fn scope_permissions(scopes: &[String]) -> Vec<Permission> {
198    scopes
199        .iter()
200        .filter_map(|s| Permission::from_str(s).ok())
201        .collect()
202}
203
204/// Decide which of the requested scopes a `client_credentials` token may carry.
205///
206/// `client_credentials` (RFC 6749 §4.4) is the grant where the client acts as
207/// itself, with no resource owner in the loop. systemprompt-oauth still records
208/// an `owner_user_id` on every client for *audit attribution* — the JWT's
209/// `sub` resolves back to a human so downstream events trace to a person —
210/// but ownership does not authorize the grant by itself.
211///
212/// Scopes split into two tiers, already encoded on [`Permission`]:
213///
214/// * **Service-tier** ([`Permission::is_service_scope`]: `hook:govern`,
215///   `hook:track`, `service`, `a2a`, `mcp`). The client is provisioned with
216///   these statically at registration; the owner is irrelevant. Granted iff the
217///   client holds the scope.
218/// * **User-tier** ([`Permission::is_user_role`]: `admin`, `user`,
219///   `anonymous`). These represent delegated authority — the machine acting on
220///   behalf of the owner — so they require both the client *and* the owner to
221///   hold the permission.
222///
223/// If the result is empty the error names the actual deficit (client grant vs.
224/// owner roles) so operator logs and the bridge `sync` PARTIAL body point at
225/// the right misconfiguration instead of a generic "not allowed".
226fn authorize_client_grant(
227    requested: &[Permission],
228    client_scopes: &[String],
229    owner_permissions: &[Permission],
230) -> Result<Vec<Permission>, ClientCredentialsError> {
231    let client_allowed = scope_permissions(client_scopes);
232
233    let mut granted: Vec<Permission> = Vec::with_capacity(requested.len());
234    let mut missing_from_client: Vec<Permission> = Vec::new();
235    let mut missing_from_owner: Vec<Permission> = Vec::new();
236
237    for &perm in requested {
238        if !client_allowed.contains(&perm) {
239            missing_from_client.push(perm);
240            continue;
241        }
242        match perm {
243            Permission::HookGovern
244            | Permission::HookTrack
245            | Permission::Service
246            | Permission::A2a
247            | Permission::Mcp => granted.push(perm),
248            Permission::Admin | Permission::User | Permission::Anonymous => {
249                if owner_permissions.contains(&perm) {
250                    granted.push(perm);
251                } else {
252                    missing_from_owner.push(perm);
253                }
254            },
255        }
256    }
257
258    granted.sort_by_key(|p| std::cmp::Reverse(p.hierarchy_level()));
259    granted.dedup();
260
261    if granted.is_empty() {
262        let reason = if !missing_from_client.is_empty() {
263            format!(
264                "requested scopes not in client grant: {}",
265                permissions_to_string(&missing_from_client)
266            )
267        } else if !missing_from_owner.is_empty() {
268            format!(
269                "delegated scopes not held by owner: {}",
270                permissions_to_string(&missing_from_owner)
271            )
272        } else {
273            "no scopes requested".to_owned()
274        };
275        return Err(ClientCredentialsError::InvalidScope(reason));
276    }
277
278    Ok(granted)
279}
280
281fn resolve_audience(
282    requested: Option<&str>,
283    global_config: &Config,
284) -> Result<Vec<JwtAudience>, ClientCredentialsError> {
285    let Some(value) = requested else {
286        return Ok(global_config.jwt_audiences.clone());
287    };
288
289    if !global_config
290        .allowed_resource_audiences
291        .iter()
292        .any(|allowed| allowed == value)
293    {
294        return Err(ClientCredentialsError::InvalidAudience(format!(
295            "'{value}' not in allowed audiences"
296        )));
297    }
298
299    JwtAudience::from_str(value)
300        .map(|aud| vec![aud])
301        .map_err(|e| ClientCredentialsError::InvalidAudience(format!("'{value}': {e}")))
302}