Skip to main content

systemprompt_api/routes/oauth/endpoints/token/generation/
client_credentials.rs

1use 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}