systemprompt_api/routes/oauth/endpoints/token/generation/
client_credentials.rs1use std::str::FromStr;
12use systemprompt_identifiers::{ClientId, SessionId, SessionSource, UserId};
13use systemprompt_models::Config;
14use systemprompt_models::auth::{
15 AuthenticatedUser, JwtAudience, Permission, parse_permissions, permissions_to_string,
16};
17use systemprompt_oauth::OAuthState;
18use systemprompt_oauth::repository::OAuthRepository;
19use systemprompt_oauth::services::{JwtConfig, JwtSigningParams, generate_jwt};
20use systemprompt_traits::{CreateSessionInput, ExtractSignals};
21use thiserror::Error;
22
23use super::super::TokenResponse;
24use super::RequestOrigin;
25
26#[derive(Debug, Default)]
27pub struct ClientTokenOptions<'a> {
28 pub scope: Option<&'a str>,
29 pub plugin_id: Option<&'a str>,
30 pub audience: Option<&'a str>,
31}
32
33#[derive(Debug, Error)]
41pub enum ClientCredentialsError {
42 #[error("Client not found")]
43 ClientNotFound,
44 #[error("Client owner not found")]
45 OwnerNotFound,
46 #[error("Client owner is not active")]
47 OwnerInactive,
48 #[error("Client owner has a non-uuid id ({0})")]
49 OwnerIdMalformed(String),
50 #[error("Invalid scope: {0}")]
51 InvalidScope(String),
52 #[error("Invalid audience: {0}")]
53 InvalidAudience(String),
54 #[error("Hook scopes require audience=hook on the token request")]
55 HookScopeRequiresHookAudience,
56 #[error("Failed to load client owner: {0}")]
57 UserProviderUnavailable(#[source] Box<dyn std::error::Error + Send + Sync>),
58 #[error("Failed to create session: {0}")]
59 SessionCreate(#[source] Box<dyn std::error::Error + Send + Sync>),
60 #[error("JWT signing failed: {0}")]
61 JwtSign(#[source] Box<dyn std::error::Error + Send + Sync>),
62 #[error("Config unavailable: {0}")]
63 ConfigUnavailable(#[source] Box<dyn std::error::Error + Send + Sync>),
64}
65
66#[cfg(feature = "test-api")]
67pub mod test_api {
68 pub use super::{authorize_client_grant, resolve_audience, scope_permissions};
69}
70
71struct OwnerProfile {
72 name: String,
73 email: String,
74 permissions: Vec<Permission>,
75}
76
77async fn load_active_owner(
78 state: &OAuthState,
79 owner_user_id: &UserId,
80) -> Result<OwnerProfile, ClientCredentialsError> {
81 let owner = state
82 .user_provider()
83 .find_by_id(owner_user_id)
84 .await
85 .map_err(|e| ClientCredentialsError::UserProviderUnavailable(e.into()))?
86 .ok_or(ClientCredentialsError::OwnerNotFound)?;
87 if !owner.is_active {
88 return Err(ClientCredentialsError::OwnerInactive);
89 }
90 Ok(OwnerProfile {
91 permissions: scope_permissions(&owner.roles),
92 name: owner.name,
93 email: owner.email,
94 })
95}
96
97async fn create_client_session(
98 state: &OAuthState,
99 origin: RequestOrigin<'_>,
100 owner_user_id: &UserId,
101 expires_in: i64,
102) -> Result<SessionId, ClientCredentialsError> {
103 let session_id = SessionId::new(format!("sess_{}", uuid::Uuid::new_v4().simple()));
104 let expires_at = chrono::Utc::now() + chrono::Duration::seconds(expires_in);
105 let analytics = state.analytics_provider().extract_analytics(
106 origin.headers,
107 ExtractSignals {
108 caller_ip: origin.caller_ip,
109 ..Default::default()
110 },
111 );
112
113 state
114 .analytics_provider()
115 .create_session(CreateSessionInput {
116 session_id: &session_id,
117 user_id: Some(owner_user_id),
118 analytics: &analytics,
119 session_source: SessionSource::Oauth,
120 is_bot: false,
121 is_ai_crawler: false,
122 expires_at,
123 })
124 .await
125 .map_err(|e| ClientCredentialsError::SessionCreate(e.into()))?;
126 Ok(session_id)
127}
128
129pub async fn generate_client_tokens(
130 repo: &OAuthRepository,
131 client_id: &ClientId,
132 origin: RequestOrigin<'_>,
133 state: &OAuthState,
134 options: ClientTokenOptions<'_>,
135) -> Result<TokenResponse, ClientCredentialsError> {
136 let global_config =
137 Config::get().map_err(|e| ClientCredentialsError::ConfigUnavailable(e.into()))?;
138 let expires_in = global_config.jwt_access_token_expiration;
139
140 let client = repo
141 .find_client_by_id(client_id)
142 .await
143 .map_err(|e| ClientCredentialsError::UserProviderUnavailable(e.into()))?
144 .ok_or(ClientCredentialsError::ClientNotFound)?;
145
146 let requested_permissions = match options.scope {
147 Some(scope_str) => parse_permissions(scope_str)
148 .map_err(|e| ClientCredentialsError::InvalidScope(e.to_string()))?,
149 None => scope_permissions(&client.scopes),
150 };
151
152 let owner = load_active_owner(state, &client.owner_user_id).await?;
153
154 let permissions =
155 authorize_client_grant(&requested_permissions, &client.scopes, &owner.permissions)?;
156
157 let audience = resolve_audience(options.audience, global_config)?;
158
159 if permissions.iter().any(Permission::is_hook_scope)
160 && !audience.iter().any(|a| matches!(a, JwtAudience::Hook))
161 {
162 return Err(ClientCredentialsError::HookScopeRequiresHookAudience);
163 }
164
165 let owner_uuid = uuid::Uuid::parse_str(client.owner_user_id.as_str())
166 .map_err(|e| ClientCredentialsError::OwnerIdMalformed(e.to_string()))?;
167 let authenticated =
168 AuthenticatedUser::new(owner_uuid, owner.name, owner.email, permissions.clone());
169
170 let config = JwtConfig {
171 permissions: permissions.clone(),
172 audience,
173 expires_in_hours: Some(global_config.jwt_access_token_expiration / 3600),
174 plugin_id: options.plugin_id.map(str::to_owned),
175 ..Default::default()
176 };
177 let session_id =
178 create_client_session(state, origin, &client.owner_user_id, expires_in).await?;
179
180 let signing = JwtSigningParams {
181 issuer: &global_config.jwt_issuer,
182 };
183 let jwt_token = generate_jwt(
184 &authenticated,
185 config,
186 uuid::Uuid::new_v4().to_string(),
187 &session_id,
188 &signing,
189 )
190 .map_err(|e| ClientCredentialsError::JwtSign(e.into()))?;
191
192 Ok(TokenResponse {
193 access_token: jwt_token,
194 token_type: "Bearer".to_owned(),
195 expires_in,
196 refresh_token: None,
197 scope: Some(
198 permissions
199 .iter()
200 .map(ToString::to_string)
201 .collect::<Vec<_>>()
202 .join(" "),
203 ),
204 issued_token_type: None,
205 })
206}
207
208#[cfg_attr(
209 not(feature = "test-api"),
210 expect(
211 unreachable_pub,
212 reason = "items are re-exported via `test_api` only when the feature is on"
213 )
214)]
215pub fn scope_permissions(scopes: &[String]) -> Vec<Permission> {
216 scopes
217 .iter()
218 .filter_map(|s| Permission::from_str(s).ok())
219 .collect()
220}
221
222#[cfg_attr(
227 not(feature = "test-api"),
228 expect(
229 unreachable_pub,
230 reason = "items are re-exported via `test_api` only when the feature is on"
231 )
232)]
233pub fn authorize_client_grant(
234 requested: &[Permission],
235 client_scopes: &[String],
236 owner_permissions: &[Permission],
237) -> Result<Vec<Permission>, ClientCredentialsError> {
238 let client_allowed = scope_permissions(client_scopes);
239
240 let mut granted: Vec<Permission> = Vec::with_capacity(requested.len());
241 let mut missing_from_client: Vec<Permission> = Vec::new();
242 let mut missing_from_owner: Vec<Permission> = Vec::new();
243
244 for &perm in requested {
245 if !client_allowed.contains(&perm) {
246 missing_from_client.push(perm);
247 continue;
248 }
249 match perm {
250 Permission::HookGovern
251 | Permission::HookTrack
252 | Permission::Service
253 | Permission::A2a
254 | Permission::Mcp => granted.push(perm),
255 Permission::Admin | Permission::User | Permission::Anonymous => {
256 if owner_permissions.contains(&perm) {
257 granted.push(perm);
258 } else {
259 missing_from_owner.push(perm);
260 }
261 },
262 }
263 }
264
265 granted.sort_by_key(|p| std::cmp::Reverse(p.hierarchy_level()));
266 granted.dedup();
267
268 if granted.is_empty() {
269 let reason = if !missing_from_client.is_empty() {
270 format!(
271 "requested scopes not in client grant: {}",
272 permissions_to_string(&missing_from_client)
273 )
274 } else if !missing_from_owner.is_empty() {
275 format!(
276 "delegated scopes not held by owner: {}",
277 permissions_to_string(&missing_from_owner)
278 )
279 } else {
280 "no scopes requested".to_owned()
281 };
282 return Err(ClientCredentialsError::InvalidScope(reason));
283 }
284
285 Ok(granted)
286}
287
288#[cfg_attr(
289 not(feature = "test-api"),
290 expect(
291 unreachable_pub,
292 reason = "items are re-exported via `test_api` only when the feature is on"
293 )
294)]
295pub fn resolve_audience(
296 requested: Option<&str>,
297 global_config: &Config,
298) -> Result<Vec<JwtAudience>, ClientCredentialsError> {
299 let Some(value) = requested else {
300 return Ok(global_config.jwt_audiences.clone());
301 };
302
303 if !global_config
304 .allowed_resource_audiences
305 .iter()
306 .any(|allowed| allowed == value)
307 {
308 return Err(ClientCredentialsError::InvalidAudience(format!(
309 "'{value}' not in allowed audiences"
310 )));
311 }
312
313 JwtAudience::from_str(value)
314 .map(|aud| vec![aud])
315 .map_err(|e| ClientCredentialsError::InvalidAudience(format!("'{value}': {e}")))
316}