systemprompt-api 0.15.0

Axum-based HTTP server and API gateway for systemprompt.io AI governance infrastructure. Exposes governed agents, MCP, A2A, and admin endpoints with rate limiting and RBAC.
Documentation
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::{Form, Json};
use serde::{Deserialize, Serialize};
use systemprompt_identifiers::ClientId;
use systemprompt_oauth::repository::OAuthRepository;
use systemprompt_oauth::services::validate_jwt_token;
use systemprompt_oauth::services::validation::validate_client_credentials;

use crate::routes::oauth::OAuthHttpError;
use crate::routes::oauth::extractors::OAuthRepo;

#[derive(Debug, Deserialize)]
pub struct IntrospectRequest {
    pub token: String,
    pub token_type_hint: Option<String>,
    pub client_id: Option<String>,
    pub client_secret: Option<String>,
}

#[derive(Debug, Serialize, Default)]
pub struct IntrospectResponse {
    pub active: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub scope: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub client_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub username: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub token_type: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub exp: Option<i64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub iat: Option<i64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sub: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub aud: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub iss: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub jti: Option<String>,
}

pub async fn handle_introspect(
    OAuthRepo(repo): OAuthRepo,
    Form(request): Form<IntrospectRequest>,
) -> Result<Response, OAuthHttpError> {
    let client_id_str = request
        .client_id
        .clone()
        .ok_or_else(|| OAuthHttpError::invalid_client("Client authentication required"))?;
    let client_id = ClientId::new(client_id_str);

    if validate_client_credentials(&repo, &client_id, request.client_secret.as_deref())
        .await
        .is_err()
    {
        return Err(OAuthHttpError::invalid_client("Invalid client credentials"));
    }

    let response = match introspect_token(&repo, &request.token)? {
        Some(full) => {
            let authoritative_client_id = full.client_id.as_deref();
            if authoritative_client_id == Some(client_id.as_str()) {
                full
            } else {
                IntrospectResponse {
                    active: true,
                    ..IntrospectResponse::default()
                }
            }
        },
        None => IntrospectResponse {
            active: false,
            ..IntrospectResponse::default()
        },
    };
    Ok((StatusCode::OK, Json(response)).into_response())
}

fn introspect_token(
    _repo: &OAuthRepository,
    token: &str,
) -> Result<Option<IntrospectResponse>, OAuthHttpError> {
    let config = systemprompt_models::Config::get()
        .map_err(|e| OAuthHttpError::server_error(format!("Failed to load config: {e}")))?;
    match validate_jwt_token(token, &config.jwt_issuer, &config.jwt_audiences) {
        Ok(claims) => Ok(Some(IntrospectResponse {
            active: true,
            scope: Some(systemprompt_models::auth::permissions_to_string(
                &claims.scope,
            )),
            client_id: claims.client_id.as_ref().map(|c| c.as_str().to_owned()),
            username: Some(claims.username),
            token_type: Some("Bearer".to_owned()),
            exp: Some(claims.exp),
            iat: Some(claims.iat),
            sub: Some(claims.sub),
            aud: claims.aud.iter().map(ToString::to_string).collect(),
            iss: Some(claims.iss),
            jti: Some(claims.jti),
        })),
        Err(_) => Ok(None),
    }
}