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