systemprompt_api/routes/oauth/endpoints/token/generation/
client_credentials.rs1use anyhow::{Result, anyhow};
2use axum::http::HeaderMap;
3use std::str::FromStr;
4use systemprompt_identifiers::{ClientId, SessionId, SessionSource};
5use systemprompt_models::Config;
6use systemprompt_models::auth::{AuthenticatedUser, JwtAudience, Permission, parse_permissions};
7use systemprompt_oauth::OAuthState;
8use systemprompt_oauth::repository::OAuthRepository;
9use systemprompt_oauth::services::{JwtConfig, JwtSigningParams, generate_jwt};
10use systemprompt_traits::CreateSessionInput;
11
12use super::super::TokenResponse;
13
14#[derive(Debug, Default)]
15pub struct ClientTokenOptions<'a> {
16 pub scope: Option<&'a str>,
17 pub plugin_id: Option<&'a str>,
18 pub audience: Option<&'a str>,
19}
20
21pub async fn generate_client_tokens(
22 repo: &OAuthRepository,
23 client_id: &ClientId,
24 headers: &HeaderMap,
25 state: &OAuthState,
26 options: ClientTokenOptions<'_>,
27) -> Result<TokenResponse> {
28 let expires_in = Config::get()?.jwt_access_token_expiration;
29
30 let client = repo
31 .find_client_by_id(client_id)
32 .await?
33 .ok_or_else(|| anyhow!("Client not found"))?;
34
35 let requested_permissions = match options.scope {
36 Some(scope_str) => parse_permissions(scope_str)?,
37 None => client_scope_permissions(&client.scopes),
38 };
39
40 let owner = state
41 .user_provider()
42 .find_by_id(&client.owner_user_id)
43 .await
44 .map_err(|e| anyhow!("Failed to load client owner: {e}"))?
45 .ok_or_else(|| anyhow!("Client owner not found"))?;
46 if !owner.is_active {
47 return Err(anyhow!("Client owner is not active"));
48 }
49 let owner_permissions: Vec<Permission> = owner
50 .roles
51 .iter()
52 .filter_map(|r| Permission::from_str(r).ok())
53 .collect();
54
55 let permissions =
56 intersect_permissions(&requested_permissions, &client.scopes, &owner_permissions)?;
57
58 let global_config = Config::get()?;
59 let audience = resolve_audience(options.audience, global_config)?;
60
61 if permissions.iter().any(Permission::is_hook_scope)
62 && !audience.iter().any(|a| matches!(a, JwtAudience::Hook))
63 {
64 return Err(anyhow!(
65 "Hook scopes require audience=hook on the token request"
66 ));
67 }
68
69 let owner_uuid = uuid::Uuid::parse_str(client.owner_user_id.as_str())
70 .map_err(|e| anyhow!("Client owner has a non-uuid id ({e})"))?;
71 let role_strings: Vec<String> = permissions.iter().map(ToString::to_string).collect();
72 let authenticated = AuthenticatedUser::new_with_roles(
73 owner_uuid,
74 owner.name.clone(),
75 owner.email.clone(),
76 permissions.clone(),
77 role_strings,
78 );
79
80 let config = JwtConfig {
81 permissions: permissions.clone(),
82 audience,
83 expires_in_hours: Some(global_config.jwt_access_token_expiration / 3600),
84 plugin_id: options.plugin_id.map(str::to_owned),
85 ..Default::default()
86 };
87 let session_id = SessionId::new(format!("sess_{}", uuid::Uuid::new_v4().simple()));
88 let expires_at = chrono::Utc::now() + chrono::Duration::seconds(expires_in);
89 let analytics = state.analytics_provider().extract_analytics(headers, None);
90
91 state
92 .analytics_provider()
93 .create_session(CreateSessionInput {
94 session_id: &session_id,
95 user_id: Some(&client.owner_user_id),
96 analytics: &analytics,
97 session_source: SessionSource::Oauth,
98 is_bot: false,
99 is_ai_crawler: false,
100 expires_at,
101 })
102 .await
103 .map_err(|e| anyhow!("Failed to create session: {e}"))?;
104
105 let signing = JwtSigningParams {
106 issuer: &global_config.jwt_issuer,
107 };
108 let jwt_token = generate_jwt(
109 &authenticated,
110 config,
111 uuid::Uuid::new_v4().to_string(),
112 &session_id,
113 &signing,
114 )?;
115
116 Ok(TokenResponse {
117 access_token: jwt_token,
118 token_type: "Bearer".to_owned(),
119 expires_in,
120 refresh_token: None,
121 scope: Some(
122 permissions
123 .iter()
124 .map(ToString::to_string)
125 .collect::<Vec<_>>()
126 .join(" "),
127 ),
128 issued_token_type: None,
129 })
130}
131
132fn client_scope_permissions(client_scopes: &[String]) -> Vec<Permission> {
133 client_scopes
134 .iter()
135 .filter_map(|s| Permission::from_str(s).ok())
136 .collect()
137}
138
139fn intersect_permissions(
140 requested: &[Permission],
141 client_scopes: &[String],
142 owner_permissions: &[Permission],
143) -> Result<Vec<Permission>> {
144 let client_allowed: Vec<Permission> = client_scope_permissions(client_scopes);
145
146 let allowed: Vec<Permission> = requested
147 .iter()
148 .filter(|p| client_allowed.contains(p) && owner_permissions.contains(p))
149 .copied()
150 .collect();
151
152 if allowed.is_empty() {
153 return Err(anyhow!(
154 "No valid permissions: scopes not allowed for both client and owner"
155 ));
156 }
157
158 Ok(allowed)
159}
160
161fn resolve_audience(requested: Option<&str>, global_config: &Config) -> Result<Vec<JwtAudience>> {
162 let Some(value) = requested else {
163 return Ok(global_config.jwt_audiences.clone());
164 };
165
166 if !global_config
167 .allowed_resource_audiences
168 .iter()
169 .any(|allowed| allowed == value)
170 {
171 return Err(anyhow!(
172 "invalid_target: '{value}' not in allowed audiences"
173 ));
174 }
175
176 JwtAudience::from_str(value)
177 .map(|aud| vec![aud])
178 .map_err(|e| anyhow!("Invalid audience '{value}': {e}"))
179}