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