Skip to main content

systemprompt_api/routes/oauth/endpoints/authorize/validation/
mod.rs

1//! Authorization-request validation.
2//!
3//! Enforces the supported OAuth parameter set: `response_type`, PKCE, display
4//! and prompt values, and the RFC 9728 `resource` self-origin carve-out
5//! ([`SelfOrigins`]). [`validate_authorize_request`] resolves and checks the
6//! effective scope against the registered client.
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11mod entropy;
12mod redirect;
13mod resource;
14
15pub use redirect::{RegisteredRedirect, resolve_registered_redirect};
16
17use super::AuthorizeQuery;
18use systemprompt_models::net::OutboundUrlError;
19use systemprompt_oauth::OauthError;
20use systemprompt_oauth::models::clients::OAuthClient;
21use systemprompt_oauth::repository::OAuthRepository;
22use url::Origin;
23
24use crate::routes::oauth::{OAuthHttpError, internal};
25
26#[derive(Debug, Clone)]
27pub struct ValidatedAuthorizeRequest {
28    pub client: OAuthClient,
29    pub scope: String,
30}
31
32/// Origin pair the `resource` self-origin carve-out matches against.
33///
34/// `primary` is derived from `api_external_url`; `request` is derived from the
35/// (allowlisted) Host header so that RFC 9728 dual-self-identity flows — where
36/// one gateway answers on both `127.0.0.1` and `localhost` — accept resource
37/// URIs constructed from either advertised identity.
38#[derive(Debug, Clone)]
39pub struct SelfOrigins {
40    primary: Origin,
41    request: Origin,
42}
43
44impl SelfOrigins {
45    #[must_use]
46    pub const fn new(primary: Origin, request: Origin) -> Self {
47        Self { primary, request }
48    }
49
50    pub fn matches(&self, other: &Origin) -> bool {
51        &self.primary == other || &self.request == other
52    }
53}
54
55#[derive(Debug, thiserror::Error)]
56pub enum AuthorizeRequestError {
57    #[error("{0}")]
58    Denied(String),
59    #[error("Invalid scopes requested: {0}")]
60    Scope(#[source] OauthError),
61    #[error(transparent)]
62    Oauth(#[from] OauthError),
63}
64
65impl From<AuthorizeRequestError> for OAuthHttpError {
66    fn from(error: AuthorizeRequestError) -> Self {
67        match error {
68            AuthorizeRequestError::Denied(message) => Self::invalid_request(message),
69            AuthorizeRequestError::Scope(source) => {
70                internal::classify_validation(source, Self::invalid_request)
71            },
72            AuthorizeRequestError::Oauth(source) => Self::from(source),
73        }
74    }
75}
76
77pub async fn validate_authorize_request(
78    state: &systemprompt_oauth::OAuthState,
79    params: &AuthorizeQuery,
80    repo: &OAuthRepository,
81) -> Result<ValidatedAuthorizeRequest, AuthorizeRequestError> {
82    if params.response_type != "code" {
83        return Err(AuthorizeRequestError::Denied(
84            "Unsupported response_type. Only 'code' is supported".to_owned(),
85        ));
86    }
87
88    let client = repo
89        .find_client_by_id(&params.client_id)
90        .await?
91        .ok_or_else(|| AuthorizeRequestError::Denied("Invalid client_id".to_owned()))?;
92
93    if let Some(redirect_uri) = &params.redirect_uri {
94        use systemprompt_oauth::services::validation::validate_redirect_uri;
95
96        validate_redirect_uri(&client.redirect_uris, Some(redirect_uri)).map_err(|_e| {
97            AuthorizeRequestError::Denied(format!(
98                "redirect_uri '{redirect_uri}' not registered for client '{}'",
99                params.client_id
100            ))
101        })?;
102    }
103
104    let resource_scopes = match &params.resource {
105        Some(resource) => resource::resolve_resource_scopes(state, resource).await,
106        None => None,
107    };
108
109    let scope = if let Some(scope_param) = params.scope.as_deref() {
110        scope_param.to_owned()
111    } else if let Some(ref rs) = resource_scopes {
112        rs.clone()
113    } else if client.scopes.is_empty() {
114        return Err(AuthorizeRequestError::Denied(
115            "Client has no registered scopes and none provided in request".to_owned(),
116        ));
117    } else {
118        client.scopes.join(" ")
119    };
120
121    let requested_scopes = OAuthRepository::parse_scopes(&scope);
122
123    OAuthRepository::validate_scopes(&requested_scopes).map_err(AuthorizeRequestError::Scope)?;
124    OAuthRepository::validate_scopes_for_client(&client.scopes, &requested_scopes)
125        .map_err(AuthorizeRequestError::Scope)?;
126
127    Ok(ValidatedAuthorizeRequest { client, scope })
128}
129
130#[derive(Debug, thiserror::Error)]
131pub enum AuthorizeParamError {
132    #[error("{0}")]
133    Invalid(String),
134    #[error("Resource URI points to an internal or private network address: {0}")]
135    BlockedResource(#[source] OutboundUrlError),
136    #[error("Invalid resource URI: {0}")]
137    InvalidResource(#[source] OutboundUrlError),
138}
139
140pub fn validate_oauth_parameters(
141    params: &AuthorizeQuery,
142    self_origins: &SelfOrigins,
143) -> Result<(), AuthorizeParamError> {
144    if params.response_type != "code" {
145        return Err(AuthorizeParamError::Invalid(format!(
146            "Unsupported response_type '{}'. Only 'code' is supported.",
147            params.response_type
148        )));
149    }
150
151    if let Some(response_mode) = &params.response_mode
152        && response_mode != "query"
153    {
154        return Err(AuthorizeParamError::Invalid(format!(
155            "Unsupported response_mode '{response_mode}'. Only 'query' mode is supported."
156        )));
157    }
158
159    validate_pkce(params)?;
160    validate_display_and_prompt(params)?;
161
162    if let Some(max_age) = params.max_age
163        && max_age < 0
164    {
165        return Err(AuthorizeParamError::Invalid(
166            "max_age must be a non-negative integer".to_owned(),
167        ));
168    }
169
170    if let Some(resource) = &params.resource {
171        resource::validate_resource_uri(resource, self_origins)?;
172    }
173
174    Ok(())
175}
176
177fn validate_pkce(params: &AuthorizeQuery) -> Result<(), AuthorizeParamError> {
178    let Some(code_challenge) = &params.code_challenge else {
179        return Err(AuthorizeParamError::Invalid(
180            "code_challenge is required. PKCE with S256 method must be used.".to_owned(),
181        ));
182    };
183
184    if code_challenge.len() < systemprompt_oauth::constants::pkce::CODE_CHALLENGE_MIN_LENGTH {
185        return Err(AuthorizeParamError::Invalid(format!(
186            "code_challenge too short. Must be at least {} characters for security.",
187            systemprompt_oauth::constants::pkce::CODE_CHALLENGE_MIN_LENGTH
188        )));
189    }
190    if code_challenge.len() > systemprompt_oauth::constants::pkce::CODE_CHALLENGE_MAX_LENGTH {
191        return Err(AuthorizeParamError::Invalid(format!(
192            "code_challenge too long. Must be at most {} characters.",
193            systemprompt_oauth::constants::pkce::CODE_CHALLENGE_MAX_LENGTH
194        )));
195    }
196
197    let is_valid_base64url = code_challenge
198        .chars()
199        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
200
201    if !is_valid_base64url {
202        return Err(AuthorizeParamError::Invalid(
203            "code_challenge must be base64url encoded (A-Z, a-z, 0-9, -, _)".to_owned(),
204        ));
205    }
206
207    if entropy::is_low_entropy_challenge(code_challenge) {
208        return Err(AuthorizeParamError::Invalid(
209            "code_challenge appears to have insufficient entropy for security".to_owned(),
210        ));
211    }
212
213    let method = params.code_challenge_method.as_deref().ok_or_else(|| {
214        AuthorizeParamError::Invalid(
215            "code_challenge_method is required when code_challenge is provided".to_owned(),
216        )
217    })?;
218
219    match method {
220        "S256" => Ok(()),
221        "plain" => Err(AuthorizeParamError::Invalid(
222            "PKCE method 'plain' is not allowed. Use 'S256' for security.".to_owned(),
223        )),
224        _ => Err(AuthorizeParamError::Invalid(format!(
225            "Unsupported code_challenge_method '{method}'. Only 'S256' is allowed."
226        ))),
227    }
228}
229
230fn validate_display_and_prompt(params: &AuthorizeQuery) -> Result<(), AuthorizeParamError> {
231    if let Some(display) = &params.display {
232        match display.as_str() {
233            "page" | "popup" | "touch" | "wap" => {},
234            _ => {
235                return Err(AuthorizeParamError::Invalid(format!(
236                    "Unsupported display value '{display}'. Supported values: page, popup, touch, \
237                     wap."
238                )));
239            },
240        }
241    }
242
243    if let Some(prompt) = &params.prompt {
244        for prompt_value in prompt.split_whitespace() {
245            match prompt_value {
246                "none" | "login" | "consent" | "select_account" | "passkey" => {},
247                _ => {
248                    return Err(AuthorizeParamError::Invalid(format!(
249                        "Unsupported prompt value '{prompt_value}'. Supported values: none, \
250                         login, consent, select_account, passkey."
251                    )));
252                },
253            }
254        }
255    }
256
257    Ok(())
258}