Skip to main content

systemprompt_api/routes/oauth/endpoints/token/generation/client_credentials/
mod.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 systemprompt_identifiers::{ClientId, SessionId, SessionSource, UserId};
12use systemprompt_models::Config;
13use systemprompt_models::auth::{AuthenticatedUser, JwtAudience, Permission, parse_permissions};
14use systemprompt_oauth::OAuthState;
15use systemprompt_oauth::repository::OAuthRepository;
16use systemprompt_oauth::services::{JwtConfig, JwtSigningParams, generate_jwt};
17use systemprompt_traits::{CreateSessionInput, ExtractSignals};
18use thiserror::Error;
19
20use super::super::TokenResponse;
21use super::RequestOrigin;
22
23mod scope;
24
25#[cfg(feature = "test-api")]
26pub use self::scope::{authorize_client_grant, resolve_audience, scope_permissions};
27
28#[cfg(not(feature = "test-api"))]
29use self::scope::{authorize_client_grant, resolve_audience, scope_permissions};
30
31#[derive(Debug, Default)]
32pub struct ClientTokenOptions<'a> {
33    pub scope: Option<&'a str>,
34    pub plugin_id: Option<&'a str>,
35    pub audience: Option<&'a str>,
36}
37
38/// Failure modes of the `client_credentials` grant.
39///
40/// Variants partition by RFC 6749 §5.2 error code so the route handler can map
41/// each to the right HTTP status. Recoverable client mistakes (unknown client,
42/// orphaned or inactive owner, bad scope/audience) must surface as 4xx, never
43/// 5xx — the latter masks operator-visible misconfiguration as gateway
44/// failures and triggers spurious paging.
45#[derive(Debug, Error)]
46pub enum ClientCredentialsError {
47    #[error("Client not found")]
48    ClientNotFound,
49    #[error("Client owner not found")]
50    OwnerNotFound,
51    #[error("Client owner is not active")]
52    OwnerInactive,
53    #[error("Client owner has a non-uuid id ({0})")]
54    OwnerIdMalformed(String),
55    #[error("Invalid scope: {0}")]
56    InvalidScope(String),
57    #[error("Invalid audience: {0}")]
58    InvalidAudience(String),
59    #[error("Hook scopes require audience=hook on the token request")]
60    HookScopeRequiresHookAudience,
61    #[error("Failed to load client owner: {0}")]
62    UserProviderUnavailable(#[source] Box<dyn std::error::Error + Send + Sync>),
63    #[error("Failed to create session: {0}")]
64    SessionCreate(#[source] Box<dyn std::error::Error + Send + Sync>),
65    #[error("JWT signing failed: {0}")]
66    JwtSign(#[source] Box<dyn std::error::Error + Send + Sync>),
67    #[error("Config unavailable: {0}")]
68    ConfigUnavailable(#[source] Box<dyn std::error::Error + Send + Sync>),
69}
70
71#[cfg(feature = "test-api")]
72pub mod test_api {
73    pub use super::{authorize_client_grant, resolve_audience, scope_permissions};
74}
75
76struct OwnerProfile {
77    name: String,
78    email: String,
79    permissions: Vec<Permission>,
80}
81
82async fn load_active_owner(
83    state: &OAuthState,
84    owner_user_id: &UserId,
85) -> Result<OwnerProfile, ClientCredentialsError> {
86    let owner = state
87        .user_provider()
88        .find_by_id(owner_user_id)
89        .await
90        .map_err(|e| ClientCredentialsError::UserProviderUnavailable(e.into()))?
91        .ok_or(ClientCredentialsError::OwnerNotFound)?;
92    if !owner.is_active {
93        return Err(ClientCredentialsError::OwnerInactive);
94    }
95    Ok(OwnerProfile {
96        permissions: scope_permissions(&owner.roles),
97        name: owner.name,
98        email: owner.email,
99    })
100}
101
102async fn create_client_session(
103    state: &OAuthState,
104    origin: RequestOrigin<'_>,
105    owner_user_id: &UserId,
106    expires_in: i64,
107) -> Result<SessionId, ClientCredentialsError> {
108    let session_id = SessionId::new(format!("sess_{}", uuid::Uuid::new_v4().simple()));
109    let expires_at = chrono::Utc::now() + chrono::Duration::seconds(expires_in);
110    let analytics = state.analytics_provider().extract_analytics(
111        origin.headers,
112        ExtractSignals {
113            caller_ip: origin.caller_ip,
114            ..Default::default()
115        },
116    );
117
118    state
119        .analytics_provider()
120        .create_session(CreateSessionInput {
121            session_id: &session_id,
122            user_id: Some(owner_user_id),
123            analytics: &analytics,
124            session_source: SessionSource::Oauth,
125            is_bot: false,
126            is_ai_crawler: false,
127            expires_at,
128        })
129        .await
130        .map_err(|e| ClientCredentialsError::SessionCreate(e.into()))?;
131    Ok(session_id)
132}
133
134pub async fn generate_client_tokens(
135    repo: &OAuthRepository,
136    client_id: &ClientId,
137    origin: RequestOrigin<'_>,
138    state: &OAuthState,
139    options: ClientTokenOptions<'_>,
140) -> Result<TokenResponse, ClientCredentialsError> {
141    let global_config =
142        Config::get().map_err(|e| ClientCredentialsError::ConfigUnavailable(e.into()))?;
143    let expires_in = global_config.jwt_access_token_expiration;
144
145    let client = repo
146        .find_client_by_id(client_id)
147        .await
148        .map_err(|e| ClientCredentialsError::UserProviderUnavailable(e.into()))?
149        .ok_or(ClientCredentialsError::ClientNotFound)?;
150
151    let requested_permissions = match options.scope {
152        Some(scope_str) => parse_permissions(scope_str)
153            .map_err(|e| ClientCredentialsError::InvalidScope(e.to_string()))?,
154        None => scope_permissions(&client.scopes),
155    };
156
157    let owner = load_active_owner(state, &client.owner_user_id).await?;
158
159    let permissions =
160        authorize_client_grant(&requested_permissions, &client.scopes, &owner.permissions)?;
161
162    let audience = resolve_audience(options.audience, global_config)?;
163
164    if permissions.iter().any(Permission::is_hook_scope)
165        && !audience.iter().any(|a| matches!(a, JwtAudience::Hook))
166    {
167        return Err(ClientCredentialsError::HookScopeRequiresHookAudience);
168    }
169
170    let owner_uuid = uuid::Uuid::parse_str(client.owner_user_id.as_str())
171        .map_err(|e| ClientCredentialsError::OwnerIdMalformed(e.to_string()))?;
172    let authenticated =
173        AuthenticatedUser::new(owner_uuid, owner.name, owner.email, permissions.clone());
174
175    let config = JwtConfig {
176        permissions: permissions.clone(),
177        audience,
178        expires_in_hours: Some(global_config.jwt_access_token_expiration / 3600),
179        plugin_id: options.plugin_id.map(str::to_owned),
180        client_id: Some(client_id.clone()),
181        ..Default::default()
182    };
183    let session_id =
184        create_client_session(state, origin, &client.owner_user_id, expires_in).await?;
185
186    let signing = JwtSigningParams {
187        issuer: &global_config.jwt_issuer,
188    };
189    let jwt_token = generate_jwt(
190        &authenticated,
191        config,
192        uuid::Uuid::new_v4().to_string(),
193        &session_id,
194        &signing,
195    )
196    .map_err(|e| ClientCredentialsError::JwtSign(e.into()))?;
197
198    Ok(TokenResponse {
199        access_token: jwt_token,
200        token_type: "Bearer".to_owned(),
201        expires_in,
202        refresh_token: None,
203        scope: Some(
204            permissions
205                .iter()
206                .map(ToString::to_string)
207                .collect::<Vec<_>>()
208                .join(" "),
209        ),
210        issued_token_type: None,
211    })
212}