Skip to main content

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

1use anyhow::Result;
2use axum::http::HeaderMap;
3use sha2::{Digest, Sha256};
4use std::str::FromStr;
5use systemprompt_identifiers::{ClientId, SessionId, SessionSource, UserId};
6use systemprompt_models::Config;
7use systemprompt_models::auth::{AuthenticatedUser, JwtAudience, Permission, parse_permissions};
8use systemprompt_oauth::OAuthState;
9use systemprompt_oauth::repository::OAuthRepository;
10use systemprompt_oauth::services::{JwtConfig, JwtSigningParams, generate_jwt};
11use systemprompt_traits::CreateSessionInput;
12
13use super::super::TokenResponse;
14
15#[derive(Debug, Default)]
16pub struct ClientTokenOptions<'a> {
17    pub scope: Option<&'a str>,
18    pub plugin_id: Option<&'a str>,
19    pub audience: Option<&'a str>,
20}
21
22pub async fn generate_client_tokens(
23    repo: &OAuthRepository,
24    client_id: &ClientId,
25    headers: &HeaderMap,
26    state: &OAuthState,
27    options: ClientTokenOptions<'_>,
28) -> Result<TokenResponse> {
29    let expires_in = Config::get()?.jwt_access_token_expiration;
30
31    let scope_str = options
32        .scope
33        .ok_or_else(|| anyhow::anyhow!("Scope is required for client credentials grant"))?;
34
35    let requested_permissions = parse_permissions(scope_str)?;
36
37    let client = repo
38        .find_client_by_id(client_id)
39        .await?
40        .ok_or_else(|| anyhow::anyhow!("Client not found"))?;
41
42    let permissions = resolve_client_permissions(requested_permissions, &client.scopes)?;
43    let (user_id, client_user) = build_client_user(client_id, &permissions);
44
45    let jwt_secret = systemprompt_config::SecretsBootstrap::jwt_secret()?;
46    let global_config = Config::get()?;
47
48    // Why: Resource audiences are intentionally open here. Capability is granted by
49    // `scope`, not audience; the audience claim is informational and does not
50    // authorize anything.
51    let audience_override = options
52        .audience
53        .map(|a| {
54            JwtAudience::from_str(a).map_err(|e| anyhow::anyhow!("Invalid audience '{a}': {e}"))
55        })
56        .transpose()?;
57    let audience =
58        audience_override.map_or_else(|| global_config.jwt_audiences.clone(), |aud| vec![aud]);
59
60    if permissions.iter().any(Permission::is_hook_scope)
61        && !audience.iter().any(|a| matches!(a, JwtAudience::Hook))
62    {
63        return Err(anyhow::anyhow!(
64            "Hook scopes require audience=hook on the token request"
65        ));
66    }
67
68    let config = JwtConfig {
69        permissions: permissions.clone(),
70        audience,
71        expires_in_hours: Some(global_config.jwt_access_token_expiration / 3600),
72        plugin_id: options.plugin_id.map(str::to_string),
73        ..Default::default()
74    };
75    let session_id = SessionId::new(format!("sess_{}", uuid::Uuid::new_v4().simple()));
76    let expires_at = chrono::Utc::now() + chrono::Duration::seconds(expires_in);
77    let analytics = state.analytics_provider().extract_analytics(headers, None);
78
79    state
80        .analytics_provider()
81        .create_session(CreateSessionInput {
82            session_id: &session_id,
83            user_id: Some(&user_id),
84            analytics: &analytics,
85            session_source: SessionSource::Oauth,
86            is_bot: false,
87            is_ai_crawler: false,
88            expires_at,
89        })
90        .await
91        .map_err(|e| anyhow::anyhow!("Failed to create session: {e}"))?;
92
93    let signing = JwtSigningParams {
94        secret: jwt_secret,
95        issuer: &global_config.jwt_issuer,
96    };
97    let jwt_token = generate_jwt(
98        &client_user,
99        config,
100        uuid::Uuid::new_v4().to_string(),
101        &session_id,
102        &signing,
103    )?;
104
105    Ok(TokenResponse {
106        access_token: jwt_token,
107        token_type: "Bearer".to_string(),
108        expires_in,
109        refresh_token: None,
110        scope: Some(
111            permissions
112                .iter()
113                .map(ToString::to_string)
114                .collect::<Vec<_>>()
115                .join(" "),
116        ),
117    })
118}
119
120fn resolve_client_permissions(
121    requested_permissions: Vec<Permission>,
122    client_scopes: &[String],
123) -> Result<Vec<Permission>> {
124    let client_allowed: Vec<Permission> = client_scopes
125        .iter()
126        .filter_map(|s| {
127            Permission::from_str(s)
128                .map_err(|e| {
129                    tracing::warn!(scope = %s, error = %e, "Invalid scope in client configuration");
130                    e
131                })
132                .ok()
133        })
134        .collect();
135
136    let permissions: Vec<Permission> = requested_permissions
137        .into_iter()
138        .filter(|p| client_allowed.contains(p))
139        .collect();
140
141    if permissions.is_empty() {
142        return Err(anyhow::anyhow!(
143            "No valid permissions: requested scopes not allowed for this client"
144        ));
145    }
146
147    Ok(permissions)
148}
149
150fn build_client_user(
151    client_id: &ClientId,
152    permissions: &[Permission],
153) -> (UserId, AuthenticatedUser) {
154    let client_id_str = client_id.as_str();
155    let mut hasher = Sha256::new();
156    hasher.update(format!("client.{client_id_str}").as_bytes());
157    let hash = hasher.finalize();
158
159    let mut uuid_bytes = [0u8; 16];
160    uuid_bytes.copy_from_slice(&hash[..16]);
161    let client_uuid = uuid::Uuid::from_bytes(uuid_bytes);
162    let user_id = UserId::new(client_uuid.to_string());
163
164    let role_strings: Vec<String> = permissions.iter().map(ToString::to_string).collect();
165    let client_user = AuthenticatedUser::new_with_roles(
166        client_uuid,
167        format!("client:{client_id_str}"),
168        format!("{client_id_str}@client.local"),
169        permissions.to_vec(),
170        role_strings,
171    );
172
173    (user_id, client_user)
174}