use std::sync::Arc;
use std::time::Duration;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::Redirect;
use axum::{Extension, Json};
use axum_extra::extract::PrivateCookieJar;
use axum_extra::extract::cookie::{Cookie, SameSite};
use kellnr_common::account_name::UserName;
use kellnr_db::{User, UserFlags};
use kellnr_http::error::ApiError;
use kellnr_http::state::{AppState, AppStateData, DbState, SettingsState};
use kellnr_oauth2::{OAuth2Error, OAuth2Handler, UserInfo, generate_unique_username};
use kellnr_settings::UrlPathPrefix;
use kellnr_settings::constants::{COOKIE_OAUTH2_STATE, COOKIE_OIDC_ID_TOKEN, OAUTH2_STATE_MAX_AGE};
use serde::{Deserialize, Serialize};
use tracing::{error, trace, warn};
use utoipa::ToSchema;
use crate::session::{create_session_jar, session_cookie};
pub type OAuth2Ext = Extension<Option<Arc<OAuth2Handler>>>;
const PROVIDER_RETRY_AFTER: Duration = Duration::from_secs(30);
#[derive(Debug, Serialize, ToSchema)]
#[allow(clippy::struct_excessive_bools)]
pub struct OAuth2Config {
pub enabled: bool,
pub available: bool,
pub button_text: String,
pub enforced: bool,
pub auto_redirect: bool,
}
#[derive(Debug, Deserialize, ToSchema, utoipa::IntoParams)]
pub struct CallbackParams {
pub code: Option<String>,
pub state: Option<String>,
pub error: Option<String>,
pub error_description: Option<String>,
}
#[derive(Debug, PartialEq, Eq, Deserialize)]
#[serde(try_from = "CallbackParams")]
pub enum CallbackQuery {
Success { code: String, state: String },
ProviderError {
error: String,
error_description: Option<String>,
state: Option<String>,
},
}
impl TryFrom<CallbackParams> for CallbackQuery {
type Error = &'static str;
fn try_from(params: CallbackParams) -> Result<Self, Self::Error> {
match params {
CallbackParams {
error: Some(error),
error_description,
state,
..
} => Ok(Self::ProviderError {
error,
error_description,
state,
}),
CallbackParams {
code: Some(code),
state: Some(state),
..
} => Ok(Self::Success { code, state }),
_ => Err("OAuth2 callback without code or state"),
}
}
}
#[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,
Extension(oauth2_handler): OAuth2Ext,
) -> Json<OAuth2Config> {
Json(OAuth2Config {
enabled: settings.oauth2.enabled,
available: oauth2_handler
.as_ref()
.is_some_and(|handler| handler.is_available()),
button_text: settings.oauth2.button_text.clone(),
enforced: settings.oauth2.enforced,
auto_redirect: settings.oauth2.auto_redirect,
})
}
#[utoipa::path(
get,
path = "/login",
tag = "oauth2",
responses(
(status = 302, description = "Redirect to OAuth2 provider"),
(status = 404, description = "OAuth2 not enabled"),
(status = 503, description = "OIDC provider not reachable yet, retry after the `Retry-After` seconds")
)
)]
pub async fn login(
cookies: PrivateCookieJar,
State(db): DbState,
State(settings): SettingsState,
Extension(oauth2_handler): OAuth2Ext,
) -> Result<(PrivateCookieJar, Redirect), ApiError> {
trace!("OAuth2 login initiated");
let handler = oauth2_handler.as_ref().ok_or_else(|| {
warn!("OAuth2 login attempted but OAuth2 is not enabled");
ApiError::from(StatusCode::NOT_FOUND)
})?;
let auth_request = handler.generate_auth_url().map_err(|e| {
warn!("OAuth2 login attempted but the OIDC provider is unavailable: {e}");
provider_unavailable()
})?;
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);
ApiError::from(StatusCode::INTERNAL_SERVER_ERROR)
})?;
let jar = cookies.add(state_cookie(
auth_request.state,
settings.origin.secure_cookies(),
));
Ok((jar, Redirect::to(auth_request.auth_url.as_str())))
}
fn state_cookie(state: String, secure: bool) -> Cookie<'static> {
let max_age = time::Duration::try_from(OAUTH2_STATE_MAX_AGE).unwrap_or(time::Duration::ZERO);
Cookie::build((COOKIE_OAUTH2_STATE, state))
.max_age(max_age)
.same_site(SameSite::Lax)
.http_only(true)
.secure(secure)
.path("/")
.into()
}
fn take_state_cookie(
jar: PrivateCookieJar,
query_state: &str,
) -> Result<PrivateCookieJar, ApiError> {
let matches = jar
.get(COOKIE_OAUTH2_STATE)
.is_some_and(|c| c.value() == query_state);
if !matches {
warn!("OAuth2 callback state does not match the state cookie of this browser");
return Err(ApiError::from(StatusCode::BAD_REQUEST));
}
Ok(jar.remove(Cookie::build((COOKIE_OAUTH2_STATE, "")).path("/")))
}
#[utoipa::path(
get,
path = "/callback",
tag = "oauth2",
params(CallbackParams),
responses(
(status = 302, description = "Redirect to UI after successful login or to the login page on a provider error"),
(status = 400, description = "Invalid state or missing code"),
(status = 401, description = "Token exchange failed"),
(status = 403, description = "User not found and auto-provisioning disabled"),
(status = 404, description = "OAuth2 not enabled"),
(status = 503, description = "OIDC provider not reachable yet, retry after the `Retry-After` seconds")
)
)]
pub async fn callback(
cookies: PrivateCookieJar,
Query(query): Query<CallbackQuery>,
State(app_state): AppState,
Extension(oauth2_handler): OAuth2Ext,
) -> Result<(PrivateCookieJar, Redirect), ApiError> {
let (code, query_state) = match query {
CallbackQuery::Success { code, state } => {
trace!(state = ?state, "OAuth2 callback received");
(code, state)
}
CallbackQuery::ProviderError {
error,
error_description,
state,
} => {
trace!(state = ?state, "OAuth2 callback received");
warn!("OAuth2 provider returned an error: {error} - {error_description:?}");
let cookies = cookies.remove(Cookie::build((COOKIE_OAUTH2_STATE, "")).path("/"));
let redirect = login_error_redirect(
&app_state.settings.origin.path_prefix(),
error_description.as_deref().unwrap_or(&error),
);
return Ok((cookies, redirect));
}
};
let handler = oauth2_handler.as_ref().ok_or_else(|| {
warn!("OAuth2 callback received but OAuth2 is not enabled");
ApiError::from(StatusCode::NOT_FOUND)
})?;
let cookies = take_state_cookie(cookies, &query_state)?;
let state_data = app_state
.db
.get_and_delete_oauth2_state(&query_state)
.await
.map_err(|e| {
error!("Failed to retrieve OAuth2 state: {}", e);
ApiError::from(StatusCode::INTERNAL_SERVER_ERROR)
})?
.ok_or_else(|| {
warn!("OAuth2 callback with invalid or expired state: {query_state}");
ApiError::from(StatusCode::BAD_REQUEST)
})?;
let token_result = handler
.exchange_and_validate(&code, &state_data.pkce_verifier, &state_data.nonce)
.await
.map_err(|e| {
error!("Failed to exchange OAuth2 code: {}", e);
match e {
OAuth2Error::ProviderUnavailable => provider_unavailable(),
_ => ApiError::from(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);
ApiError::from(StatusCode::INTERNAL_SERVER_ERROR)
})? {
Some(user) => {
trace!("Found existing user '{}' for OAuth2 identity", user.name);
sync_oauth2_privileges(
&app_state,
handler.admin_claim_configured(),
handler.read_only_claim_configured(),
user,
&user_info,
)
.await?
}
None => {
if !handler.settings().auto_provision_users {
warn!(
"OAuth2 user not found and auto-provisioning is disabled: {}",
user_info.subject
);
return Err(ApiError::from(StatusCode::FORBIDDEN));
}
let username = generate_unique_username(&user_info, |name| {
let db = app_state.db.clone();
async move {
db.is_username_available(&UserName::from_unchecked(name))
.await
}
})
.await
.map_err(|e| {
error!("Failed to check if a user name is available: {e}");
ApiError::from(StatusCode::INTERNAL_SERVER_ERROR)
})?;
let username = UserName::from_unchecked(username);
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(),
UserFlags {
admin: user_info.is_admin,
read_only: user_info.is_read_only,
},
)
.await
.map_err(|e| {
error!("Failed to create OAuth2 user: {}", e);
ApiError::from(StatusCode::INTERNAL_SERVER_ERROR)
})?
}
};
let jar = create_session_jar(cookies, &app_state, &user.name).await?;
let jar = jar.add(session_cookie(
COOKIE_OIDC_ID_TOKEN,
token_result.id_token.clone(),
app_state.settings.registry.session_age().cookie_max_age(),
app_state.settings.origin.secure_cookies(),
));
trace!("Created session for OAuth2 user: {}", user.name);
let base_path = format!("{}/", app_state.settings.origin.path_prefix());
Ok((jar, Redirect::to(&base_path)))
}
async fn sync_oauth2_privileges(
app_state: &AppStateData,
admin_governed: bool,
read_only_governed: bool,
mut user: User,
user_info: &UserInfo,
) -> Result<User, ApiError> {
let mut changed = false;
if admin_governed && user.is_admin != user_info.is_admin {
app_state
.db
.change_admin_state(&user.name, user_info.is_admin)
.await
.map_err(|e| {
error!(
"Failed to sync OAuth2 admin state for '{}': {}",
user.name, e
);
ApiError::from(StatusCode::INTERNAL_SERVER_ERROR)
})?;
user.is_admin = user_info.is_admin;
changed = true;
}
if read_only_governed && user.is_read_only != user_info.is_read_only {
app_state
.db
.change_read_only_state(&user.name, user_info.is_read_only)
.await
.map_err(|e| {
error!(
"Failed to sync OAuth2 read-only state for '{}': {}",
user.name, e
);
ApiError::from(StatusCode::INTERNAL_SERVER_ERROR)
})?;
user.is_read_only = user_info.is_read_only;
changed = true;
}
if changed {
app_state.token_cache.invalidate_all();
trace!(
"Synced OAuth2 privileges for '{}' (admin: {}, read_only: {})",
user.name, user.is_admin, user.is_read_only
);
}
Ok(user)
}
#[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(
State(settings): SettingsState,
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);
login_error_redirect(&settings.origin.path_prefix(), error_msg)
}
fn provider_unavailable() -> ApiError {
ApiError::service_unavailable(
"Single sign-on is temporarily unavailable",
PROVIDER_RETRY_AFTER,
)
}
fn login_error_redirect(path_prefix: &UrlPathPrefix, error: &str) -> Redirect {
let encoded_error = urlencoding::encode(error);
Redirect::to(&format!("{path_prefix}/login?error={encoded_error}"))
}
#[cfg(test)]
mod sync_tests {
use kellnr_db::DbProvider;
use kellnr_http::test_support::sqlite_test_state;
use kellnr_test_db::TestDb;
use super::*;
fn user_info(is_admin: bool, is_read_only: bool) -> UserInfo {
UserInfo {
subject: "sub".to_string(),
email: None,
preferred_username: None,
groups: vec![],
is_admin,
is_read_only,
}
}
fn alice() -> UserName {
UserName::new("alice").unwrap()
}
async fn state_with_alice(is_admin: bool, is_read_only: bool) -> (AppStateData, TestDb) {
let (state, db) = sqlite_test_state().await;
let flags = UserFlags {
admin: is_admin,
read_only: is_read_only,
};
db.add_user(&alice(), "pwd", flags).await.unwrap();
(state, db)
}
async fn sync(
stored: (bool, bool),
admin_governed: bool,
read_only_governed: bool,
user_info: &UserInfo,
) -> (User, User) {
let (state, db) = state_with_alice(stored.0, stored.1).await;
let user = db.get_user(&alice()).await.unwrap();
let result =
sync_oauth2_privileges(&state, admin_governed, read_only_governed, user, user_info)
.await
.unwrap();
(result, db.get_user(&alice()).await.unwrap())
}
#[tokio::test]
async fn read_only_governed_promotes_user() {
let (result, stored) = sync((false, false), false, true, &user_info(false, true)).await;
assert!(result.is_read_only);
assert!(stored.is_read_only);
}
#[tokio::test]
async fn admin_governed_demotes_user() {
let (result, stored) = sync((true, false), true, false, &user_info(false, false)).await;
assert!(!result.is_admin);
assert!(!stored.is_admin);
}
#[tokio::test]
async fn unconfigured_claim_leaves_user_untouched() {
let (result, stored) = sync((true, false), false, false, &user_info(false, true)).await;
assert!(result.is_admin);
assert!(!result.is_read_only);
assert!(stored.is_admin);
assert!(!stored.is_read_only);
}
#[tokio::test]
async fn governed_but_unchanged_is_noop() {
let (result, stored) = sync((true, true), true, true, &user_info(true, true)).await;
assert!(result.is_admin);
assert!(result.is_read_only);
assert!(stored.is_admin);
assert!(stored.is_read_only);
}
}
#[cfg(test)]
mod state_cookie_tests {
use axum::http::{HeaderMap, header};
use axum_extra::extract::cookie::Key;
use kellnr_http::test_support::{TEST_KEY, encode_cookies};
use super::*;
fn jar_with_state(state: &'static str) -> PrivateCookieJar {
let mut headers = HeaderMap::new();
headers.insert(
header::COOKIE,
encode_cookies([(COOKIE_OAUTH2_STATE, state)])
.parse()
.unwrap(),
);
PrivateCookieJar::from_headers(&headers, Key::from(TEST_KEY))
}
fn empty_jar() -> PrivateCookieJar {
PrivateCookieJar::from_headers(&HeaderMap::new(), Key::from(TEST_KEY))
}
#[test]
fn state_cookie_attributes() {
let cookie = state_cookie("abc".to_string(), true);
assert_eq!(cookie.name(), COOKIE_OAUTH2_STATE);
assert_eq!(cookie.value(), "abc");
assert_eq!(cookie.same_site(), Some(SameSite::Lax));
assert_eq!(cookie.http_only(), Some(true));
assert_eq!(cookie.secure(), Some(true));
assert_eq!(cookie.path(), Some("/"));
assert_eq!(cookie.max_age(), Some(time::Duration::minutes(10)));
}
#[test]
fn state_cookie_not_secure_over_http() {
assert_eq!(state_cookie("abc".to_string(), false).secure(), Some(false));
}
#[test]
fn matching_state_is_accepted_and_cookie_removed() {
let jar = take_state_cookie(jar_with_state("abc"), "abc").unwrap();
assert!(jar.get(COOKIE_OAUTH2_STATE).is_none());
}
#[test]
fn mismatching_state_is_rejected() {
let result = take_state_cookie(jar_with_state("abc"), "attacker");
assert!(
result
.as_ref()
.is_err_and(|e| e.status() == StatusCode::BAD_REQUEST)
);
}
#[test]
fn missing_state_cookie_is_rejected() {
let result = take_state_cookie(empty_jar(), "abc");
assert!(
result
.as_ref()
.is_err_and(|e| e.status() == StatusCode::BAD_REQUEST)
);
}
}
#[cfg(test)]
mod callback_tests {
use axum::http::{HeaderMap, header};
use axum::response::IntoResponse;
use axum_extra::extract::cookie::Key;
use kellnr_http::test_support::TEST_KEY;
use kellnr_settings::Settings;
use super::*;
fn state_with_origin_path(path: &str) -> AppStateData {
let mut settings = Settings::clone(&kellnr_http::test_support::test_state().settings);
settings.origin.path = path.to_string();
AppStateData {
settings: Arc::new(settings),
..kellnr_http::test_support::test_state()
}
}
fn location(response: impl IntoResponse) -> String {
response.into_response().headers()[header::LOCATION]
.to_str()
.unwrap()
.to_string()
}
fn parse_query(query: &str) -> Option<CallbackQuery> {
let uri: axum::http::Uri = format!("/callback?{query}").parse().unwrap();
Query::<CallbackQuery>::try_from_uri(&uri)
.ok()
.map(|Query(query)| query)
}
#[test]
fn callback_query_with_code_and_state_is_success() {
assert_eq!(
parse_query("code=c&state=s&unknown=x"),
Some(CallbackQuery::Success {
code: "c".to_string(),
state: "s".to_string()
})
);
}
#[test]
fn callback_query_with_error_is_provider_error() {
assert_eq!(
parse_query("error=access_denied&error_description=User%20cancelled&state=s"),
Some(CallbackQuery::ProviderError {
error: "access_denied".to_string(),
error_description: Some("User cancelled".to_string()),
state: Some("s".to_string()),
})
);
assert_eq!(
parse_query("error=server_error&code=c"),
Some(CallbackQuery::ProviderError {
error: "server_error".to_string(),
error_description: None,
state: None,
})
);
}
#[test]
fn callback_query_without_code_or_state_is_rejected() {
assert_eq!(parse_query(""), None);
assert_eq!(parse_query("state=s"), None);
assert_eq!(parse_query("code=c"), None);
}
#[tokio::test]
async fn provider_error_redirects_to_login_page() {
let jar = PrivateCookieJar::from_headers(&HeaderMap::new(), Key::from(TEST_KEY));
let result = callback(
jar,
Query(parse_query("error=access_denied&state=abc").unwrap()),
State(state_with_origin_path("/kellnr")),
Extension(None),
)
.await;
let Ok((_, redirect)) = result else {
panic!("expected a redirect");
};
assert_eq!(location(redirect), "/kellnr/login?error=access_denied");
}
#[tokio::test]
async fn error_callback_redirects_below_origin_path() {
let settings = state_with_origin_path("/kellnr").settings;
let redirect = error_callback(
State(settings),
Query(ErrorQuery {
error: "access_denied".to_string(),
error_description: Some("User cancelled".to_string()),
}),
)
.await;
assert_eq!(location(redirect), "/kellnr/login?error=User%20cancelled");
}
}
#[cfg(test)]
mod availability_tests {
use axum::http::{HeaderMap, header};
use axum::response::IntoResponse;
use axum_extra::extract::cookie::Key;
use kellnr_http::test_support::{TEST_KEY, test_state};
use kellnr_settings::OAuth2 as OAuth2Settings;
use super::*;
fn empty_jar() -> PrivateCookieJar {
PrivateCookieJar::from_headers(&HeaderMap::new(), Key::from(TEST_KEY))
}
fn undiscovered_handler() -> Arc<OAuth2Handler> {
let settings = OAuth2Settings {
enabled: true,
issuer_url: Some("http://127.0.0.1:9".to_string()),
client_id: Some("kellnr".to_string()),
client_secret: Some("secret".to_string()),
..Default::default()
};
let handler = OAuth2Handler::new(&settings, "http://kellnr/api/v1/oauth2/callback")
.expect("configuration is valid");
Arc::new(handler)
}
async fn login_response(handler: Option<Arc<OAuth2Handler>>) -> axum::response::Response {
let state = test_state();
login(
empty_jar(),
State(state.db),
State(state.settings),
Extension(handler),
)
.await
.into_response()
}
#[tokio::test]
async fn login_without_oauth2_is_not_found() {
let response = login_response(None).await;
assert_eq!(StatusCode::NOT_FOUND, response.status());
}
#[tokio::test]
async fn login_with_undiscovered_provider_is_unavailable() {
let response = login_response(Some(undiscovered_handler())).await;
assert_eq!(StatusCode::SERVICE_UNAVAILABLE, response.status());
assert_eq!(
PROVIDER_RETRY_AFTER.as_secs().to_string(),
response.headers()[header::RETRY_AFTER]
);
}
#[tokio::test]
async fn config_reports_undiscovered_provider_as_unavailable() {
let Json(config) = get_config(
State(test_state().settings),
Extension(Some(undiscovered_handler())),
)
.await;
assert!(!config.available);
}
#[tokio::test]
async fn config_reports_disabled_oauth2_as_unavailable() {
let Json(config) = get_config(State(test_state().settings), Extension(None)).await;
assert!(!config.available);
}
}