systemprompt_api/routes/oauth/endpoints/authorize/validation/
mod.rs1mod 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#[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(¶ms.client_id)
90 .await?
91 .ok_or_else(|| AuthorizeRequestError::Denied("Invalid client_id".to_owned()))?;
92
93 if let Some(redirect_uri) = ¶ms.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 ¶ms.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) = ¶ms.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) = ¶ms.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) = ¶ms.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) = ¶ms.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) = ¶ms.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}