use systemprompt_identifiers::{ClientId, SessionId, SessionSource, UserId};
use systemprompt_models::Config;
use systemprompt_models::auth::{AuthenticatedUser, JwtAudience, Permission, parse_permissions};
use systemprompt_oauth::OAuthState;
use systemprompt_oauth::repository::OAuthRepository;
use systemprompt_oauth::services::{JwtConfig, JwtSigningParams, generate_jwt};
use systemprompt_traits::{CreateSessionInput, ExtractSignals};
use thiserror::Error;
use super::super::TokenResponse;
use super::RequestOrigin;
mod scope;
#[cfg(feature = "test-api")]
pub use self::scope::{authorize_client_grant, resolve_audience, scope_permissions};
#[cfg(not(feature = "test-api"))]
use self::scope::{authorize_client_grant, resolve_audience, scope_permissions};
#[derive(Debug, Default)]
pub struct ClientTokenOptions<'a> {
pub scope: Option<&'a str>,
pub plugin_id: Option<&'a str>,
pub audience: Option<&'a str>,
}
#[derive(Debug, Error)]
pub enum ClientCredentialsError {
#[error("Client not found")]
ClientNotFound,
#[error("Client owner not found")]
OwnerNotFound,
#[error("Client owner is not active")]
OwnerInactive,
#[error("Client owner has a non-uuid id ({0})")]
OwnerIdMalformed(String),
#[error("Invalid scope: {0}")]
InvalidScope(String),
#[error("Invalid audience: {0}")]
InvalidAudience(String),
#[error("Hook scopes require audience=hook on the token request")]
HookScopeRequiresHookAudience,
#[error("Failed to load client owner: {0}")]
UserProviderUnavailable(#[source] Box<dyn std::error::Error + Send + Sync>),
#[error("Failed to create session: {0}")]
SessionCreate(#[source] Box<dyn std::error::Error + Send + Sync>),
#[error("JWT signing failed: {0}")]
JwtSign(#[source] Box<dyn std::error::Error + Send + Sync>),
#[error("Config unavailable: {0}")]
ConfigUnavailable(#[source] Box<dyn std::error::Error + Send + Sync>),
}
#[cfg(feature = "test-api")]
pub mod test_api {
pub use super::{authorize_client_grant, resolve_audience, scope_permissions};
}
struct OwnerProfile {
name: String,
email: String,
permissions: Vec<Permission>,
}
async fn load_active_owner(
state: &OAuthState,
owner_user_id: &UserId,
) -> Result<OwnerProfile, ClientCredentialsError> {
let owner = state
.user_provider()
.find_by_id(owner_user_id)
.await
.map_err(|e| ClientCredentialsError::UserProviderUnavailable(e.into()))?
.ok_or(ClientCredentialsError::OwnerNotFound)?;
if !owner.is_active {
return Err(ClientCredentialsError::OwnerInactive);
}
Ok(OwnerProfile {
permissions: scope_permissions(&owner.roles),
name: owner.name,
email: owner.email,
})
}
async fn create_client_session(
state: &OAuthState,
origin: RequestOrigin<'_>,
owner_user_id: &UserId,
expires_in: i64,
) -> Result<SessionId, ClientCredentialsError> {
let session_id = SessionId::new(format!("sess_{}", uuid::Uuid::new_v4().simple()));
let expires_at = chrono::Utc::now() + chrono::Duration::seconds(expires_in);
let analytics = state.analytics_provider().extract_analytics(
origin.headers,
ExtractSignals {
caller_ip: origin.caller_ip,
..Default::default()
},
);
state
.analytics_provider()
.create_session(CreateSessionInput {
session_id: &session_id,
user_id: Some(owner_user_id),
analytics: &analytics,
session_source: SessionSource::Oauth,
is_bot: false,
is_ai_crawler: false,
expires_at,
})
.await
.map_err(|e| ClientCredentialsError::SessionCreate(e.into()))?;
Ok(session_id)
}
pub async fn generate_client_tokens(
repo: &OAuthRepository,
client_id: &ClientId,
origin: RequestOrigin<'_>,
state: &OAuthState,
options: ClientTokenOptions<'_>,
) -> Result<TokenResponse, ClientCredentialsError> {
let global_config =
Config::get().map_err(|e| ClientCredentialsError::ConfigUnavailable(e.into()))?;
let expires_in = global_config.jwt_access_token_expiration;
let client = repo
.find_client_by_id(client_id)
.await
.map_err(|e| ClientCredentialsError::UserProviderUnavailable(e.into()))?
.ok_or(ClientCredentialsError::ClientNotFound)?;
let requested_permissions = match options.scope {
Some(scope_str) => parse_permissions(scope_str)
.map_err(|e| ClientCredentialsError::InvalidScope(e.to_string()))?,
None => scope_permissions(&client.scopes),
};
let owner = load_active_owner(state, &client.owner_user_id).await?;
let permissions =
authorize_client_grant(&requested_permissions, &client.scopes, &owner.permissions)?;
let audience = resolve_audience(options.audience, global_config)?;
if permissions.iter().any(Permission::is_hook_scope)
&& !audience.iter().any(|a| matches!(a, JwtAudience::Hook))
{
return Err(ClientCredentialsError::HookScopeRequiresHookAudience);
}
let owner_uuid = uuid::Uuid::parse_str(client.owner_user_id.as_str())
.map_err(|e| ClientCredentialsError::OwnerIdMalformed(e.to_string()))?;
let authenticated =
AuthenticatedUser::new(owner_uuid, owner.name, owner.email, permissions.clone());
let config = JwtConfig {
permissions: permissions.clone(),
audience,
expires_in_hours: Some(global_config.jwt_access_token_expiration / 3600),
plugin_id: options.plugin_id.map(str::to_owned),
client_id: Some(client_id.clone()),
..Default::default()
};
let session_id =
create_client_session(state, origin, &client.owner_user_id, expires_in).await?;
let signing = JwtSigningParams {
issuer: &global_config.jwt_issuer,
};
let jwt_token = generate_jwt(
&authenticated,
config,
uuid::Uuid::new_v4().to_string(),
&session_id,
&signing,
)
.map_err(|e| ClientCredentialsError::JwtSign(e.into()))?;
Ok(TokenResponse {
access_token: jwt_token,
token_type: "Bearer".to_owned(),
expires_in,
refresh_token: None,
scope: Some(
permissions
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(" "),
),
issued_token_type: None,
})
}