systemprompt_api/routes/oauth/endpoints/token/generation/client_credentials/
mod.rs1use systemprompt_identifiers::{ClientId, SessionId, SessionSource, UserId};
12use systemprompt_models::Config;
13use systemprompt_models::auth::{AuthenticatedUser, JwtAudience, Permission, parse_permissions};
14use systemprompt_oauth::OAuthState;
15use systemprompt_oauth::repository::OAuthRepository;
16use systemprompt_oauth::services::{JwtConfig, JwtSigningParams, generate_jwt};
17use systemprompt_traits::{CreateSessionInput, ExtractSignals};
18use thiserror::Error;
19
20use super::super::TokenResponse;
21use super::RequestOrigin;
22
23mod scope;
24
25#[cfg(feature = "test-api")]
26pub use self::scope::{authorize_client_grant, resolve_audience, scope_permissions};
27
28#[cfg(not(feature = "test-api"))]
29use self::scope::{authorize_client_grant, resolve_audience, scope_permissions};
30
31#[derive(Debug, Default)]
32pub struct ClientTokenOptions<'a> {
33 pub scope: Option<&'a str>,
34 pub plugin_id: Option<&'a str>,
35 pub audience: Option<&'a str>,
36}
37
38#[derive(Debug, Error)]
46pub enum ClientCredentialsError {
47 #[error("Client not found")]
48 ClientNotFound,
49 #[error("Client owner not found")]
50 OwnerNotFound,
51 #[error("Client owner is not active")]
52 OwnerInactive,
53 #[error("Client owner has a non-uuid id ({0})")]
54 OwnerIdMalformed(String),
55 #[error("Invalid scope: {0}")]
56 InvalidScope(String),
57 #[error("Invalid audience: {0}")]
58 InvalidAudience(String),
59 #[error("Hook scopes require audience=hook on the token request")]
60 HookScopeRequiresHookAudience,
61 #[error("Failed to load client owner: {0}")]
62 UserProviderUnavailable(#[source] Box<dyn std::error::Error + Send + Sync>),
63 #[error("Failed to create session: {0}")]
64 SessionCreate(#[source] Box<dyn std::error::Error + Send + Sync>),
65 #[error("JWT signing failed: {0}")]
66 JwtSign(#[source] Box<dyn std::error::Error + Send + Sync>),
67 #[error("Config unavailable: {0}")]
68 ConfigUnavailable(#[source] Box<dyn std::error::Error + Send + Sync>),
69}
70
71#[cfg(feature = "test-api")]
72pub mod test_api {
73 pub use super::{authorize_client_grant, resolve_audience, scope_permissions};
74}
75
76struct OwnerProfile {
77 name: String,
78 email: String,
79 permissions: Vec<Permission>,
80}
81
82async fn load_active_owner(
83 state: &OAuthState,
84 owner_user_id: &UserId,
85) -> Result<OwnerProfile, ClientCredentialsError> {
86 let owner = state
87 .user_provider()
88 .find_by_id(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 Ok(OwnerProfile {
96 permissions: scope_permissions(&owner.roles),
97 name: owner.name,
98 email: owner.email,
99 })
100}
101
102async fn create_client_session(
103 state: &OAuthState,
104 origin: RequestOrigin<'_>,
105 owner_user_id: &UserId,
106 expires_in: i64,
107) -> Result<SessionId, ClientCredentialsError> {
108 let session_id = SessionId::new(format!("sess_{}", uuid::Uuid::new_v4().simple()));
109 let expires_at = chrono::Utc::now() + chrono::Duration::seconds(expires_in);
110 let analytics = state.analytics_provider().extract_analytics(
111 origin.headers,
112 ExtractSignals {
113 caller_ip: origin.caller_ip,
114 ..Default::default()
115 },
116 );
117
118 state
119 .analytics_provider()
120 .create_session(CreateSessionInput {
121 session_id: &session_id,
122 user_id: Some(owner_user_id),
123 analytics: &analytics,
124 session_source: SessionSource::Oauth,
125 is_bot: false,
126 is_ai_crawler: false,
127 expires_at,
128 })
129 .await
130 .map_err(|e| ClientCredentialsError::SessionCreate(e.into()))?;
131 Ok(session_id)
132}
133
134pub async fn generate_client_tokens(
135 repo: &OAuthRepository,
136 client_id: &ClientId,
137 origin: RequestOrigin<'_>,
138 state: &OAuthState,
139 options: ClientTokenOptions<'_>,
140) -> Result<TokenResponse, ClientCredentialsError> {
141 let global_config =
142 Config::get().map_err(|e| ClientCredentialsError::ConfigUnavailable(e.into()))?;
143 let expires_in = global_config.jwt_access_token_expiration;
144
145 let client = repo
146 .find_client_by_id(client_id)
147 .await
148 .map_err(|e| ClientCredentialsError::UserProviderUnavailable(e.into()))?
149 .ok_or(ClientCredentialsError::ClientNotFound)?;
150
151 let requested_permissions = match options.scope {
152 Some(scope_str) => parse_permissions(scope_str)
153 .map_err(|e| ClientCredentialsError::InvalidScope(e.to_string()))?,
154 None => scope_permissions(&client.scopes),
155 };
156
157 let owner = load_active_owner(state, &client.owner_user_id).await?;
158
159 let permissions =
160 authorize_client_grant(&requested_permissions, &client.scopes, &owner.permissions)?;
161
162 let audience = resolve_audience(options.audience, global_config)?;
163
164 if permissions.iter().any(Permission::is_hook_scope)
165 && !audience.iter().any(|a| matches!(a, JwtAudience::Hook))
166 {
167 return Err(ClientCredentialsError::HookScopeRequiresHookAudience);
168 }
169
170 let owner_uuid = uuid::Uuid::parse_str(client.owner_user_id.as_str())
171 .map_err(|e| ClientCredentialsError::OwnerIdMalformed(e.to_string()))?;
172 let authenticated =
173 AuthenticatedUser::new(owner_uuid, owner.name, owner.email, permissions.clone());
174
175 let config = JwtConfig {
176 permissions: permissions.clone(),
177 audience,
178 expires_in_hours: Some(global_config.jwt_access_token_expiration / 3600),
179 plugin_id: options.plugin_id.map(str::to_owned),
180 client_id: Some(client_id.clone()),
181 ..Default::default()
182 };
183 let session_id =
184 create_client_session(state, origin, &client.owner_user_id, expires_in).await?;
185
186 let signing = JwtSigningParams {
187 issuer: &global_config.jwt_issuer,
188 };
189 let jwt_token = generate_jwt(
190 &authenticated,
191 config,
192 uuid::Uuid::new_v4().to_string(),
193 &session_id,
194 &signing,
195 )
196 .map_err(|e| ClientCredentialsError::JwtSign(e.into()))?;
197
198 Ok(TokenResponse {
199 access_token: jwt_token,
200 token_type: "Bearer".to_owned(),
201 expires_in,
202 refresh_token: None,
203 scope: Some(
204 permissions
205 .iter()
206 .map(ToString::to_string)
207 .collect::<Vec<_>>()
208 .join(" "),
209 ),
210 issued_token_type: None,
211 })
212}