systemprompt_api/routes/oauth/endpoints/token/generation/
client_credentials.rs1use 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#[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
201fn 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}