systemprompt_api/routes/oauth/endpoints/token/generation/
client_credentials.rs1use 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 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}