systemprompt_api/routes/oauth/endpoints/token/generation/
client_credentials.rs1use axum::http::HeaderMap;
9use std::str::FromStr;
10use systemprompt_identifiers::{ClientId, SessionId, SessionSource};
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
63pub async fn generate_client_tokens(
64 repo: &OAuthRepository,
65 client_id: &ClientId,
66 headers: &HeaderMap,
67 state: &OAuthState,
68 options: ClientTokenOptions<'_>,
69) -> Result<TokenResponse, ClientCredentialsError> {
70 let global_config =
71 Config::get().map_err(|e| ClientCredentialsError::ConfigUnavailable(e.into()))?;
72 let expires_in = global_config.jwt_access_token_expiration;
73
74 let client = repo
75 .find_client_by_id(client_id)
76 .await
77 .map_err(|e| ClientCredentialsError::UserProviderUnavailable(e.into()))?
78 .ok_or(ClientCredentialsError::ClientNotFound)?;
79
80 let requested_permissions = match options.scope {
81 Some(scope_str) => parse_permissions(scope_str)
82 .map_err(|e| ClientCredentialsError::InvalidScope(e.to_string()))?,
83 None => client_scope_permissions(&client.scopes),
84 };
85
86 let owner = state
87 .user_provider()
88 .find_by_id(&client.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 let owner_permissions: Vec<Permission> = owner
96 .roles
97 .iter()
98 .filter_map(|r| Permission::from_str(r).ok())
99 .collect();
100
101 let permissions =
102 authorize_client_grant(&requested_permissions, &client.scopes, &owner_permissions)?;
103
104 let audience = resolve_audience(options.audience, global_config)?;
105
106 if permissions.iter().any(Permission::is_hook_scope)
107 && !audience.iter().any(|a| matches!(a, JwtAudience::Hook))
108 {
109 return Err(ClientCredentialsError::HookScopeRequiresHookAudience);
110 }
111
112 let owner_uuid = uuid::Uuid::parse_str(client.owner_user_id.as_str())
113 .map_err(|e| ClientCredentialsError::OwnerIdMalformed(e.to_string()))?;
114 let authenticated = AuthenticatedUser::new(
115 owner_uuid,
116 owner.name.clone(),
117 owner.email.clone(),
118 permissions.clone(),
119 );
120
121 let config = JwtConfig {
122 permissions: permissions.clone(),
123 audience,
124 expires_in_hours: Some(global_config.jwt_access_token_expiration / 3600),
125 plugin_id: options.plugin_id.map(str::to_owned),
126 ..Default::default()
127 };
128 let session_id = SessionId::new(format!("sess_{}", uuid::Uuid::new_v4().simple()));
129 let expires_at = chrono::Utc::now() + chrono::Duration::seconds(expires_in);
130 let analytics = state.analytics_provider().extract_analytics(headers, None);
131
132 state
133 .analytics_provider()
134 .create_session(CreateSessionInput {
135 session_id: &session_id,
136 user_id: Some(&client.owner_user_id),
137 analytics: &analytics,
138 session_source: SessionSource::Oauth,
139 is_bot: false,
140 is_ai_crawler: false,
141 expires_at,
142 })
143 .await
144 .map_err(|e| ClientCredentialsError::SessionCreate(e.into()))?;
145
146 let signing = JwtSigningParams {
147 issuer: &global_config.jwt_issuer,
148 };
149 let jwt_token = generate_jwt(
150 &authenticated,
151 config,
152 uuid::Uuid::new_v4().to_string(),
153 &session_id,
154 &signing,
155 )
156 .map_err(|e| ClientCredentialsError::JwtSign(e.into()))?;
157
158 Ok(TokenResponse {
159 access_token: jwt_token,
160 token_type: "Bearer".to_owned(),
161 expires_in,
162 refresh_token: None,
163 scope: Some(
164 permissions
165 .iter()
166 .map(ToString::to_string)
167 .collect::<Vec<_>>()
168 .join(" "),
169 ),
170 issued_token_type: None,
171 })
172}
173
174fn client_scope_permissions(client_scopes: &[String]) -> Vec<Permission> {
175 client_scopes
176 .iter()
177 .filter_map(|s| Permission::from_str(s).ok())
178 .collect()
179}
180
181fn authorize_client_grant(
204 requested: &[Permission],
205 client_scopes: &[String],
206 owner_permissions: &[Permission],
207) -> Result<Vec<Permission>, ClientCredentialsError> {
208 let client_allowed = client_scope_permissions(client_scopes);
209
210 let mut granted: Vec<Permission> = Vec::with_capacity(requested.len());
211 let mut missing_from_client: Vec<Permission> = Vec::new();
212 let mut missing_from_owner: Vec<Permission> = Vec::new();
213
214 for &perm in requested {
215 if !client_allowed.contains(&perm) {
216 missing_from_client.push(perm);
217 continue;
218 }
219 match perm {
220 Permission::HookGovern
221 | Permission::HookTrack
222 | Permission::Service
223 | Permission::A2a
224 | Permission::Mcp => granted.push(perm),
225 Permission::Admin | Permission::User | Permission::Anonymous => {
226 if owner_permissions.contains(&perm) {
227 granted.push(perm);
228 } else {
229 missing_from_owner.push(perm);
230 }
231 },
232 }
233 }
234
235 granted.sort_by_key(|p| std::cmp::Reverse(p.hierarchy_level()));
236 granted.dedup();
237
238 if granted.is_empty() {
239 let reason = if !missing_from_client.is_empty() {
240 format!(
241 "requested scopes not in client grant: {}",
242 permissions_to_string(&missing_from_client)
243 )
244 } else if !missing_from_owner.is_empty() {
245 format!(
246 "delegated scopes not held by owner: {}",
247 permissions_to_string(&missing_from_owner)
248 )
249 } else {
250 "no scopes requested".to_owned()
251 };
252 return Err(ClientCredentialsError::InvalidScope(reason));
253 }
254
255 Ok(granted)
256}
257
258fn resolve_audience(
259 requested: Option<&str>,
260 global_config: &Config,
261) -> Result<Vec<JwtAudience>, ClientCredentialsError> {
262 let Some(value) = requested else {
263 return Ok(global_config.jwt_audiences.clone());
264 };
265
266 if !global_config
267 .allowed_resource_audiences
268 .iter()
269 .any(|allowed| allowed == value)
270 {
271 return Err(ClientCredentialsError::InvalidAudience(format!(
272 "'{value}' not in allowed audiences"
273 )));
274 }
275
276 JwtAudience::from_str(value)
277 .map(|aud| vec![aud])
278 .map_err(|e| ClientCredentialsError::InvalidAudience(format!("'{value}': {e}")))
279}