feat(admin): add admin settings panel for OIDC configuration
- Admin UI at /admin.html with settings management interface - REST API: GET/PUT /api/admin/settings/oidc, POST .../test, GET .../general - DB-backed settings in auth.admin_settings table (PostgreSQL) - OIDC auto-discovery from issuer URL (.well-known/openid-configuration) - Hot-reload: OIDC config changes apply without server restart - Role-based access: admin-only endpoints with 403 for regular users - Client secret stored securely, never exposed in GET responses - Env var override detection shown in admin UI - Clean architecture: repository trait, PG implementation, service, handler
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::domain::repositories::settings_repository::SettingsRepository;
|
||||
use crate::application::services::auth_application_service::AuthApplicationService;
|
||||
use crate::application::dtos::settings_dto::{
|
||||
OidcSettingsDto, SaveOidcSettingsDto, OidcTestResultDto, TestOidcConnectionDto,
|
||||
};
|
||||
use crate::infrastructure::services::oidc_service::OidcService;
|
||||
use crate::common::config::OidcConfig;
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
|
||||
/// Admin settings service — manages platform configuration in the database.
|
||||
///
|
||||
/// Configuration priority: **env vars > DB settings > defaults**.
|
||||
/// Supports hot-reloading OIDC configuration without server restart.
|
||||
pub struct AdminSettingsService {
|
||||
settings_repo: Arc<dyn SettingsRepository>,
|
||||
env_oidc_config: OidcConfig,
|
||||
auth_app_service: Arc<AuthApplicationService>,
|
||||
server_base_url: String,
|
||||
}
|
||||
|
||||
impl AdminSettingsService {
|
||||
pub fn new(
|
||||
settings_repo: Arc<dyn SettingsRepository>,
|
||||
env_oidc_config: OidcConfig,
|
||||
auth_app_service: Arc<AuthApplicationService>,
|
||||
server_base_url: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
settings_repo,
|
||||
env_oidc_config,
|
||||
auth_app_service,
|
||||
server_base_url,
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-generated OIDC callback URL
|
||||
fn callback_url(&self) -> String {
|
||||
let base = self.server_base_url.trim_end_matches('/');
|
||||
format!("{}/api/auth/oidc/callback", base)
|
||||
}
|
||||
|
||||
/// Detect which OIDC fields are overridden by environment variables
|
||||
fn get_env_overrides(&self) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let vars = [
|
||||
("OXICLOUD_OIDC_ENABLED", "enabled"),
|
||||
("OXICLOUD_OIDC_ISSUER_URL", "issuer_url"),
|
||||
("OXICLOUD_OIDC_CLIENT_ID", "client_id"),
|
||||
("OXICLOUD_OIDC_CLIENT_SECRET", "client_secret"),
|
||||
("OXICLOUD_OIDC_SCOPES", "scopes"),
|
||||
("OXICLOUD_OIDC_AUTO_PROVISION", "auto_provision"),
|
||||
("OXICLOUD_OIDC_ADMIN_GROUPS", "admin_groups"),
|
||||
("OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN", "disable_password_login"),
|
||||
("OXICLOUD_OIDC_PROVIDER_NAME", "provider_name"),
|
||||
];
|
||||
for (env_key, field_name) in &vars {
|
||||
if std::env::var(env_key).is_ok() {
|
||||
out.push(field_name.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Apply environment variable overrides on top of a config
|
||||
fn apply_env_overrides(&self, config: &mut OidcConfig) {
|
||||
let e = &self.env_oidc_config;
|
||||
if std::env::var("OXICLOUD_OIDC_ENABLED").is_ok() { config.enabled = e.enabled; }
|
||||
if std::env::var("OXICLOUD_OIDC_ISSUER_URL").is_ok() { config.issuer_url = e.issuer_url.clone(); }
|
||||
if std::env::var("OXICLOUD_OIDC_CLIENT_ID").is_ok() { config.client_id = e.client_id.clone(); }
|
||||
if std::env::var("OXICLOUD_OIDC_CLIENT_SECRET").is_ok() { config.client_secret = e.client_secret.clone(); }
|
||||
if std::env::var("OXICLOUD_OIDC_SCOPES").is_ok() { config.scopes = e.scopes.clone(); }
|
||||
if std::env::var("OXICLOUD_OIDC_REDIRECT_URI").is_ok() { config.redirect_uri = e.redirect_uri.clone(); }
|
||||
if std::env::var("OXICLOUD_OIDC_FRONTEND_URL").is_ok() { config.frontend_url = e.frontend_url.clone(); }
|
||||
if std::env::var("OXICLOUD_OIDC_AUTO_PROVISION").is_ok() { config.auto_provision = e.auto_provision; }
|
||||
if std::env::var("OXICLOUD_OIDC_ADMIN_GROUPS").is_ok() { config.admin_groups = e.admin_groups.clone(); }
|
||||
if std::env::var("OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN").is_ok() { config.disable_password_login = e.disable_password_login; }
|
||||
if std::env::var("OXICLOUD_OIDC_PROVIDER_NAME").is_ok() { config.provider_name = e.provider_name.clone(); }
|
||||
}
|
||||
|
||||
/// Load the effective OIDC config: DB settings + env var overrides + defaults.
|
||||
pub async fn load_effective_oidc_config(&self) -> Result<OidcConfig, DomainError> {
|
||||
let db = self.settings_repo.get_by_category("oidc").await?;
|
||||
let d = OidcConfig::default();
|
||||
|
||||
let mut config = OidcConfig {
|
||||
enabled: db.get("oidc.enabled").and_then(|v| v.parse().ok()).unwrap_or(d.enabled),
|
||||
issuer_url: db.get("oidc.issuer_url").cloned().unwrap_or(d.issuer_url),
|
||||
client_id: db.get("oidc.client_id").cloned().unwrap_or(d.client_id),
|
||||
client_secret: db.get("oidc.client_secret").cloned().unwrap_or(d.client_secret),
|
||||
redirect_uri: self.callback_url(),
|
||||
scopes: db.get("oidc.scopes").cloned().unwrap_or(d.scopes),
|
||||
frontend_url: self.server_base_url.clone(),
|
||||
auto_provision: db.get("oidc.auto_provision").and_then(|v| v.parse().ok()).unwrap_or(d.auto_provision),
|
||||
admin_groups: db.get("oidc.admin_groups").cloned().unwrap_or(d.admin_groups),
|
||||
disable_password_login: db.get("oidc.disable_password_login").and_then(|v| v.parse().ok()).unwrap_or(d.disable_password_login),
|
||||
provider_name: db.get("oidc.provider_name").cloned().unwrap_or(d.provider_name),
|
||||
};
|
||||
|
||||
// Env vars override DB
|
||||
self.apply_env_overrides(&mut config);
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Get OIDC settings for display in admin UI (secrets masked).
|
||||
pub async fn get_oidc_settings(&self) -> Result<OidcSettingsDto, DomainError> {
|
||||
let db = self.settings_repo.get_by_category("oidc").await?;
|
||||
let d = OidcConfig::default();
|
||||
|
||||
let has_secret = db.get("oidc.client_secret").map(|s| !s.is_empty()).unwrap_or(false)
|
||||
|| std::env::var("OXICLOUD_OIDC_CLIENT_SECRET").map(|s| !s.is_empty()).unwrap_or(false);
|
||||
|
||||
Ok(OidcSettingsDto {
|
||||
enabled: db.get("oidc.enabled").and_then(|v| v.parse().ok()).unwrap_or(d.enabled),
|
||||
issuer_url: db.get("oidc.issuer_url").cloned().unwrap_or_default(),
|
||||
client_id: db.get("oidc.client_id").cloned().unwrap_or_default(),
|
||||
client_secret_set: has_secret,
|
||||
scopes: db.get("oidc.scopes").cloned().unwrap_or(d.scopes),
|
||||
auto_provision: db.get("oidc.auto_provision").and_then(|v| v.parse().ok()).unwrap_or(d.auto_provision),
|
||||
admin_groups: db.get("oidc.admin_groups").cloned().unwrap_or_default(),
|
||||
disable_password_login: db.get("oidc.disable_password_login").and_then(|v| v.parse().ok()).unwrap_or(d.disable_password_login),
|
||||
provider_name: db.get("oidc.provider_name").cloned().unwrap_or(d.provider_name),
|
||||
callback_url: self.callback_url(),
|
||||
env_overrides: self.get_env_overrides(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Save OIDC settings to DB and hot-reload the OIDC service.
|
||||
pub async fn save_oidc_settings(
|
||||
&self,
|
||||
dto: SaveOidcSettingsDto,
|
||||
updated_by: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let cat = "oidc";
|
||||
let by = Some(updated_by);
|
||||
|
||||
self.settings_repo.set("oidc.enabled", &dto.enabled.to_string(), cat, false, by).await?;
|
||||
self.settings_repo.set("oidc.issuer_url", &dto.issuer_url, cat, false, by).await?;
|
||||
self.settings_repo.set("oidc.client_id", &dto.client_id, cat, false, by).await?;
|
||||
|
||||
if let Some(ref secret) = dto.client_secret {
|
||||
if !secret.is_empty() {
|
||||
self.settings_repo.set("oidc.client_secret", secret, cat, true, by).await?;
|
||||
}
|
||||
}
|
||||
if let Some(ref v) = dto.scopes {
|
||||
self.settings_repo.set("oidc.scopes", v, cat, false, by).await?;
|
||||
}
|
||||
if let Some(v) = dto.auto_provision {
|
||||
self.settings_repo.set("oidc.auto_provision", &v.to_string(), cat, false, by).await?;
|
||||
}
|
||||
if let Some(ref v) = dto.admin_groups {
|
||||
self.settings_repo.set("oidc.admin_groups", v, cat, false, by).await?;
|
||||
}
|
||||
if let Some(v) = dto.disable_password_login {
|
||||
self.settings_repo.set("oidc.disable_password_login", &v.to_string(), cat, false, by).await?;
|
||||
}
|
||||
if let Some(ref v) = dto.provider_name {
|
||||
self.settings_repo.set("oidc.provider_name", v, cat, false, by).await?;
|
||||
}
|
||||
|
||||
// Hot-reload OIDC service
|
||||
let eff = self.load_effective_oidc_config().await?;
|
||||
if eff.enabled && !eff.issuer_url.is_empty()
|
||||
&& !eff.client_id.is_empty() && !eff.client_secret.is_empty()
|
||||
{
|
||||
let svc = Arc::new(OidcService::new(eff.clone()));
|
||||
self.auth_app_service.reload_oidc(svc, eff);
|
||||
tracing::info!("OIDC service hot-reloaded with new configuration");
|
||||
} else if !eff.enabled {
|
||||
self.auth_app_service.disable_oidc();
|
||||
tracing::info!("OIDC service disabled via admin panel");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test OIDC connection by fetching the discovery document.
|
||||
pub async fn test_oidc_connection(
|
||||
&self,
|
||||
dto: TestOidcConnectionDto,
|
||||
) -> Result<OidcTestResultDto, DomainError> {
|
||||
let issuer = dto.issuer_url.trim_end_matches('/');
|
||||
let discovery_url = format!("{}/.well-known/openid-configuration", issuer);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| DomainError::new(
|
||||
ErrorKind::InternalError, "OIDC", format!("HTTP client error: {}", e),
|
||||
))?;
|
||||
|
||||
let resp = match client.get(&discovery_url).send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return Ok(OidcTestResultDto {
|
||||
success: false,
|
||||
message: format!("Cannot reach the OIDC provider: {}. Check your Issuer URL.", e),
|
||||
issuer: None,
|
||||
authorization_endpoint: None,
|
||||
token_endpoint: None,
|
||||
userinfo_endpoint: None,
|
||||
provider_name_suggestion: None,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Ok(OidcTestResultDto {
|
||||
success: false,
|
||||
message: format!(
|
||||
"OIDC discovery returned HTTP {} — the Issuer URL may be incorrect.",
|
||||
resp.status()
|
||||
),
|
||||
issuer: None,
|
||||
authorization_endpoint: None,
|
||||
token_endpoint: None,
|
||||
userinfo_endpoint: None,
|
||||
provider_name_suggestion: None,
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Discovery {
|
||||
issuer: Option<String>,
|
||||
authorization_endpoint: Option<String>,
|
||||
token_endpoint: Option<String>,
|
||||
userinfo_endpoint: Option<String>,
|
||||
}
|
||||
|
||||
let disc: Discovery = match resp.json().await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
return Ok(OidcTestResultDto {
|
||||
success: false,
|
||||
message: format!("Invalid discovery document: {}", e),
|
||||
issuer: None,
|
||||
authorization_endpoint: None,
|
||||
token_endpoint: None,
|
||||
userinfo_endpoint: None,
|
||||
provider_name_suggestion: None,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Suggest provider name from hostname
|
||||
let suggestion = issuer
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://")
|
||||
.split('/')
|
||||
.next()
|
||||
.and_then(|host| {
|
||||
let parts: Vec<&str> = host.split('.').collect();
|
||||
let name = if parts.len() >= 2 { parts[0] } else { host };
|
||||
let mut c = name.chars();
|
||||
c.next().map(|f| f.to_uppercase().to_string() + c.as_str())
|
||||
});
|
||||
|
||||
Ok(OidcTestResultDto {
|
||||
success: true,
|
||||
message: "OIDC provider is reachable and returned a valid discovery document.".into(),
|
||||
issuer: disc.issuer,
|
||||
authorization_endpoint: disc.authorization_endpoint,
|
||||
token_endpoint: disc.token_endpoint,
|
||||
userinfo_endpoint: disc.userinfo_endpoint,
|
||||
provider_name_suggestion: suggestion,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
use crate::domain::entities::user::{User, UserRole};
|
||||
use crate::domain::entities::session::Session;
|
||||
use crate::application::ports::auth_ports::{UserStoragePort, SessionStoragePort, PasswordHasherPort, TokenServicePort, OidcServicePort, OidcIdClaims};
|
||||
@@ -8,14 +9,19 @@ use crate::application::ports::inbound::FolderUseCase;
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::common::config::OidcConfig;
|
||||
|
||||
/// Interior state for OIDC — protected by RwLock for hot-reload.
|
||||
struct OidcState {
|
||||
service: Option<Arc<dyn OidcServicePort>>,
|
||||
config: Option<OidcConfig>,
|
||||
}
|
||||
|
||||
pub struct AuthApplicationService {
|
||||
user_storage: Arc<dyn UserStoragePort>,
|
||||
session_storage: Arc<dyn SessionStoragePort>,
|
||||
password_hasher: Arc<dyn PasswordHasherPort>,
|
||||
token_service: Arc<dyn TokenServicePort>,
|
||||
folder_service: Option<Arc<dyn FolderUseCase>>,
|
||||
oidc_service: Option<Arc<dyn OidcServicePort>>,
|
||||
oidc_config: Option<OidcConfig>,
|
||||
oidc: RwLock<OidcState>,
|
||||
}
|
||||
|
||||
impl AuthApplicationService {
|
||||
@@ -31,8 +37,7 @@ impl AuthApplicationService {
|
||||
password_hasher,
|
||||
token_service,
|
||||
folder_service: None,
|
||||
oidc_service: None,
|
||||
oidc_config: None,
|
||||
oidc: RwLock::new(OidcState { service: None, config: None }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,30 +48,51 @@ impl AuthApplicationService {
|
||||
}
|
||||
|
||||
/// Configura el servicio OIDC
|
||||
pub fn with_oidc(mut self, oidc_service: Arc<dyn OidcServicePort>, oidc_config: OidcConfig) -> Self {
|
||||
self.oidc_service = Some(oidc_service);
|
||||
self.oidc_config = Some(oidc_config);
|
||||
pub fn with_oidc(self, oidc_service: Arc<dyn OidcServicePort>, oidc_config: OidcConfig) -> Self {
|
||||
{
|
||||
let mut state = self.oidc.write().unwrap();
|
||||
state.service = Some(oidc_service);
|
||||
state.config = Some(oidc_config);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Hot-reload OIDC configuration at runtime (called from admin settings service)
|
||||
pub fn reload_oidc(&self, oidc_service: Arc<dyn OidcServicePort>, oidc_config: OidcConfig) {
|
||||
let mut state = self.oidc.write().unwrap();
|
||||
state.service = Some(oidc_service);
|
||||
state.config = Some(oidc_config);
|
||||
}
|
||||
|
||||
/// Disable OIDC at runtime (called from admin settings service)
|
||||
pub fn disable_oidc(&self) {
|
||||
let mut state = self.oidc.write().unwrap();
|
||||
state.service = None;
|
||||
state.config = None;
|
||||
}
|
||||
|
||||
/// Returns whether OIDC is configured and enabled
|
||||
pub fn oidc_enabled(&self) -> bool {
|
||||
self.oidc_service.is_some() && self.oidc_config.as_ref().map_or(false, |c| c.enabled)
|
||||
let state = self.oidc.read().unwrap();
|
||||
state.service.is_some() && state.config.as_ref().map_or(false, |c| c.enabled)
|
||||
}
|
||||
|
||||
/// Returns whether password login is disabled (OIDC-only mode)
|
||||
pub fn password_login_disabled(&self) -> bool {
|
||||
self.oidc_config.as_ref().map_or(false, |c| c.disable_password_login)
|
||||
let state = self.oidc.read().unwrap();
|
||||
state.config.as_ref().map_or(false, |c| c.disable_password_login)
|
||||
}
|
||||
|
||||
/// Returns the OIDC config if available
|
||||
pub fn oidc_config(&self) -> Option<&OidcConfig> {
|
||||
self.oidc_config.as_ref()
|
||||
/// Returns a clone of the OIDC config if available
|
||||
pub fn oidc_config(&self) -> Option<OidcConfig> {
|
||||
let state = self.oidc.read().unwrap();
|
||||
state.config.clone()
|
||||
}
|
||||
|
||||
/// Returns the OIDC service if available
|
||||
pub fn oidc_service(&self) -> Option<&Arc<dyn OidcServicePort>> {
|
||||
self.oidc_service.as_ref()
|
||||
/// Returns an Arc clone of the OIDC service if available
|
||||
pub fn oidc_service(&self) -> Option<Arc<dyn OidcServicePort>> {
|
||||
let state = self.oidc.read().unwrap();
|
||||
state.service.clone()
|
||||
}
|
||||
|
||||
pub async fn register(&self, dto: RegisterDto) -> Result<UserDto, DomainError> {
|
||||
@@ -559,7 +585,7 @@ impl AuthApplicationService {
|
||||
/// Generate the OIDC authorization URL for redirecting the user to the IdP.
|
||||
/// The `state` parameter is a signed JWT to prevent CSRF.
|
||||
pub fn oidc_authorize_url(&self, state: &str) -> Result<String, DomainError> {
|
||||
let oidc = self.oidc_service.as_ref().ok_or_else(|| DomainError::new(
|
||||
let oidc = self.oidc_service().ok_or_else(|| DomainError::new(
|
||||
ErrorKind::InternalError, "OIDC", "OIDC service not configured",
|
||||
))?;
|
||||
oidc.get_authorize_url(state)
|
||||
@@ -578,12 +604,17 @@ impl AuthApplicationService {
|
||||
/// Handle the OIDC callback: exchange code, validate ID token,
|
||||
/// find or create user (JIT provisioning), and issue internal tokens.
|
||||
pub async fn oidc_callback(&self, code: &str) -> Result<AuthResponseDto, DomainError> {
|
||||
let oidc = self.oidc_service.as_ref().ok_or_else(|| DomainError::new(
|
||||
ErrorKind::InternalError, "OIDC", "OIDC service not configured",
|
||||
))?;
|
||||
let oidc_config = self.oidc_config.as_ref().ok_or_else(|| DomainError::new(
|
||||
ErrorKind::InternalError, "OIDC", "OIDC config not available",
|
||||
))?;
|
||||
// Clone the Arc and config out of the RwLock so we don't hold the lock across await points
|
||||
let (oidc, oidc_config) = {
|
||||
let state = self.oidc.read().unwrap();
|
||||
let svc = state.service.clone().ok_or_else(|| DomainError::new(
|
||||
ErrorKind::InternalError, "OIDC", "OIDC service not configured",
|
||||
))?;
|
||||
let cfg = state.config.clone().ok_or_else(|| DomainError::new(
|
||||
ErrorKind::InternalError, "OIDC", "OIDC config not available",
|
||||
))?;
|
||||
(svc, cfg)
|
||||
};
|
||||
|
||||
// 1. Exchange authorization code for tokens
|
||||
let token_set = oidc.exchange_code(code).await?;
|
||||
@@ -649,7 +680,7 @@ impl AuthApplicationService {
|
||||
}
|
||||
|
||||
// Determine role from OIDC groups
|
||||
let role = self.map_oidc_role(&claims.groups, oidc_config);
|
||||
let role = self.map_oidc_role(&claims.groups, &oidc_config);
|
||||
|
||||
let quota = if role == UserRole::Admin {
|
||||
107374182400 // 100GB
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod admin_settings_service;
|
||||
pub mod auth_application_service;
|
||||
pub mod batch_operations;
|
||||
pub mod calendar_service;
|
||||
|
||||
Reference in New Issue
Block a user