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 role_strings: Vec<String> = permissions.iter().map(ToString::to_string).collect();
115 let authenticated = AuthenticatedUser::new_with_roles(
116 owner_uuid,
117 owner.name.clone(),
118 owner.email.clone(),
119 permissions.clone(),
120 role_strings,
121 );
122
123 let config = JwtConfig {
124 permissions: permissions.clone(),
125 audience,
126 expires_in_hours: Some(global_config.jwt_access_token_expiration / 3600),
127 plugin_id: options.plugin_id.map(str::to_owned),
128 ..Default::default()
129 };
130 let session_id = SessionId::new(format!("sess_{}", uuid::Uuid::new_v4().simple()));
131 let expires_at = chrono::Utc::now() + chrono::Duration::seconds(expires_in);
132 let analytics = state.analytics_provider().extract_analytics(headers, None);
133
134 state
135 .analytics_provider()
136 .create_session(CreateSessionInput {
137 session_id: &session_id,
138 user_id: Some(&client.owner_user_id),
139 analytics: &analytics,
140 session_source: SessionSource::Oauth,
141 is_bot: false,
142 is_ai_crawler: false,
143 expires_at,
144 })
145 .await
146 .map_err(|e| ClientCredentialsError::SessionCreate(e.into()))?;
147
148 let signing = JwtSigningParams {
149 issuer: &global_config.jwt_issuer,
150 };
151 let jwt_token = generate_jwt(
152 &authenticated,
153 config,
154 uuid::Uuid::new_v4().to_string(),
155 &session_id,
156 &signing,
157 )
158 .map_err(|e| ClientCredentialsError::JwtSign(e.into()))?;
159
160 Ok(TokenResponse {
161 access_token: jwt_token,
162 token_type: "Bearer".to_owned(),
163 expires_in,
164 refresh_token: None,
165 scope: Some(
166 permissions
167 .iter()
168 .map(ToString::to_string)
169 .collect::<Vec<_>>()
170 .join(" "),
171 ),
172 issued_token_type: None,
173 })
174}
175
176fn client_scope_permissions(client_scopes: &[String]) -> Vec<Permission> {
177 client_scopes
178 .iter()
179 .filter_map(|s| Permission::from_str(s).ok())
180 .collect()
181}
182
183fn authorize_client_grant(
206 requested: &[Permission],
207 client_scopes: &[String],
208 owner_permissions: &[Permission],
209) -> Result<Vec<Permission>, ClientCredentialsError> {
210 let client_allowed = client_scope_permissions(client_scopes);
211
212 let mut granted: Vec<Permission> = Vec::with_capacity(requested.len());
213 let mut missing_from_client: Vec<Permission> = Vec::new();
214 let mut missing_from_owner: Vec<Permission> = Vec::new();
215
216 for &perm in requested {
217 if !client_allowed.contains(&perm) {
218 missing_from_client.push(perm);
219 continue;
220 }
221 match perm {
222 Permission::HookGovern
223 | Permission::HookTrack
224 | Permission::Service
225 | Permission::A2a
226 | Permission::Mcp => granted.push(perm),
227 Permission::Admin | Permission::User | Permission::Anonymous => {
228 if owner_permissions.contains(&perm) {
229 granted.push(perm);
230 } else {
231 missing_from_owner.push(perm);
232 }
233 },
234 }
235 }
236
237 granted.sort_by_key(|p| std::cmp::Reverse(p.hierarchy_level()));
238 granted.dedup();
239
240 if granted.is_empty() {
241 let reason = if !missing_from_client.is_empty() {
242 format!(
243 "requested scopes not in client grant: {}",
244 permissions_to_string(&missing_from_client)
245 )
246 } else if !missing_from_owner.is_empty() {
247 format!(
248 "delegated scopes not held by owner: {}",
249 permissions_to_string(&missing_from_owner)
250 )
251 } else {
252 "no scopes requested".to_owned()
253 };
254 return Err(ClientCredentialsError::InvalidScope(reason));
255 }
256
257 Ok(granted)
258}
259
260fn resolve_audience(
261 requested: Option<&str>,
262 global_config: &Config,
263) -> Result<Vec<JwtAudience>, ClientCredentialsError> {
264 let Some(value) = requested else {
265 return Ok(global_config.jwt_audiences.clone());
266 };
267
268 if !global_config
269 .allowed_resource_audiences
270 .iter()
271 .any(|allowed| allowed == value)
272 {
273 return Err(ClientCredentialsError::InvalidAudience(format!(
274 "'{value}' not in allowed audiences"
275 )));
276 }
277
278 JwtAudience::from_str(value)
279 .map(|aud| vec![aud])
280 .map_err(|e| ClientCredentialsError::InvalidAudience(format!("'{value}': {e}")))
281}