systemprompt-api 0.63.1

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
//! Authorization-request validation.
//!
//! Enforces the supported OAuth parameter set: `response_type`, PKCE, display
//! and prompt values, and the RFC 9728 `resource` self-origin carve-out
//! ([`SelfOrigins`]). [`validate_authorize_request`] resolves and checks the
//! effective scope against the registered client.
//!
//! Copyright (c) systemprompt.io — Business Source License 1.1.
//! See <https://systemprompt.io> for licensing details.

mod entropy;
mod redirect;
mod resource;

pub use redirect::{RegisteredRedirect, resolve_registered_redirect};

use super::AuthorizeQuery;
use systemprompt_models::net::OutboundUrlError;
use systemprompt_oauth::OauthError;
use systemprompt_oauth::models::clients::OAuthClient;
use systemprompt_oauth::repository::OAuthRepository;
use url::Origin;

use crate::routes::oauth::{OAuthHttpError, internal};

#[derive(Debug, Clone)]
pub struct ValidatedAuthorizeRequest {
    pub client: OAuthClient,
    pub scope: String,
}

/// Origin pair the `resource` self-origin carve-out matches against.
///
/// `primary` is derived from `api_external_url`; `request` is derived from the
/// (allowlisted) Host header so that RFC 9728 dual-self-identity flows — where
/// one gateway answers on both `127.0.0.1` and `localhost` — accept resource
/// URIs constructed from either advertised identity.
#[derive(Debug, Clone)]
pub struct SelfOrigins {
    primary: Origin,
    request: Origin,
}

impl SelfOrigins {
    #[must_use]
    pub const fn new(primary: Origin, request: Origin) -> Self {
        Self { primary, request }
    }

    pub fn matches(&self, other: &Origin) -> bool {
        &self.primary == other || &self.request == other
    }
}

#[derive(Debug, thiserror::Error)]
pub enum AuthorizeRequestError {
    #[error("{0}")]
    Denied(String),
    #[error("Invalid scopes requested: {0}")]
    Scope(#[source] OauthError),
    #[error(transparent)]
    Oauth(#[from] OauthError),
}

impl From<AuthorizeRequestError> for OAuthHttpError {
    fn from(error: AuthorizeRequestError) -> Self {
        match error {
            AuthorizeRequestError::Denied(message) => Self::invalid_request(message),
            AuthorizeRequestError::Scope(source) => {
                internal::classify_validation(source, Self::invalid_request)
            },
            AuthorizeRequestError::Oauth(source) => Self::from(source),
        }
    }
}

pub async fn validate_authorize_request(
    state: &systemprompt_oauth::OAuthState,
    params: &AuthorizeQuery,
    repo: &OAuthRepository,
) -> Result<ValidatedAuthorizeRequest, AuthorizeRequestError> {
    if params.response_type != "code" {
        return Err(AuthorizeRequestError::Denied(
            "Unsupported response_type. Only 'code' is supported".to_owned(),
        ));
    }

    let client = repo
        .find_client_by_id(&params.client_id)
        .await?
        .ok_or_else(|| AuthorizeRequestError::Denied("Invalid client_id".to_owned()))?;

    if let Some(redirect_uri) = &params.redirect_uri {
        use systemprompt_oauth::services::validation::validate_redirect_uri;

        validate_redirect_uri(&client.redirect_uris, Some(redirect_uri)).map_err(|_e| {
            AuthorizeRequestError::Denied(format!(
                "redirect_uri '{redirect_uri}' not registered for client '{}'",
                params.client_id
            ))
        })?;
    }

    let resource_scopes = match &params.resource {
        Some(resource) => resource::resolve_resource_scopes(state, resource).await,
        None => None,
    };

    let scope = if let Some(scope_param) = params.scope.as_deref() {
        scope_param.to_owned()
    } else if let Some(ref rs) = resource_scopes {
        rs.clone()
    } else if client.scopes.is_empty() {
        return Err(AuthorizeRequestError::Denied(
            "Client has no registered scopes and none provided in request".to_owned(),
        ));
    } else {
        client.scopes.join(" ")
    };

    let requested_scopes = OAuthRepository::parse_scopes(&scope);

    OAuthRepository::validate_scopes(&requested_scopes).map_err(AuthorizeRequestError::Scope)?;
    OAuthRepository::validate_scopes_for_client(&client.scopes, &requested_scopes)
        .map_err(AuthorizeRequestError::Scope)?;

    Ok(ValidatedAuthorizeRequest { client, scope })
}

#[derive(Debug, thiserror::Error)]
pub enum AuthorizeParamError {
    #[error("{0}")]
    Invalid(String),
    #[error("Resource URI points to an internal or private network address: {0}")]
    BlockedResource(#[source] OutboundUrlError),
    #[error("Invalid resource URI: {0}")]
    InvalidResource(#[source] OutboundUrlError),
}

pub fn validate_oauth_parameters(
    params: &AuthorizeQuery,
    self_origins: &SelfOrigins,
) -> Result<(), AuthorizeParamError> {
    if params.response_type != "code" {
        return Err(AuthorizeParamError::Invalid(format!(
            "Unsupported response_type '{}'. Only 'code' is supported.",
            params.response_type
        )));
    }

    if let Some(response_mode) = &params.response_mode
        && response_mode != "query"
    {
        return Err(AuthorizeParamError::Invalid(format!(
            "Unsupported response_mode '{response_mode}'. Only 'query' mode is supported."
        )));
    }

    validate_pkce(params)?;
    validate_display_and_prompt(params)?;

    if let Some(max_age) = params.max_age
        && max_age < 0
    {
        return Err(AuthorizeParamError::Invalid(
            "max_age must be a non-negative integer".to_owned(),
        ));
    }

    if let Some(resource) = &params.resource {
        resource::validate_resource_uri(resource, self_origins)?;
    }

    Ok(())
}

fn validate_pkce(params: &AuthorizeQuery) -> Result<(), AuthorizeParamError> {
    let Some(code_challenge) = &params.code_challenge else {
        return Err(AuthorizeParamError::Invalid(
            "code_challenge is required. PKCE with S256 method must be used.".to_owned(),
        ));
    };

    if code_challenge.len() < systemprompt_oauth::constants::pkce::CODE_CHALLENGE_MIN_LENGTH {
        return Err(AuthorizeParamError::Invalid(format!(
            "code_challenge too short. Must be at least {} characters for security.",
            systemprompt_oauth::constants::pkce::CODE_CHALLENGE_MIN_LENGTH
        )));
    }
    if code_challenge.len() > systemprompt_oauth::constants::pkce::CODE_CHALLENGE_MAX_LENGTH {
        return Err(AuthorizeParamError::Invalid(format!(
            "code_challenge too long. Must be at most {} characters.",
            systemprompt_oauth::constants::pkce::CODE_CHALLENGE_MAX_LENGTH
        )));
    }

    let is_valid_base64url = code_challenge
        .chars()
        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');

    if !is_valid_base64url {
        return Err(AuthorizeParamError::Invalid(
            "code_challenge must be base64url encoded (A-Z, a-z, 0-9, -, _)".to_owned(),
        ));
    }

    if entropy::is_low_entropy_challenge(code_challenge) {
        return Err(AuthorizeParamError::Invalid(
            "code_challenge appears to have insufficient entropy for security".to_owned(),
        ));
    }

    let method = params.code_challenge_method.as_deref().ok_or_else(|| {
        AuthorizeParamError::Invalid(
            "code_challenge_method is required when code_challenge is provided".to_owned(),
        )
    })?;

    match method {
        "S256" => Ok(()),
        "plain" => Err(AuthorizeParamError::Invalid(
            "PKCE method 'plain' is not allowed. Use 'S256' for security.".to_owned(),
        )),
        _ => Err(AuthorizeParamError::Invalid(format!(
            "Unsupported code_challenge_method '{method}'. Only 'S256' is allowed."
        ))),
    }
}

fn validate_display_and_prompt(params: &AuthorizeQuery) -> Result<(), AuthorizeParamError> {
    if let Some(display) = &params.display {
        match display.as_str() {
            "page" | "popup" | "touch" | "wap" => {},
            _ => {
                return Err(AuthorizeParamError::Invalid(format!(
                    "Unsupported display value '{display}'. Supported values: page, popup, touch, \
                     wap."
                )));
            },
        }
    }

    if let Some(prompt) = &params.prompt {
        for prompt_value in prompt.split_whitespace() {
            match prompt_value {
                "none" | "login" | "consent" | "select_account" | "passkey" => {},
                _ => {
                    return Err(AuthorizeParamError::Invalid(format!(
                        "Unsupported prompt value '{prompt_value}'. Supported values: none, \
                         login, consent, select_account, passkey."
                    )));
                },
            }
        }
    }

    Ok(())
}