use super::response_builder::{
convert_form_to_query, generate_webauthn_form, is_user_consent_granted,
};
use super::validation::{
RegisteredRedirect, SelfOrigins, resolve_registered_redirect, validate_authorize_request,
validate_oauth_parameters,
};
use super::{AuthorizeQuery, AuthorizeRequest};
use crate::routes::oauth::extractors::OAuthRepo;
use crate::routes::oauth::{OAuthHttpError, internal};
use crate::services::request_base_url::RequestBaseUrl;
use axum::extract::{Extension, Form, Query, State};
use axum::response::{Html, IntoResponse, Response};
use systemprompt_manifest::Config;
use systemprompt_models::RequestContext;
use systemprompt_oauth::OAuthState;
use systemprompt_oauth::services::validation::CsrfToken;
use tracing::instrument;
fn require_csrf_token(params: &AuthorizeQuery) -> Result<CsrfToken, OAuthHttpError> {
match params.state.as_deref() {
None | Some("") => Err(OAuthHttpError::invalid_request(
"CSRF token (state parameter) is required",
)),
Some(state_str) => CsrfToken::new(state_str).map_err(|_e| {
OAuthHttpError::invalid_request("CSRF token (state parameter) is invalid")
}),
}
}
fn resolve_self_origins(base: &RequestBaseUrl) -> Result<SelfOrigins, OAuthHttpError> {
let primary_origin = Config::get()
.map_err(|e| internal::server_error("Configuration unavailable", e))
.and_then(|c| {
reqwest::Url::parse(&c.api_external_url)
.map(|u| u.origin())
.map_err(|e| internal::server_error("Configuration invalid", e))
})?;
Ok(SelfOrigins::new(primary_origin, base.origin().clone()))
}
fn wants_passkey_form(params: &AuthorizeQuery) -> bool {
params
.prompt
.as_deref()
.is_some_and(|p| p.split_whitespace().any(|v| v == "passkey"))
}
fn login_page_redirect(params: &AuthorizeQuery) -> Option<Response> {
let login_page_url = Config::get().ok()?.login_page_url.clone()?;
let target = login_page_redirect_target(&login_page_url, params)?;
Some(axum::response::Redirect::to(&target).into_response())
}
#[must_use]
pub fn login_page_redirect_target(login_page_url: &str, params: &AuthorizeQuery) -> Option<String> {
if wants_passkey_form(params) {
return None;
}
match reqwest::Url::parse(login_page_url) {
Ok(url) if matches!(url.scheme(), "http" | "https") => {},
_ => {
tracing::error!(
login_page_url = %login_page_url,
"security.login_page_url is not an absolute http(s) URL; rendering built-in form"
);
return None;
},
}
let query = authorize_query_string(params);
Some(format!("{login_page_url}?{query}"))
}
fn authorize_query_string(params: &AuthorizeQuery) -> String {
let mut pairs: Vec<(&str, &str)> = vec![
("response_type", params.response_type.as_str()),
("client_id", params.client_id.as_str()),
];
let optional = [
("redirect_uri", params.redirect_uri.as_deref()),
("scope", params.scope.as_deref()),
("state", params.state.as_deref()),
("code_challenge", params.code_challenge.as_deref()),
(
"code_challenge_method",
params.code_challenge_method.as_deref(),
),
("resource", params.resource.as_deref()),
];
for (key, value) in optional {
if let Some(value) = value {
pairs.push((key, value));
}
}
pairs
.iter()
.map(|(k, v)| format!("{k}={}", urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&")
}
#[instrument(skip(repo, _req_ctx, params), fields(client_id = %params.client_id))]
pub async fn handle_authorize_get(
State(state): State<OAuthState>,
Extension(_req_ctx): Extension<RequestContext>,
base: RequestBaseUrl,
Query(params): Query<AuthorizeQuery>,
OAuthRepo(repo): OAuthRepo,
) -> Result<Response, OAuthHttpError> {
tracing::info!(
client_id = %params.client_id,
response_type = %params.response_type,
redirect_uri = ?params.redirect_uri,
requested_scopes = ?params.scope,
state_present = params.state.is_some(),
pkce_challenge_present = params.code_challenge.is_some(),
code_challenge_method = ?params.code_challenge_method,
"Authorization request received"
);
require_csrf_token(¶ms)?;
let redirect = resolve_registered_redirect(&repo, ¶ms).await?;
let attach = |err| RegisteredRedirect::attach_if_registered(redirect.as_ref(), err);
if params.response_type.is_empty() || params.client_id.as_str().is_empty() {
return Err(attach(OAuthHttpError::invalid_request(
"Validation error: Missing required parameters",
)));
}
let self_origins = resolve_self_origins(&base)?;
if let Err(validation_error) = validate_oauth_parameters(¶ms, &self_origins) {
return Err(attach(OAuthHttpError::invalid_request(
validation_error.to_string(),
)));
}
match validate_authorize_request(&state, ¶ms, &repo).await {
Ok(validated) => {
tracing::info!(
client_id = %params.client_id,
resolved_scopes = %validated.scope,
redirect_uri = ?params.redirect_uri,
state = ?params.state,
"Authorization request validated"
);
if let Some(redirect) = login_page_redirect(¶ms) {
return Ok(redirect);
}
Ok(Html(generate_webauthn_form(¶ms, &validated)).into_response())
},
Err(error) => {
tracing::info!(
client_id = %params.client_id,
denial_reason = %error,
requested_scopes = ?params.scope,
redirect_uri = ?params.redirect_uri,
"Authorization request denied"
);
Err(attach(OAuthHttpError::from(error)))
},
}
}
#[instrument(skip(repo, _req_ctx, form), fields(client_id = %form.client_id))]
pub async fn handle_authorize_post(
State(state): State<OAuthState>,
Extension(_req_ctx): Extension<RequestContext>,
OAuthRepo(repo): OAuthRepo,
Form(form): Form<AuthorizeRequest>,
) -> Result<Response, OAuthHttpError> {
let query = convert_form_to_query(&form);
tracing::info!(
client_id = %form.client_id,
user_consent = ?form.user_consent,
username_provided = form.username.is_some(),
password_provided = form.password.is_some(),
response_type = %form.response_type,
"Authorization form submission received"
);
let redirect = resolve_registered_redirect(&repo, &query).await?;
let attach = |err| RegisteredRedirect::attach_if_registered(redirect.as_ref(), err);
if let Err(error) = validate_authorize_request(&state, &query, &repo).await {
return Err(attach(OAuthHttpError::from(error)));
}
if !is_user_consent_granted(&form) {
tracing::info!(
client_id = %form.client_id,
denial_reason = "user_denied_consent",
requested_scopes = ?form.scope,
"User consent denied"
);
return Err(attach(OAuthHttpError::access_denied(
"User denied the request",
)));
}
tracing::info!(
client_id = %form.client_id,
attempted_method = "password_based",
supported_method = "webauthn",
"Unsupported authentication method attempted"
);
Err(attach(OAuthHttpError::unsupported_grant_type(
"Password authentication not supported. Use WebAuthn flow instead.",
)))
}