use std::sync::Arc;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::Redirect;
use axum::{Extension, Json};
use axum_extra::extract::PrivateCookieJar;
use kellnr_appstate::{AppState, DbState, SettingsState};
use kellnr_auth::oauth2::{OAuth2Handler, generate_unique_username};
use serde::{Deserialize, Serialize};
use tracing::{error, trace, warn};
use utoipa::ToSchema;
use crate::error::RouteError;
use crate::session::create_session_jar;
pub type OAuth2Ext = Extension<Option<Arc<OAuth2Handler>>>;
#[derive(Debug, Serialize, ToSchema)]
pub struct OAuth2Config {
pub enabled: bool,
pub button_text: String,
}
#[derive(Debug, Deserialize, ToSchema, utoipa::IntoParams)]
pub struct CallbackQuery {
pub code: String,
pub state: String,
}
#[utoipa::path(
get,
path = "/config",
tag = "oauth2",
responses(
(status = 200, description = "OAuth2 configuration", body = OAuth2Config)
)
)]
#[allow(clippy::unused_async)]
pub async fn get_config(State(settings): SettingsState) -> Json<OAuth2Config> {
Json(OAuth2Config {
enabled: settings.oauth2.enabled,
button_text: settings.oauth2.button_text.clone(),
})
}
#[utoipa::path(
get,
path = "/login",
tag = "oauth2",
responses(
(status = 302, description = "Redirect to OAuth2 provider"),
(status = 404, description = "OAuth2 not enabled")
)
)]
pub async fn login(
State(db): DbState,
Extension(oauth2_handler): OAuth2Ext,
) -> Result<Redirect, RouteError> {
trace!("OAuth2 login initiated");
let handler = oauth2_handler.as_ref().ok_or_else(|| {
warn!("OAuth2 login attempted but OAuth2 is not enabled");
RouteError::Status(StatusCode::NOT_FOUND)
})?;
let auth_request = handler.generate_auth_url();
db.store_oauth2_state(
&auth_request.state,
&auth_request.pkce_verifier,
&auth_request.nonce,
)
.await
.map_err(|e| {
error!("Failed to store OAuth2 state: {}", e);
RouteError::Status(StatusCode::INTERNAL_SERVER_ERROR)
})?;
Ok(Redirect::to(auth_request.auth_url.as_str()))
}
#[utoipa::path(
get,
path = "/callback",
tag = "oauth2",
params(CallbackQuery),
responses(
(status = 302, description = "Redirect to UI after successful login"),
(status = 400, description = "Invalid state"),
(status = 401, description = "Token exchange failed"),
(status = 403, description = "User not found and auto-provisioning disabled"),
(status = 404, description = "OAuth2 not enabled")
)
)]
pub async fn callback(
cookies: PrivateCookieJar,
Query(query): Query<CallbackQuery>,
State(app_state): AppState,
Extension(oauth2_handler): OAuth2Ext,
) -> Result<(PrivateCookieJar, Redirect), RouteError> {
trace!(state = %query.state, "OAuth2 callback received");
let handler = oauth2_handler.as_ref().ok_or_else(|| {
warn!("OAuth2 callback received but OAuth2 is not enabled");
RouteError::Status(StatusCode::NOT_FOUND)
})?;
let state_data = app_state
.db
.get_and_delete_oauth2_state(&query.state)
.await
.map_err(|e| {
error!("Failed to retrieve OAuth2 state: {}", e);
RouteError::Status(StatusCode::INTERNAL_SERVER_ERROR)
})?
.ok_or_else(|| {
warn!(
"OAuth2 callback with invalid or expired state: {}",
query.state
);
RouteError::Status(StatusCode::BAD_REQUEST)
})?;
let token_result = handler
.exchange_and_validate(&query.code, &state_data.pkce_verifier, &state_data.nonce)
.await
.map_err(|e| {
error!("Failed to exchange OAuth2 code: {}", e);
RouteError::Status(StatusCode::UNAUTHORIZED)
})?;
let user_info = handler.extract_user_info(&token_result);
let issuer = handler.issuer_url();
trace!(
"OAuth2 authentication successful for subject: {}, email: {:?}",
user_info.subject, user_info.email
);
#[allow(clippy::single_match_else)]
let user = match app_state
.db
.get_user_by_oauth2_identity(issuer, &user_info.subject)
.await
.map_err(|e| {
error!("Failed to look up OAuth2 identity: {}", e);
RouteError::Status(StatusCode::INTERNAL_SERVER_ERROR)
})? {
Some(user) => {
trace!("Found existing user '{}' for OAuth2 identity", user.name);
user
}
None => {
if !handler.settings().auto_provision_users {
warn!(
"OAuth2 user not found and auto-provisioning is disabled: {}",
user_info.subject
);
return Err(RouteError::Status(StatusCode::FORBIDDEN));
}
let username = generate_unique_username(&user_info, |name| {
let db = app_state.db.clone();
async move { db.is_username_available(&name).await.unwrap_or(false) }
})
.await;
trace!(
"Creating new OAuth2 user '{}' (admin: {}, read_only: {})",
username, user_info.is_admin, user_info.is_read_only
);
app_state
.db
.create_oauth2_user(
&username,
issuer,
&user_info.subject,
user_info.email.clone(),
user_info.is_admin,
user_info.is_read_only,
)
.await
.map_err(|e| {
error!("Failed to create OAuth2 user: {}", e);
RouteError::Status(StatusCode::INTERNAL_SERVER_ERROR)
})?
}
};
let jar = create_session_jar(cookies, &app_state, &user.name).await?;
trace!("Created session for OAuth2 user: {}", user.name);
let mut base_path = app_state.settings.origin.path.clone();
if !base_path.ends_with('/') {
base_path.push('/');
}
if !base_path.starts_with('/') {
base_path.insert(0, '/');
}
Ok((jar, Redirect::to(&base_path)))
}
#[derive(Debug, Deserialize, ToSchema, utoipa::IntoParams)]
pub struct ErrorQuery {
pub error: String,
#[serde(default)]
pub error_description: Option<String>,
}
#[utoipa::path(
get,
path = "/error",
tag = "oauth2",
params(ErrorQuery),
responses(
(status = 302, description = "Redirect to login page with error")
)
)]
#[allow(clippy::unused_async)]
pub async fn error_callback(Query(query): Query<ErrorQuery>) -> Redirect {
warn!(
"OAuth2 error callback: {} - {:?}",
query.error, query.error_description
);
let error_msg = query.error_description.as_deref().unwrap_or(&query.error);
let encoded_error = urlencoding::encode(error_msg);
Redirect::to(&format!("/login?error={encoded_error}"))
}