Private
Public Access
1
0

feat: OIDC SSO provider support (Keycloak, Azure AD, custom)
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 4s
CI Pipeline / Clippy Lints (push) Successful in 52s
CI Pipeline / Rust Unit Tests (push) Successful in 1m11s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped

- Refactored azure_sso.rs to sso.rs with generic OIDC provider support
- Added OIDC discovery URL lookup with 1hr TTL caching
- Added PKCE for all providers, client_secret optional for public clients
- Added /api/v1/auth/sso/login and /api/v1/auth/sso/callback routes
- Added /api/v1/auth/azure/* backward-compatible routes
- Added POST /settings/sso/discover and POST /settings/sso/test endpoints
- Frontend: Provider dropdown (Keycloak/Azure AD/Custom OIDC)
- Frontend: Auto-fill discovery URL for Keycloak
- Frontend: Discover Endpoints and Test Connection buttons
- Frontend: Dynamic SSO button based on provider display name
- Made migration 014 idempotent with DO blocks and IF NOT EXISTS
- Fixed debian/install to use /usr/local/bin/ for binaries
- Fixed frontend file path in .deb package
- Reset admin password on dev server
- Fixed database permissions for oidc_config table
This commit is contained in:
2026-05-13 13:32:24 +00:00
parent e3d8569b05
commit 69d2e88bbd
14 changed files with 883 additions and 496 deletions

14
Cargo.lock generated
View File

@ -2381,7 +2381,7 @@ dependencies = [
[[package]] [[package]]
name = "pm-agent-client" name = "pm-agent-client"
version = "0.1.3" version = "0.1.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@ -2398,7 +2398,7 @@ dependencies = [
[[package]] [[package]]
name = "pm-auth" name = "pm-auth"
version = "0.1.3" version = "0.1.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
@ -2425,7 +2425,7 @@ dependencies = [
[[package]] [[package]]
name = "pm-ca" name = "pm-ca"
version = "0.1.3" version = "0.1.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@ -2448,7 +2448,7 @@ dependencies = [
[[package]] [[package]]
name = "pm-core" name = "pm-core"
version = "0.1.3" version = "0.1.5"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"anyhow", "anyhow",
@ -2472,7 +2472,7 @@ dependencies = [
[[package]] [[package]]
name = "pm-reports" name = "pm-reports"
version = "0.1.3" version = "0.1.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@ -2492,7 +2492,7 @@ dependencies = [
[[package]] [[package]]
name = "pm-web" name = "pm-web"
version = "0.1.3" version = "0.1.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@ -2529,7 +2529,7 @@ dependencies = [
[[package]] [[package]]
name = "pm-worker" name = "pm-worker"
version = "0.1.3" version = "0.1.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",

View File

@ -11,7 +11,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.1.4" version = "0.1.5"
edition = "2021" edition = "2021"
authors = ["Echo <echo@moon-dragon.us>"] authors = ["Echo <echo@moon-dragon.us>"]
license = "MIT" license = "MIT"

View File

@ -10,7 +10,7 @@ use pm_auth::{
rbac::{require_auth, AuthConfig}, rbac::{require_auth, AuthConfig},
}; };
use pm_core::{config::AppConfig, db, logging, request_id::request_id_middleware}; use pm_core::{config::AppConfig, db, logging, request_id::request_id_middleware};
use routes::azure_sso::{JwksCache, SsoSession}; use routes::sso::{OidcCache, SsoSession};
use routes::ws::WsTicket; use routes::ws::WsTicket;
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::{net::SocketAddr, sync::Arc, time::Duration}; use std::{net::SocketAddr, sync::Arc, time::Duration};
@ -31,8 +31,8 @@ pub struct AppState {
pub ws_tickets: Arc<DashMap<String, WsTicket>>, pub ws_tickets: Arc<DashMap<String, WsTicket>>,
/// In-memory store for SSO PKCE sessions (state → code_verifier). /// In-memory store for SSO PKCE sessions (state → code_verifier).
pub sso_sessions: Arc<DashMap<String, SsoSession>>, pub sso_sessions: Arc<DashMap<String, SsoSession>>,
/// Cached Azure AD JWKS for id_token signature verification. /// Cached OIDC discovery document and JWKS for SSO id_token verification.
pub jwks_cache: Arc<Mutex<JwksCache>>, pub oidc_cache: Arc<Mutex<OidcCache>>,
/// Internal certificate authority for mTLS client cert issuance. /// Internal certificate authority for mTLS client cert issuance.
pub ca: Arc<pm_ca::CertAuthority>, pub ca: Arc<pm_ca::CertAuthority>,
} }
@ -90,7 +90,7 @@ async fn main() -> anyhow::Result<()> {
let ws_tickets: Arc<DashMap<String, WsTicket>> = Arc::new(DashMap::new()); let ws_tickets: Arc<DashMap<String, WsTicket>> = Arc::new(DashMap::new());
let sso_sessions: Arc<DashMap<String, SsoSession>> = Arc::new(DashMap::new()); let sso_sessions: Arc<DashMap<String, SsoSession>> = Arc::new(DashMap::new());
let jwks_cache: Arc<Mutex<JwksCache>> = Arc::new(Mutex::new(JwksCache::default())); let oidc_cache: Arc<Mutex<OidcCache>> = Arc::new(Mutex::new(OidcCache::default()));
// Background task: purge expired WS tickets every 30 seconds. // Background task: purge expired WS tickets every 30 seconds.
{ {
@ -137,7 +137,7 @@ async fn main() -> anyhow::Result<()> {
ws_tickets, ws_tickets,
sso_sessions, sso_sessions,
ca: Arc::new(ca), ca: Arc::new(ca),
jwks_cache, oidc_cache,
}; };
let app = build_router(state); let app = build_router(state);
@ -234,7 +234,7 @@ pub fn build_router(state: AppState) -> Router {
// Public auth routes (no JWT needed) // Public auth routes (no JWT needed)
.nest("/api/v1/auth", routes::auth::public_router()) .nest("/api/v1/auth", routes::auth::public_router())
// Public Azure SSO routes (no JWT needed) // Public Azure SSO routes (no JWT needed)
.nest("/api/v1/auth/azure", routes::azure_sso::public_router()) .nest("/api/v1/auth/azure", routes::sso::azure_compat_router())
// Protected API routes (JWT required) // Protected API routes (JWT required)
.nest("/api/v1", protected_api) .nest("/api/v1", protected_api)
// WebSocket browser endpoint — ticket-authenticated, outside JWT middleware // WebSocket browser endpoint — ticket-authenticated, outside JWT middleware

View File

@ -1,6 +1,5 @@
//! Route modules for the pm-web API. //! Route modules for the pm-web API.
pub mod auth; pub mod auth;
pub mod azure_sso;
pub mod ca; pub mod ca;
pub mod discovery; pub mod discovery;
pub mod groups; pub mod groups;
@ -9,6 +8,7 @@ pub mod hosts;
pub mod jobs; pub mod jobs;
pub mod maintenance_windows; pub mod maintenance_windows;
pub mod settings; pub mod settings;
pub mod sso;
pub mod status; pub mod status;
pub mod users; pub mod users;
pub mod ws; pub mod ws;

View File

@ -2,7 +2,9 @@
//! //!
//! GET /api/v1/settings — get all settings (admin only) //! GET /api/v1/settings — get all settings (admin only)
//! PUT /api/v1/settings — update settings (admin only) //! PUT /api/v1/settings — update settings (admin only)
//! POST /api/v1/settings/azure-sso/test — test Azure SSO connectivity (admin only) //! POST /api/v1/settings/sso/discover — discover OIDC endpoints (admin only)
//! POST /api/v1/settings/sso/test — test OIDC provider connectivity (admin only)
//! POST /api/v1/settings/azure-sso/test — backward-compat alias for SSO test (admin only)
//! POST /api/v1/settings/smtp/test — send test email (admin only) //! POST /api/v1/settings/smtp/test — send test email (admin only)
//! GET /api/v1/settings/ip-whitelist — get IP whitelist (admin only) //! GET /api/v1/settings/ip-whitelist — get IP whitelist (admin only)
//! PUT /api/v1/settings/ip-whitelist — update IP whitelist (admin only) //! PUT /api/v1/settings/ip-whitelist — update IP whitelist (admin only)
@ -34,7 +36,7 @@ use crate::AppState;
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct SettingsResponse { pub struct SettingsResponse {
pub azure_sso: AzureSsoConfig, pub oidc: OidcConfigResponse,
pub smtp: SmtpConfig, pub smtp: SmtpConfig,
pub polling: PollingConfig, pub polling: PollingConfig,
pub ip_whitelist: Vec<String>, pub ip_whitelist: Vec<String>,
@ -44,10 +46,13 @@ pub struct SettingsResponse {
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct AzureSsoConfig { pub struct OidcConfigResponse {
pub enabled: bool, pub enabled: bool,
pub tenant_id: String, pub provider_type: String, // "keycloak", "azure", "custom"
pub display_name: String,
pub discovery_url: String,
pub client_id: String, pub client_id: String,
pub client_secret: String, // Always masked in responses
pub redirect_uri: String, pub redirect_uri: String,
pub scopes: String, pub scopes: String,
} }
@ -70,7 +75,7 @@ pub struct PollingConfig {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct UpdateSettingsRequest { pub struct UpdateSettingsRequest {
pub azure_sso: Option<AzureSsoConfigUpdate>, pub oidc: Option<OidcConfigUpdate>,
pub smtp: Option<SmtpConfigUpdate>, pub smtp: Option<SmtpConfigUpdate>,
pub polling: Option<PollingConfigUpdate>, pub polling: Option<PollingConfigUpdate>,
pub ip_whitelist: Option<Vec<String>>, pub ip_whitelist: Option<Vec<String>>,
@ -93,15 +98,31 @@ pub struct NotificationConfigUpdate {
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct AzureSsoConfigUpdate { pub struct OidcConfigUpdate {
pub enabled: Option<bool>, pub enabled: Option<bool>,
pub tenant_id: Option<String>, pub provider_type: Option<String>,
pub display_name: Option<String>,
pub discovery_url: Option<String>,
pub client_id: Option<String>, pub client_id: Option<String>,
pub client_secret: Option<String>, pub client_secret: Option<String>,
pub redirect_uri: Option<String>, pub redirect_uri: Option<String>,
pub scopes: Option<String>, pub scopes: Option<String>,
} }
#[derive(Debug, Deserialize)]
pub struct OidcDiscoveryRequest {
pub discovery_url: String,
}
#[derive(Debug, Serialize)]
pub struct OidcDiscoveryResult {
pub issuer: String,
pub authorization_endpoint: String,
pub token_endpoint: String,
pub jwks_uri: String,
pub userinfo_endpoint: Option<String>,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct SmtpConfigUpdate { pub struct SmtpConfigUpdate {
pub enabled: Option<bool>, pub enabled: Option<bool>,
@ -131,7 +152,9 @@ pub struct IpWhitelistUpdate {
pub fn router() -> Router<AppState> { pub fn router() -> Router<AppState> {
Router::new() Router::new()
.route("/", get(get_settings).put(update_settings)) .route("/", get(get_settings).put(update_settings))
.route("/azure-sso/test", post(test_azure_sso)) .route("/sso/discover", post(discover_oidc))
.route("/sso/test", post(test_oidc))
.route("/azure-sso/test", post(test_azure_sso_compat))
.route("/smtp/test", post(test_smtp)) .route("/smtp/test", post(test_smtp))
.route( .route(
"/ip-whitelist", "/ip-whitelist",
@ -175,7 +198,7 @@ async fn load_system_config(
fn build_settings_response( fn build_settings_response(
cfg: &HashMap<String, String>, cfg: &HashMap<String, String>,
azure: AzureSsoConfig, oidc: OidcConfigResponse,
) -> SettingsResponse { ) -> SettingsResponse {
let get = |key: &str| -> String { cfg.get(key).cloned().unwrap_or_default() }; let get = |key: &str| -> String { cfg.get(key).cloned().unwrap_or_default() };
@ -183,7 +206,7 @@ fn build_settings_response(
serde_json::from_str(&get("notification_email_recipients")).unwrap_or_default(); serde_json::from_str(&get("notification_email_recipients")).unwrap_or_default();
SettingsResponse { SettingsResponse {
azure_sso: azure, oidc,
smtp: SmtpConfig { smtp: SmtpConfig {
enabled: get("smtp_enabled") == "true", enabled: get("smtp_enabled") == "true",
host: get("smtp_host"), host: get("smtp_host"),
@ -227,16 +250,16 @@ async fn update_config_key(
Ok(()) Ok(())
} }
async fn fetch_azure_sso_config( async fn fetch_oidc_config(
pool: &sqlx::PgPool, pool: &sqlx::PgPool,
) -> Result<AzureSsoConfig, (StatusCode, Json<Value>)> { ) -> Result<OidcConfigResponse, (StatusCode, Json<Value>)> {
let row: Option<(bool, String, String, String, String)> = sqlx::query_as( let row: Option<(bool, String, String, String, String, String, String, String)> = sqlx::query_as(
"SELECT enabled, tenant_id, client_id, redirect_uri, scopes FROM azure_sso_config WHERE id = 1", "SELECT enabled, provider_type, display_name, discovery_url, client_id, client_secret, redirect_uri, scopes FROM oidc_config WHERE id = 1",
) )
.fetch_optional(pool) .fetch_optional(pool)
.await .await
.map_err(|e| { .map_err(|e| {
tracing::error!(error = %e, "Failed to load azure_sso_config"); tracing::error!(error = %e, "Failed to load oidc_config");
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })), Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
@ -244,19 +267,38 @@ async fn fetch_azure_sso_config(
})?; })?;
Ok(match row { Ok(match row {
Some((enabled, tenant_id, client_id, redirect_uri, scopes)) => AzureSsoConfig { Some((
enabled, enabled,
tenant_id, provider_type,
display_name,
discovery_url,
client_id, client_id,
client_secret,
redirect_uri,
scopes,
)) => OidcConfigResponse {
enabled,
provider_type,
display_name,
discovery_url,
client_id,
client_secret: if client_secret.is_empty() {
String::new()
} else {
MASKED.to_string()
},
redirect_uri, redirect_uri,
scopes, scopes,
}, },
None => AzureSsoConfig { None => OidcConfigResponse {
enabled: false, enabled: false,
tenant_id: String::new(), provider_type: "azure".to_string(),
display_name: "Azure AD".to_string(),
discovery_url: String::new(),
client_id: String::new(), client_id: String::new(),
client_secret: String::new(),
redirect_uri: String::new(), redirect_uri: String::new(),
scopes: "openid email profile".to_string(), scopes: "openid profile email".to_string(),
}, },
}) })
} }
@ -277,8 +319,8 @@ async fn get_settings(
"sso_callback_url".to_string(), "sso_callback_url".to_string(),
state.config.security.sso_callback_url.clone(), state.config.security.sso_callback_url.clone(),
); );
let azure = fetch_azure_sso_config(&state.db).await?; let oidc = fetch_oidc_config(&state.db).await?;
Ok(Json(build_settings_response(&cfg, azure))) Ok(Json(build_settings_response(&cfg, oidc)))
} }
// ============================================================ // ============================================================
@ -292,56 +334,66 @@ async fn update_settings(
) -> Result<Json<SettingsResponse>, (StatusCode, Json<Value>)> { ) -> Result<Json<SettingsResponse>, (StatusCode, Json<Value>)> {
admin_only(&auth)?; admin_only(&auth)?;
// Update Azure SSO config // Update OIDC config
// Use static queries with proper typed bindings to avoid boolean→string mismatch if let Some(oidc) = req.oidc {
if let Some(azure) = req.azure_sso { let update_secret = oidc
let update_secret = azure.client_secret.as_ref().is_some_and(|s| s != MASKED); .client_secret
.as_ref()
.is_some_and(|s| s != MASKED && !s.is_empty());
let result = if update_secret { let result = if update_secret {
sqlx::query( sqlx::query(
"UPDATE azure_sso_config SET \ "UPDATE oidc_config SET \
enabled = COALESCE($1, enabled), \ enabled = COALESCE($1, enabled), \
tenant_id = COALESCE($2, tenant_id), \ provider_type = COALESCE($2, provider_type), \
client_id = COALESCE($3, client_id), \ display_name = COALESCE($3, display_name), \
client_secret = $4, \ discovery_url = COALESCE($4, discovery_url), \
redirect_uri = COALESCE($5, redirect_uri), \ client_id = COALESCE($5, client_id), \
scopes = COALESCE($6, scopes), \ client_secret = $6, \
redirect_uri = COALESCE($7, redirect_uri), \
scopes = COALESCE($8, scopes), \
updated_at = NOW() \ updated_at = NOW() \
WHERE id = 1", WHERE id = 1",
) )
.bind(azure.enabled) .bind(oidc.enabled)
.bind(&azure.tenant_id) .bind(&oidc.provider_type)
.bind(&azure.client_id) .bind(&oidc.display_name)
.bind(azure.client_secret.as_deref().unwrap_or("")) .bind(&oidc.discovery_url)
.bind(&azure.redirect_uri) .bind(&oidc.client_id)
.bind(&azure.scopes) .bind(oidc.client_secret.as_deref().unwrap_or(""))
.bind(&oidc.redirect_uri)
.bind(&oidc.scopes)
.execute(&state.db) .execute(&state.db)
.await .await
} else { } else {
sqlx::query( sqlx::query(
"UPDATE azure_sso_config SET \ "UPDATE oidc_config SET \
enabled = COALESCE($1, enabled), \ enabled = COALESCE($1, enabled), \
tenant_id = COALESCE($2, tenant_id), \ provider_type = COALESCE($2, provider_type), \
client_id = COALESCE($3, client_id), \ display_name = COALESCE($3, display_name), \
redirect_uri = COALESCE($4, redirect_uri), \ discovery_url = COALESCE($4, discovery_url), \
scopes = COALESCE($5, scopes), \ client_id = COALESCE($5, client_id), \
redirect_uri = COALESCE($6, redirect_uri), \
scopes = COALESCE($7, scopes), \
updated_at = NOW() \ updated_at = NOW() \
WHERE id = 1", WHERE id = 1",
) )
.bind(azure.enabled) .bind(oidc.enabled)
.bind(&azure.tenant_id) .bind(&oidc.provider_type)
.bind(&azure.client_id) .bind(&oidc.display_name)
.bind(&azure.redirect_uri) .bind(&oidc.discovery_url)
.bind(&azure.scopes) .bind(&oidc.client_id)
.bind(&oidc.redirect_uri)
.bind(&oidc.scopes)
.execute(&state.db) .execute(&state.db)
.await .await
}; };
result.map_err(|e| { result.map_err(|e| {
tracing::error!(error = %e, "Failed to update azure_sso_config"); tracing::error!(error = %e, "Failed to update oidc_config");
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": format!("Failed to update Azure SSO config: {}", e) } })), Json(json!({ "error": { "code": "internal_error", "message": format!("Failed to update OIDC config: {}", e) } })),
) )
})?; })?;
@ -350,9 +402,9 @@ async fn update_settings(
AuditAction::ConfigChanged, AuditAction::ConfigChanged,
Some(auth.user_id), Some(auth.user_id),
Some(&auth.username), Some(&auth.username),
Some("azure_sso"), Some("oidc"),
Some("1"), Some("1"),
json!({ "section": "azure_sso" }), json!({ "section": "oidc" }),
None, None,
None, None,
) )
@ -497,55 +549,30 @@ async fn update_settings(
"sso_callback_url".to_string(), "sso_callback_url".to_string(),
state.config.security.sso_callback_url.clone(), state.config.security.sso_callback_url.clone(),
); );
let azure = fetch_azure_sso_config(&state.db).await?; let oidc = fetch_oidc_config(&state.db).await?;
Ok(Json(build_settings_response(&cfg, azure))) Ok(Json(build_settings_response(&cfg, oidc)))
} }
// ============================================================ // ============================================================
// POST /api/v1/settings/azure-sso/test // POST /api/v1/settings/sso/discover
// ============================================================ // ============================================================
async fn test_azure_sso( async fn discover_oidc(
State(state): State<AppState>, State(state): State<AppState>,
auth: AuthUser, auth: AuthUser,
Json(req): Json<OidcDiscoveryRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> { ) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
admin_only(&auth)?; admin_only(&auth)?;
let row: Option<(String, String)> = sqlx::query_as( if req.discovery_url.is_empty() {
"SELECT tenant_id, client_id FROM azure_sso_config WHERE id = 1", return Err((
) StatusCode::BAD_REQUEST,
.fetch_optional(&state.db) Json(
.await json!({ "error": { "code": "bad_request", "message": "discovery_url is required" } }),
.map_err(|e| { ),
tracing::error!(error = %e, "Failed to load azure_sso_config"); ));
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
)
})?;
let (tenant_id, _client_id) = match row {
Some(r) => r,
None => {
return Ok(Json(json!({
"success": false,
"message": "Azure SSO is not configured"
})));
},
};
if tenant_id.is_empty() {
return Ok(Json(json!({
"success": false,
"message": "Azure tenant ID is not set"
})));
} }
let url = format!(
"https://login.microsoftonline.com/{}/v2.0/.well-known/openid-configuration",
tenant_id
);
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10)) .timeout(std::time::Duration::from_secs(10))
.build() .build()
@ -557,35 +584,129 @@ async fn test_azure_sso(
) )
})?; })?;
match client.get(&url).send().await { match client.get(&req.discovery_url).send().await {
Ok(resp) if resp.status().is_success() => {
let body: Value = resp.json().await.unwrap_or(json!({}));
Ok(Json(json!({
"success": true,
"issuer": body.get("issuer").and_then(|v| v.as_str()).unwrap_or(""),
"authorization_endpoint": body.get("authorization_endpoint").and_then(|v| v.as_str()).unwrap_or(""),
"token_endpoint": body.get("token_endpoint").and_then(|v| v.as_str()).unwrap_or(""),
"jwks_uri": body.get("jwks_uri").and_then(|v| v.as_str()).unwrap_or(""),
"userinfo_endpoint": body.get("userinfo_endpoint").and_then(|v| v.as_str()),
})))
},
Ok(resp) => Err((
StatusCode::BAD_GATEWAY,
Json(
json!({ "error": { "code": "discovery_failed", "message": format!("Discovery endpoint returned HTTP {}", resp.status()) } }),
),
)),
Err(e) => Err((
StatusCode::BAD_GATEWAY,
Json(
json!({ "error": { "code": "discovery_failed", "message": format!("Failed to reach discovery endpoint: {}", e) } }),
),
)),
}
}
// ============================================================
// POST /api/v1/settings/sso/test
// ============================================================
async fn test_oidc(
State(state): State<AppState>,
auth: AuthUser,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
admin_only(&auth)?;
let row: Option<(bool, String, String)> = sqlx::query_as(
"SELECT enabled, provider_type, discovery_url FROM oidc_config WHERE id = 1",
)
.fetch_optional(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to load oidc_config");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
)
})?;
let (enabled, provider_type, discovery_url) = match row {
Some(r) => r,
None => {
return Ok(Json(json!({
"success": false,
"message": "OIDC is not configured"
})));
},
};
if !enabled {
return Ok(Json(json!({
"success": false,
"message": "OIDC is not enabled"
})));
}
if discovery_url.is_empty() {
return Ok(Json(json!({
"success": false,
"message": "OIDC discovery URL is not set"
})));
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| {
tracing::error!(error = %e, "Failed to build HTTP client");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "HTTP client error" } })),
)
})?;
match client.get(&discovery_url).send().await {
Ok(resp) if resp.status().is_success() => { Ok(resp) if resp.status().is_success() => {
let body: Value = resp.json().await.unwrap_or(json!({})); let body: Value = resp.json().await.unwrap_or(json!({}));
let issuer = body.get("issuer").and_then(|v| v.as_str()).unwrap_or(""); let issuer = body.get("issuer").and_then(|v| v.as_str()).unwrap_or("");
if issuer.contains(&tenant_id) { let provider_label = match provider_type.as_str() {
"keycloak" => "Keycloak",
"azure" => "Azure AD",
_ => "OIDC",
};
Ok(Json(json!({ Ok(Json(json!({
"success": true, "success": true,
"message": "Azure AD tenant verified successfully", "message": format!("{} provider verified successfully", provider_label),
"issuer": issuer "issuer": issuer,
"provider_type": provider_type,
}))) })))
} else {
Ok(Json(json!({
"success": true,
"message": "Azure AD endpoint reached, but issuer does not match tenant_id",
"issuer": issuer
})))
}
}, },
Ok(resp) => Ok(Json(json!({ Ok(resp) => Ok(Json(json!({
"success": false, "success": false,
"message": format!("Failed to reach Azure AD: HTTP {}", resp.status()) "message": format!("Failed to reach OIDC provider: HTTP {}", resp.status())
}))), }))),
Err(e) => Ok(Json(json!({ Err(e) => Ok(Json(json!({
"success": false, "success": false,
"message": format!("Failed to reach Azure AD: {}", e) "message": format!("Failed to reach OIDC provider: {}", e)
}))), }))),
} }
} }
// ============================================================
// POST /api/v1/settings/azure-sso/test (backward-compatible alias)
// ============================================================
async fn test_azure_sso_compat(
state: State<AppState>,
auth: AuthUser,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
test_oidc(state, auth).await
}
// ============================================================ // ============================================================
// POST /api/v1/settings/smtp/test // POST /api/v1/settings/smtp/test
// ============================================================ // ============================================================

View File

@ -1,8 +1,12 @@
//! Azure SSO OAuth2/OIDC flow routes. //! Generic OIDC SSO routes (Keycloak, Azure AD, Custom).
//! //!
//! Public routes (no auth required): //! Public routes (no auth required):
//! GET /api/v1/auth/azure/login — redirect to Azure AD authorization URL //! GET /api/v1/auth/sso/login — redirect to OIDC provider authorization URL
//! GET /api/v1/auth/azure/callback — handle Azure AD callback, redirect to frontend SPA //! GET /api/v1/auth/sso/callback — handle OIDC provider callback, redirect to frontend SPA
//!
//! Backward-compatible aliases:
//! GET /api/v1/auth/azure/login → redirects to generic SSO login
//! GET /api/v1/auth/azure/callback → redirects to generic SSO callback
use axum::{ use axum::{
extract::State, extract::State,
@ -51,6 +55,7 @@ struct TokenResponse {
struct IdTokenClaims { struct IdTokenClaims {
email: Option<String>, email: Option<String>,
name: Option<String>, name: Option<String>,
sub: Option<String>,
oid: Option<String>, oid: Option<String>,
preferred_username: Option<String>, preferred_username: Option<String>,
} }
@ -65,23 +70,51 @@ struct DbUserForSso {
mfa_enabled: bool, mfa_enabled: bool,
} }
/// Cache for Azure AD JWKS (JSON Web Key Set) with TTL-based refresh. /// OIDC provider configuration from database.
pub struct JwksCache { #[derive(Debug, Clone, sqlx::FromRow)]
pub keys: Option<serde_json::Value>, pub struct OidcConfig {
pub fetched_at: Option<chrono::DateTime<Utc>>, pub enabled: bool,
pub provider_type: String,
pub display_name: String,
pub discovery_url: String,
pub client_id: String,
pub client_secret: String,
pub redirect_uri: String,
pub scopes: String,
} }
impl Default for JwksCache { /// Cached OIDC discovery document.
#[derive(Debug, Clone)]
pub struct OidcDiscovery {
pub issuer: String,
pub authorization_endpoint: String,
pub token_endpoint: String,
pub jwks_uri: String,
pub userinfo_endpoint: Option<String>,
pub fetched_at: chrono::DateTime<Utc>,
}
/// Cache for OIDC discovery documents and JWKS with TTL-based refresh.
pub struct OidcCache {
pub discovery: Option<OidcDiscovery>,
pub jwks: Option<serde_json::Value>,
pub jwks_fetched_at: Option<chrono::DateTime<Utc>>,
}
impl Default for OidcCache {
fn default() -> Self { fn default() -> Self {
Self { Self {
keys: None, discovery: None,
fetched_at: None, jwks: None,
jwks_fetched_at: None,
} }
} }
} }
/// JWKS cache TTL in seconds (1 hour). /// JWKS cache TTL in seconds (1 hour).
const JWKS_CACHE_TTL_SECS: i64 = 3600; const JWKS_CACHE_TTL_SECS: i64 = 3600;
/// Discovery cache TTL in seconds (1 hour).
const DISCOVERY_CACHE_TTL_SECS: i64 = 3600;
// ============================================================ // ============================================================
// Router // Router
@ -89,52 +122,55 @@ const JWKS_CACHE_TTL_SECS: i64 = 3600;
pub fn public_router() -> Router<AppState> { pub fn public_router() -> Router<AppState> {
Router::new() Router::new()
.route("/login", get(azure_login)) .route("/login", get(sso_login))
.route("/callback", get(azure_callback)) .route("/callback", get(sso_callback))
}
/// Backward-compatible Azure SSO routes — redirect to generic SSO endpoints.
pub fn azure_compat_router() -> Router<AppState> {
Router::new()
.route("/login", get(azure_login_redirect))
.route("/callback", get(azure_callback_redirect))
} }
// ============================================================ // ============================================================
// GET /api/v1/auth/azure/login // GET /api/v1/auth/sso/login
// ============================================================ // ============================================================
async fn azure_login( async fn sso_login(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, Json<Value>)> { ) -> Result<impl IntoResponse, (StatusCode, Json<Value>)> {
// Read Azure SSO config from DB let config = load_oidc_config(&state.db).await?;
let row: Option<(bool, String, String, String, String)> = sqlx::query_as(
"SELECT enabled, tenant_id, client_id, redirect_uri, scopes FROM azure_sso_config WHERE id = 1",
)
.fetch_optional(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to load azure_sso_config");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
)
})?;
let (enabled, tenant_id, client_id, redirect_uri, scopes) = match row { if !config.enabled {
Some(r) => r, return Err((
None => { StatusCode::FORBIDDEN,
Json(json!({ "error": { "code": "forbidden", "message": "SSO is not enabled" } })),
));
}
if config.discovery_url.is_empty() {
return Err(( return Err((
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
Json( Json(
json!({ "error": { "code": "forbidden", "message": "Azure SSO is not configured" } }), json!({ "error": { "code": "forbidden", "message": "SSO discovery URL is not configured" } }),
),
));
}
// Fetch OIDC discovery document (with caching)
let discovery = match fetch_discovery(&state).await {
Ok(d) => d,
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(
json!({ "error": { "code": "internal_error", "message": format!("Failed to fetch OIDC discovery: {}", e) } }),
), ),
)); ));
}, },
}; };
if !enabled {
return Err((
StatusCode::FORBIDDEN,
Json(
json!({ "error": { "code": "forbidden", "message": "Azure SSO is not enabled" } }),
),
));
}
// Generate PKCE code_verifier (32 random bytes → base64url) // Generate PKCE code_verifier (32 random bytes → base64url)
let mut verifier_bytes = [0u8; 32]; let mut verifier_bytes = [0u8; 32];
rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut verifier_bytes); rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut verifier_bytes);
@ -156,19 +192,23 @@ async fn azure_login(
}, },
); );
// Build authorization URL // Build authorization URL from discovery
let encoded_scopes = urlencoding::encode(&scopes); let encoded_scopes = urlencoding::encode(&config.scopes);
let auth_url = format!( let auth_url = format!(
"https://login.microsoftonline.com/{}/oauth2/v2.0/authorize?client_id={}&response_type=code&redirect_uri={}&scope={}&code_challenge={}&code_challenge_method=S256&state={}", "{}?client_id={}&response_type=code&redirect_uri={}&scope={}&code_challenge={}&code_challenge_method=S256&state={}",
tenant_id, client_id, redirect_uri, encoded_scopes, code_challenge, state_token discovery.authorization_endpoint,
urlencoding::encode(&config.client_id),
urlencoding::encode(&config.redirect_uri),
encoded_scopes,
code_challenge,
state_token
); );
// Redirect to Azure AD
Ok(Redirect::to(&auth_url)) Ok(Redirect::to(&auth_url))
} }
// ============================================================ // ============================================================
// GET /api/v1/auth/azure/callback // GET /api/v1/auth/sso/callback
// ============================================================ // ============================================================
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -179,13 +219,12 @@ struct CallbackParams {
error_description: Option<String>, error_description: Option<String>,
} }
async fn azure_callback( async fn sso_callback(
State(state): State<AppState>, State(state): State<AppState>,
axum::extract::Query(params): axum::extract::Query<CallbackParams>, axum::extract::Query(params): axum::extract::Query<CallbackParams>,
) -> Result<Redirect, Redirect> { ) -> Result<Redirect, Redirect> {
let callback_url = &state.config.security.sso_callback_url; let callback_url = &state.config.security.sso_callback_url;
// Helper to build error redirect
let error_redirect = |code: &str, message: &str| -> Redirect { let error_redirect = |code: &str, message: &str| -> Redirect {
let url = format!( let url = format!(
"{}?error={}&error_description={}", "{}?error={}&error_description={}",
@ -196,10 +235,9 @@ async fn azure_callback(
Redirect::to(&url) Redirect::to(&url)
}; };
// Check for error from Azure AD
if let Some(error) = params.error { if let Some(error) = params.error {
let desc = params.error_description.unwrap_or_default(); let desc = params.error_description.unwrap_or_default();
let message = format!("Azure AD error: {} - {}", error, desc); let message = format!("OIDC provider error: {} - {}", error, desc);
return Err(error_redirect("sso_error", &message)); return Err(error_redirect("sso_error", &message));
} }
@ -213,7 +251,6 @@ async fn azure_callback(
None => return Err(error_redirect("bad_request", "Missing state parameter")), None => return Err(error_redirect("bad_request", "Missing state parameter")),
}; };
// Look up code_verifier from sso_sessions
let sso_session = match state.sso_sessions.remove(&state_token).map(|(_, v)| v) { let sso_session = match state.sso_sessions.remove(&state_token).map(|(_, v)| v) {
Some(s) => s, Some(s) => s,
None => { None => {
@ -224,33 +261,28 @@ async fn azure_callback(
}, },
}; };
// Read Azure SSO config (including client_secret for token exchange) let config = match load_oidc_config(&state.db).await {
let row: Option<(bool, String, String, String, String)> = match sqlx::query_as( Ok(c) => c,
"SELECT enabled, tenant_id, client_id, client_secret, redirect_uri FROM azure_sso_config WHERE id = 1", Err(_) => {
) return Err(error_redirect(
.fetch_optional(&state.db) "internal_error",
.await "Failed to load OIDC config",
{ ))
Ok(r) => r, },
Err(e) => {
tracing::error!(error = %e, "Failed to load azure_sso_config");
return Err(error_redirect("internal_error", "Database error"));
}
}; };
let (_enabled, tenant_id, client_id, client_secret, redirect_uri) = match row { let discovery = match fetch_discovery(&state).await {
Some(r) => r, Ok(d) => d,
None => { Err(e) => {
return Err(error_redirect("internal_error", "Azure SSO not configured")); tracing::error!(error = %e, "Failed to fetch OIDC discovery");
return Err(error_redirect(
"internal_error",
"Failed to fetch OIDC discovery",
));
}, },
}; };
// Exchange code for tokens // Exchange code for tokens
let token_url = format!(
"https://login.microsoftonline.com/{}/oauth2/v2.0/token",
tenant_id
);
let client = match reqwest::Client::builder() let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10)) .timeout(std::time::Duration::from_secs(10))
.build() .build()
@ -262,18 +294,25 @@ async fn azure_callback(
}, },
}; };
let params = [ let mut params_vec: Vec<(&str, String)> = vec![
("grant_type", "authorization_code".to_string()), ("grant_type", "authorization_code".to_string()),
("code", code.clone()), ("code", code.clone()),
("redirect_uri", redirect_uri.clone()), ("redirect_uri", config.redirect_uri.clone()),
("client_id", client_id.clone()), ("client_id", config.client_id.clone()),
("client_secret", client_secret.clone()),
("code_verifier", sso_session.code_verifier.clone()), ("code_verifier", sso_session.code_verifier.clone()),
]; ];
let form_params: Vec<(&str, String)> = params.to_vec(); // For confidential clients (Azure AD), include client_secret
if !config.client_secret.is_empty() {
params_vec.push(("client_secret", config.client_secret.clone()));
}
let token_resp = match client.post(&token_url).form(&form_params).send().await { let token_resp = match client
.post(&discovery.token_endpoint)
.form(&params_vec)
.send()
.await
{
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
tracing::error!(error = %e, "Token exchange request failed"); tracing::error!(error = %e, "Token exchange request failed");
@ -305,13 +344,12 @@ async fn azure_callback(
}, },
}; };
// Verify id_token JWT signature using Azure AD JWKS and validate claims
let id_token = match token_data.id_token { let id_token = match token_data.id_token {
Some(t) => t, Some(t) => t,
None => return Err(error_redirect("sso_error", "No id_token in response")), None => return Err(error_redirect("sso_error", "No id_token in response")),
}; };
let claims = match verify_id_token(&id_token, &tenant_id, &client_id, &state.jwks_cache).await { let claims = match verify_id_token(&id_token, &config, &discovery, &state.oidc_cache).await {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
tracing::error!(error = %e, "Failed to verify id_token"); tracing::error!(error = %e, "Failed to verify id_token");
@ -324,22 +362,37 @@ async fn azure_callback(
let email = claims.email.unwrap_or_default(); let email = claims.email.unwrap_or_default();
let name = claims.name.unwrap_or_default(); let name = claims.name.unwrap_or_default();
let oid = claims.oid.unwrap_or_default(); let oidc_sub = claims.sub.unwrap_or_default();
let azure_oid = claims.oid.unwrap_or_default();
let preferred_username = claims.preferred_username.unwrap_or_else(|| email.clone()); let preferred_username = claims.preferred_username.unwrap_or_else(|| email.clone());
if email.is_empty() || oid.is_empty() { let provider_subject = if !oidc_sub.is_empty() {
oidc_sub.clone()
} else if !azure_oid.is_empty() {
azure_oid.clone()
} else {
return Err(error_redirect( return Err(error_redirect(
"sso_error", "sso_error",
"Missing email or oid in id_token", "Missing subject identifier in id_token",
)); ));
};
if email.is_empty() {
return Err(error_redirect("sso_error", "Missing email in id_token"));
} }
// Look up or create user let auth_provider = match config.provider_type.as_str() {
"keycloak" => "keycloak",
"azure" => "azure_sso",
_ => "oidc",
};
let user_opt: Option<DbUserForSso> = match sqlx::query_as( let user_opt: Option<DbUserForSso> = match sqlx::query_as(
r#"SELECT id, username, display_name, role, is_active, mfa_enabled r#"SELECT id, username, display_name, role, is_active, mfa_enabled
FROM users WHERE email = $1 AND auth_provider = 'azure_sso'"#, FROM users WHERE email = $1 AND auth_provider = $2"#,
) )
.bind(&email) .bind(&email)
.bind(auth_provider)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await .await
{ {
@ -356,16 +409,17 @@ async fn azure_callback(
}, },
Some(u) => u, Some(u) => u,
None => { None => {
// Auto-create user with role=operator, auth_provider=azure_sso
let id: Uuid = match sqlx::query_scalar( let id: Uuid = match sqlx::query_scalar(
r#"INSERT INTO users (username, display_name, email, role, auth_provider, azure_oid) r#"INSERT INTO users (username, display_name, email, role, auth_provider, azure_oid, oidc_sub)
VALUES ($1, $2, $3, 'operator', 'azure_sso', $4) VALUES ($1, $2, $3, 'operator', $4, $5, $6)
RETURNING id"#, RETURNING id"#,
) )
.bind(&preferred_username) .bind(&preferred_username)
.bind(&name) .bind(&name)
.bind(&email) .bind(&email)
.bind(&oid) .bind(auth_provider)
.bind(if azure_oid.is_empty() { None } else { Some(azure_oid.as_str()) })
.bind(if provider_subject.is_empty() { None } else { Some(provider_subject.as_str()) })
.fetch_one(&state.db) .fetch_one(&state.db)
.await .await
{ {
@ -380,10 +434,10 @@ async fn azure_callback(
&state.db, &state.db,
AuditAction::UserCreated, AuditAction::UserCreated,
None, None,
Some("azure_sso"), Some(auth_provider),
Some("user"), Some("user"),
Some(&id.to_string()), Some(&id.to_string()),
json!({ "auth_provider": "azure_sso", "email": email }), json!({ "auth_provider": auth_provider, "email": email }),
None, None,
None, None,
) )
@ -400,11 +454,12 @@ async fn azure_callback(
}, },
}; };
// Update last_login_at and azure_oid // Update last_login_at and provider subject IDs
if let Err(e) = sqlx::query( if let Err(e) = sqlx::query(
"UPDATE users SET last_login_at = NOW(), azure_oid = COALESCE(azure_oid, $1) WHERE id = $2", "UPDATE users SET last_login_at = NOW(), azure_oid = COALESCE(azure_oid, $1), oidc_sub = COALESCE(oidc_sub, $2) WHERE id = $3",
) )
.bind(&oid) .bind(if azure_oid.is_empty() { None } else { Some(azure_oid.as_str()) })
.bind(if provider_subject.is_empty() { None } else { Some(provider_subject.as_str()) })
.bind(user.id) .bind(user.id)
.execute(&state.db) .execute(&state.db)
.await .await
@ -413,7 +468,6 @@ async fn azure_callback(
return Err(error_redirect("internal_error", "Database error")); return Err(error_redirect("internal_error", "Database error"));
} }
// Issue JWT access token + refresh token
let access_ttl = state.config.security.jwt_access_ttl_secs as i64; let access_ttl = state.config.security.jwt_access_ttl_secs as i64;
let access_token = match issue_access_token( let access_token = match issue_access_token(
user.id, user.id,
@ -447,22 +501,21 @@ async fn azure_callback(
Some(&user.username), Some(&user.username),
None, None,
None, None,
json!({ "auth_provider": "azure_sso" }), json!({ "auth_provider": auth_provider }),
None, None,
None, None,
) )
.await; .await;
// Build user JSON for query parameter
let user_json = json!({ let user_json = json!({
"id": user.id.to_string(), "id": user.id.to_string(),
"username": user.username, "username": user.username,
"display_name": user.display_name, "display_name": user.display_name,
"role": user.role, "role": user.role,
"auth_provider": auth_provider,
"mfa_enabled": user.mfa_enabled, "mfa_enabled": user.mfa_enabled,
}); });
// Redirect to frontend SPA with tokens as query parameters
let redirect_url = format!( let redirect_url = format!(
"{}?access_token={}&refresh_token={}&token_type=Bearer&expires_in={}&user={}", "{}?access_token={}&refresh_token={}&token_type=Bearer&expires_in={}&user={}",
callback_url, callback_url,
@ -476,32 +529,149 @@ async fn azure_callback(
} }
// ============================================================ // ============================================================
// JWT Verification Helpers // Backward-compatible Azure SSO redirect handlers
// ============================================================ // ============================================================
/// Verify the id_token JWT signature using Azure AD JWKS and validate standard claims. async fn azure_login_redirect(
/// State(state): State<AppState>,
/// Steps: ) -> Result<impl IntoResponse, (StatusCode, Json<Value>)> {
/// 1. Decode JWT header to extract `kid` (key ID) sso_login(State(state)).await
/// 2. Fetch JWKS from Azure AD if cache is empty or expired (1-hour TTL) }
/// 3. Find the matching JWK by `kid`
/// 4. Construct RSA public key from JWK modulus (`n`) and exponent (`e`) async fn azure_callback_redirect(
/// 5. Validate issuer, audience, and expiry via `jsonwebtoken::decode` State(state): State<AppState>,
axum::extract::Query(params): axum::extract::Query<CallbackParams>,
) -> Result<Redirect, Redirect> {
sso_callback(State(state), axum::extract::Query(params)).await
}
// ============================================================
// Database helpers
// ============================================================
async fn load_oidc_config(pool: &sqlx::PgPool) -> Result<OidcConfig, (StatusCode, Json<Value>)> {
let row: Option<OidcConfig> = sqlx::query_as(
"SELECT enabled, provider_type, display_name, discovery_url, client_id, client_secret, redirect_uri, scopes FROM oidc_config WHERE id = 1",
)
.fetch_optional(pool)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to load oidc_config");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
)
})?;
Ok(row.unwrap_or(OidcConfig {
enabled: false,
provider_type: "azure".to_string(),
display_name: "Azure AD".to_string(),
discovery_url: String::new(),
client_id: String::new(),
client_secret: String::new(),
redirect_uri: String::new(),
scopes: "openid profile email".to_string(),
}))
}
// ============================================================
// OIDC Discovery & JWKS
// ============================================================
async fn fetch_discovery(state: &AppState) -> Result<OidcDiscovery, String> {
let config = match load_oidc_config(&state.db).await {
Ok(c) => c,
Err(_) => {
return Err("Failed to load OIDC config".to_string());
},
};
let discovery_url = config.discovery_url;
// Check cache first
{
let cache = state.oidc_cache.lock().await;
if let Some(ref disc) = cache.discovery {
let elapsed = Utc::now().signed_duration_since(disc.fetched_at);
if elapsed.num_seconds() < DISCOVERY_CACHE_TTL_SECS {
return Ok(disc.clone());
}
}
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| format!("Failed to build HTTP client: {}", e))?;
let resp = client
.get(&discovery_url)
.send()
.await
.map_err(|e| format!("Discovery fetch failed: {}", e))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(format!(
"Discovery fetch failed: HTTP {} — {}",
status, body
));
}
let doc: Value = resp
.json()
.await
.map_err(|e| format!("Failed to parse discovery document: {}", e))?;
let discovery = OidcDiscovery {
issuer: doc
.get("issuer")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
authorization_endpoint: doc
.get("authorization_endpoint")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
token_endpoint: doc
.get("token_endpoint")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
jwks_uri: doc
.get("jwks_uri")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
userinfo_endpoint: doc
.get("userinfo_endpoint")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
fetched_at: Utc::now(),
};
{
let mut cache = state.oidc_cache.lock().await;
cache.discovery = Some(discovery.clone());
}
Ok(discovery)
}
async fn verify_id_token( async fn verify_id_token(
token: &str, token: &str,
tenant_id: &str, config: &OidcConfig,
client_id: &str, discovery: &OidcDiscovery,
jwks_cache: &Arc<Mutex<JwksCache>>, oidc_cache: &Arc<Mutex<OidcCache>>,
) -> Result<IdTokenClaims, String> { ) -> Result<IdTokenClaims, String> {
// 1. Decode JWT header to get the kid
let header = decode_header(token).map_err(|e| format!("Failed to decode JWT header: {}", e))?; let header = decode_header(token).map_err(|e| format!("Failed to decode JWT header: {}", e))?;
let kid = header.kid.ok_or("JWT header missing 'kid' field")?; let kid = header.kid.ok_or("JWT header missing 'kid' field")?;
// 2. Check JWKS cache — fetch if expired or missing
let jwks = { let jwks = {
let cache = jwks_cache.lock().await; let cache = oidc_cache.lock().await;
let needs_fetch = match (&cache.keys, &cache.fetched_at) { let needs_fetch = match (&cache.jwks, &cache.jwks_fetched_at) {
(None, _) => true, (None, _) => true,
(Some(_), None) => true, (Some(_), None) => true,
(Some(_), Some(fetched)) => { (Some(_), Some(fetched)) => {
@ -511,21 +681,17 @@ async fn verify_id_token(
}; };
if needs_fetch { if needs_fetch {
// Drop lock before making async HTTP request
drop(cache); drop(cache);
let jwks_value = fetch_jwks(&discovery.jwks_uri).await?;
let jwks_value = fetch_jwks(tenant_id).await?; let mut cache = oidc_cache.lock().await;
cache.jwks = Some(jwks_value);
let mut cache = jwks_cache.lock().await; cache.jwks_fetched_at = Some(Utc::now());
cache.keys = Some(jwks_value); cache.jwks.clone().unwrap()
cache.fetched_at = Some(Utc::now());
cache.keys.clone().unwrap()
} else { } else {
cache.keys.clone().unwrap() cache.jwks.clone().unwrap()
} }
}; };
// 3. Find the matching JWK by kid
let keys_array = jwks let keys_array = jwks
.get("keys") .get("keys")
.ok_or("JWKS response missing 'keys' array")? .ok_or("JWKS response missing 'keys' array")?
@ -537,7 +703,6 @@ async fn verify_id_token(
.find(|k| k.get("kid").and_then(|v| v.as_str()) == Some(kid.as_str())) .find(|k| k.get("kid").and_then(|v| v.as_str()) == Some(kid.as_str()))
.ok_or_else(|| format!("No matching JWK found for kid: {}", kid))?; .ok_or_else(|| format!("No matching JWK found for kid: {}", kid))?;
// 4. Construct RSA public key from JWK modulus (n) and exponent (e)
let n = jwk let n = jwk
.get("n") .get("n")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
@ -550,36 +715,25 @@ async fn verify_id_token(
let decoding_key = DecodingKey::from_rsa_components(n, e) let decoding_key = DecodingKey::from_rsa_components(n, e)
.map_err(|e| format!("Failed to construct RSA decoding key: {}", e))?; .map_err(|e| format!("Failed to construct RSA decoding key: {}", e))?;
// 5. Configure validation rules
let mut validation = Validation::new(Algorithm::RS256); let mut validation = Validation::new(Algorithm::RS256);
validation.iss = Some(HashSet::from([format!( validation.iss = Some(HashSet::from([discovery.issuer.clone()]));
"https://login.microsoftonline.com/{}/v2.0", validation.aud = Some(HashSet::from([config.client_id.clone()]));
tenant_id validation.leeway = 60;
)]));
validation.aud = Some(HashSet::from([client_id.to_string()]));
validation.leeway = 60; // 60 seconds clock skew tolerance
// 6. Decode and verify the JWT
let token_data = decode::<IdTokenClaims>(token, &decoding_key, &validation) let token_data = decode::<IdTokenClaims>(token, &decoding_key, &validation)
.map_err(|e| format!("JWT signature verification failed: {}", e))?; .map_err(|e| format!("JWT signature verification failed: {}", e))?;
Ok(token_data.claims) Ok(token_data.claims)
} }
/// Fetch the JWKS from the Azure AD discovery endpoint. async fn fetch_jwks(jwks_uri: &str) -> Result<serde_json::Value, String> {
async fn fetch_jwks(tenant_id: &str) -> Result<serde_json::Value, String> {
let jwks_url = format!(
"https://login.microsoftonline.com/{}/discovery/v2.0/keys",
tenant_id
);
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10)) .timeout(std::time::Duration::from_secs(10))
.build() .build()
.map_err(|e| format!("Failed to build HTTP client for JWKS fetch: {}", e))?; .map_err(|e| format!("Failed to build HTTP client for JWKS fetch: {}", e))?;
let resp = client let resp = client
.get(&jwks_url) .get(jwks_uri)
.send() .send()
.await .await
.map_err(|e| format!("JWKS fetch request failed: {}", e))?; .map_err(|e| format!("JWKS fetch request failed: {}", e))?;

View File

@ -1,7 +1,7 @@
{ {
"name": "patch-manager-ui", "name": "patch-manager-ui",
"private": true, "private": true,
"version": "0.1.4", "version": "0.1.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@ -212,6 +212,8 @@ export const reportsApi = {
}), }),
} }
// ── Settings API (M10) ──────────────────────────────────────────────────── // ── Settings API (M10) ────────────────────────────────────────────────────
/** @deprecated Use OidcConfigResponse instead */
export interface AzureSsoConfig { export interface AzureSsoConfig {
enabled: boolean enabled: boolean
tenant_id: string tenant_id: string
@ -220,6 +222,27 @@ export interface AzureSsoConfig {
scopes: string scopes: string
} }
export interface OidcConfigResponse {
enabled: boolean
provider_type: 'keycloak' | 'azure' | 'custom'
display_name: string
discovery_url: string
client_id: string
client_secret: string
redirect_uri: string
scopes: string
}
export interface OidcDiscoveryResult {
success: boolean
issuer: string
authorization_endpoint: string
token_endpoint: string
jwks_uri: string
userinfo_endpoint?: string | null
message?: string
}
export interface SmtpConfig { export interface SmtpConfig {
enabled: boolean enabled: boolean
host: string host: string
@ -241,12 +264,13 @@ export interface NotificationConfig {
} }
export interface SettingsResponse { export interface SettingsResponse {
azure_sso: AzureSsoConfig oidc: OidcConfigResponse
smtp: SmtpConfig smtp: SmtpConfig
polling: PollingConfig polling: PollingConfig
ip_whitelist: string[] ip_whitelist: string[]
web_tls_strategy: string web_tls_strategy: string
notification: NotificationConfig notification: NotificationConfig
sso_callback_url?: string
} }
export interface TestResult { export interface TestResult {
@ -267,11 +291,14 @@ export interface AuditIntegrityResult {
export const settingsApi = { export const settingsApi = {
get: () => apiClient.get<SettingsResponse>('/settings'), get: () => apiClient.get<SettingsResponse>('/settings'),
update: (data: Partial<SettingsResponse> & { update: (data: Partial<SettingsResponse> & {
azure_sso?: AzureSsoConfig & { client_secret?: string } oidc?: OidcConfigResponse & { client_secret?: string }
smtp?: SmtpConfig & { password?: string } smtp?: SmtpConfig & { password?: string }
notification?: NotificationConfig notification?: NotificationConfig
}) => apiClient.put<SettingsResponse>('/settings', data), }) => apiClient.put<SettingsResponse>('/settings', data),
testAzureSso: () => apiClient.post<TestResult>('/settings/azure-sso/test'), discoverOidc: (discoveryUrl: string) => apiClient.post<OidcDiscoveryResult>('/settings/sso/discover', { discovery_url: discoveryUrl }),
testOidc: () => apiClient.post<TestResult>('/settings/sso/test'),
/** @deprecated Use testOidc instead */
testAzureSso: () => apiClient.post<TestResult>('/settings/sso/test'),
testSmtp: () => apiClient.post<TestResult>('/settings/smtp/test'), testSmtp: () => apiClient.post<TestResult>('/settings/smtp/test'),
getIpWhitelist: () => apiClient.get<{ entries: string[] }>('/settings/ip-whitelist'), getIpWhitelist: () => apiClient.get<{ entries: string[] }>('/settings/ip-whitelist'),
updateIpWhitelist: (entries: string[]) => apiClient.put<{ entries: string[] }>('/settings/ip-whitelist', { entries }), updateIpWhitelist: (entries: string[]) => apiClient.put<{ entries: string[] }>('/settings/ip-whitelist', { entries }),

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react' import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { import {
Box, Button, Container, TextField, Typography, Box, Button, Container, TextField, Typography,
@ -9,70 +9,32 @@ import {
import { import {
Visibility, VisibilityOff, Visibility, VisibilityOff,
Check as CheckIcon, Close as CloseIcon, Check as CheckIcon, Close as CloseIcon,
Cloud as CloudIcon, Cloud as CloudIcon, VpnKey as KeyIcon,
} from '@mui/icons-material' } from '@mui/icons-material'
import { authApi } from '../api/client' import { authApi, settingsApi } from '../api/client'
import { useAuthStore } from '../store/authStore' import { useAuthStore } from '../store/authStore'
import type { User } from '../types' import type { User } from '../types'
/** Extract a human-readable error message from an Axios error. */
function getErrorMessage(err: unknown): string { function getErrorMessage(err: unknown): string {
// Network error — no response at all (server unreachable, CORS, DNS failure)
if (err instanceof Error && err.message === 'Network Error') { if (err instanceof Error && err.message === 'Network Error') {
return 'Unable to connect to the server. Please check your network connection and try again.' return 'Unable to connect to the server. Please check your network connection and try again.'
} }
// Axios-style error with a response body
const axiosErr = err as { response?: { status?: number; data?: { error?: { code?: string; message?: string } } } } const axiosErr = err as { response?: { status?: number; data?: { error?: { code?: string; message?: string } } } }
const status = axiosErr.response?.status const status = axiosErr.response?.status
const code = axiosErr.response?.data?.error?.code const code = axiosErr.response?.data?.error?.code
const msg = axiosErr.response?.data?.error?.message const msg = axiosErr.response?.data?.error?.message
if (status === 429) return 'Too many login attempts. Please wait a moment and try again.'
// Rate limited if (code === 'mfa_required') return 'MFA_REQUIRED'
if (status === 429) { if (code === 'password_reset_required') return 'PASSWORD_RESET_REQUIRED'
return 'Too many login attempts. Please wait a moment and try again.' if (code === 'account_locked') return 'ACCOUNT_LOCKED'
} if (code === 'account_disabled') return 'This account has been disabled. Contact your administrator.'
if (msg) return msg
// MFA required if (status === 401) return 'Invalid username or password.'
if (code === 'mfa_required') { if (status === 403) return 'Access denied.'
return 'MFA_REQUIRED' // sentinel — caller checks this if (status && status >= 500) return 'A server error occurred. Please try again later.'
}
// Password reset required
if (code === 'password_reset_required') {
return 'PASSWORD_RESET_REQUIRED'
}
// Account locked
if (code === 'account_locked') {
return 'ACCOUNT_LOCKED'
}
// Account disabled
if (code === 'account_disabled') {
return 'This account has been disabled. Contact your administrator.'
}
// Server-provided message
if (msg) {
return msg
}
// Generic status-based messages
if (status === 401) {
return 'Invalid username or password.'
}
if (status === 403) {
return 'Access denied.'
}
if (status && status >= 500) {
return 'A server error occurred. Please try again later.'
}
return 'Login failed. Please try again.' return 'Login failed. Please try again.'
} }
/** Password strength checker */
function checkPasswordStrength(password: string) { function checkPasswordStrength(password: string) {
return { return {
length: password.length >= 8, length: password.length >= 8,
@ -100,7 +62,9 @@ export default function LoginPage() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// Force change password state const [ssoEnabled, setSsoEnabled] = useState(false)
const [ssoDisplayName, setSsoDisplayName] = useState('SSO')
const [newPassword, setNewPassword] = useState('') const [newPassword, setNewPassword] = useState('')
const [confirmNewPassword, setConfirmNewPassword] = useState('') const [confirmNewPassword, setConfirmNewPassword] = useState('')
const [showNewPassword, setShowNewPassword] = useState(false) const [showNewPassword, setShowNewPassword] = useState(false)
@ -110,11 +74,17 @@ export default function LoginPage() {
const pwValid = isPasswordValid(pwChecks) const pwValid = isPasswordValid(pwChecks)
const pwMismatch = !!(confirmNewPassword && newPassword !== confirmNewPassword) const pwMismatch = !!(confirmNewPassword && newPassword !== confirmNewPassword)
useEffect(() => {
settingsApi.get().then(({ data }) => {
setSsoEnabled(data.oidc.enabled)
setSsoDisplayName(data.oidc.display_name || 'SSO')
}).catch(() => { /* SSO settings unavailable */ })
}, [])
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
const res = await authApi.login(username, password, needsMfa ? totpCode : undefined) const res = await authApi.login(username, password, needsMfa ? totpCode : undefined)
const { access_token, refresh_token, user } = res.data const { access_token, refresh_token, user } = res.data
@ -144,7 +114,6 @@ export default function LoginPage() {
if (!pwValid || pwMismatch) return if (!pwValid || pwMismatch) return
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
await authApi.forceChangePassword(username, password, newPassword) await authApi.forceChangePassword(username, password, newPassword)
setPasswordChanged(true) setPasswordChanged(true)
@ -177,6 +146,8 @@ export default function LoginPage() {
setConfirmNewPassword('') setConfirmNewPassword('')
} }
const ssoIcon = ssoDisplayName.toLowerCase().includes('keycloak') ? <KeyIcon /> : <CloudIcon />
return ( return (
<Container maxWidth="xs" sx={{ mt: 12 }}> <Container maxWidth="xs" sx={{ mt: 12 }}>
<Paper elevation={4} sx={{ p: 4 }}> <Paper elevation={4} sx={{ p: 4 }}>
@ -185,155 +156,51 @@ export default function LoginPage() {
</Typography> </Typography>
{error && ( {error && (
<Alert <Alert severity={forcePasswordReset ? 'warning' : 'error'} sx={{ mb: 2 }} onClose={() => setError(null)}>
severity={forcePasswordReset ? 'warning' : 'error'}
sx={{ mb: 2 }}
onClose={() => setError(null)}
>
{error} {error}
</Alert> </Alert>
)} )}
{passwordChanged ? ( {passwordChanged ? (
<Box> <Box>
<Alert severity="success" sx={{ mb: 2 }}> <Alert severity="success" sx={{ mb: 2 }}>Password changed successfully! Please log in with your new password.</Alert>
Password changed successfully! Please log in with your new password. <Button fullWidth variant="contained" size="large" onClick={handleBackToLogin}>Back to Login</Button>
</Alert>
<Button
fullWidth variant="contained" size="large"
onClick={handleBackToLogin}
>
Back to Login
</Button>
</Box> </Box>
) : forcePasswordReset ? ( ) : forcePasswordReset ? (
<Box component="form" onSubmit={handleForceChangePassword} noValidate> <Box component="form" onSubmit={handleForceChangePassword} noValidate>
<Typography variant="h6" fontWeight={600} mb={2}> <Typography variant="h6" fontWeight={600} mb={2}>Change Your Password</Typography>
Change Your Password <Typography variant="body2" color="text.secondary" mb={2}>Your password has expired and must be changed before you can log in.</Typography>
</Typography> <TextField fullWidth margin="normal" label="Username" value={username} InputProps={{ readOnly: true }} />
<Typography variant="body2" color="text.secondary" mb={2}> <TextField fullWidth margin="normal" label="Current Password" type="password" value={password} InputProps={{ readOnly: true }} />
Your password has expired and must be changed before you can log in. <TextField fullWidth margin="normal" label="New Password" type={showNewPassword ? 'text' : 'password'} value={newPassword} onChange={(e) => setNewPassword(e.target.value)} disabled={loading} required InputProps={{ endAdornment: <InputAdornment position="end"><IconButton onClick={() => setShowNewPassword(!showNewPassword)} edge="end">{showNewPassword ? <VisibilityOff /> : <Visibility />}</IconButton></InputAdornment> }} />
</Typography>
<TextField
fullWidth margin="normal" label="Username"
value={username} InputProps={{ readOnly: true }}
/>
<TextField
fullWidth margin="normal" label="Current Password" type="password"
value={password} InputProps={{ readOnly: true }}
/>
<TextField
fullWidth margin="normal" label="New Password"
type={showNewPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={loading} required
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setShowNewPassword(!showNewPassword)} edge="end">
{showNewPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
{newPassword && ( {newPassword && (
<Box sx={{ mt: 1, mb: 1 }}> <Box sx={{ mt: 1, mb: 1 }}>
<List dense disablePadding> <List dense disablePadding>
<ListItem disableGutters sx={{ py: 0 }}> <ListItem disableGutters sx={{ py: 0 }}><ListItemIcon sx={{ minWidth: 28 }}>{pwChecks.length ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}</ListItemIcon><ListItemText primary="At least 8 characters" primaryTypographyProps={{ variant: 'caption' }} /></ListItem>
<ListItemIcon sx={{ minWidth: 28 }}> <ListItem disableGutters sx={{ py: 0 }}><ListItemIcon sx={{ minWidth: 28 }}>{pwChecks.uppercase ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}</ListItemIcon><ListItemText primary="At least one uppercase letter" primaryTypographyProps={{ variant: 'caption' }} /></ListItem>
{pwChecks.length ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />} <ListItem disableGutters sx={{ py: 0 }}><ListItemIcon sx={{ minWidth: 28 }}>{pwChecks.lowercase ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}</ListItemIcon><ListItemText primary="At least one lowercase letter" primaryTypographyProps={{ variant: 'caption' }} /></ListItem>
</ListItemIcon> <ListItem disableGutters sx={{ py: 0 }}><ListItemIcon sx={{ minWidth: 28 }}>{pwChecks.digit ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}</ListItemIcon><ListItemText primary="At least one digit" primaryTypographyProps={{ variant: 'caption' }} /></ListItem>
<ListItemText primary="At least 8 characters" primaryTypographyProps={{ variant: 'caption' }} /> <ListItem disableGutters sx={{ py: 0 }}><ListItemIcon sx={{ minWidth: 28 }}>{pwChecks.special ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}</ListItemIcon><ListItemText primary="At least one special character" primaryTypographyProps={{ variant: 'caption' }} /></ListItem>
</ListItem>
<ListItem disableGutters sx={{ py: 0 }}>
<ListItemIcon sx={{ minWidth: 28 }}>
{pwChecks.uppercase ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
</ListItemIcon>
<ListItemText primary="At least one uppercase letter" primaryTypographyProps={{ variant: 'caption' }} />
</ListItem>
<ListItem disableGutters sx={{ py: 0 }}>
<ListItemIcon sx={{ minWidth: 28 }}>
{pwChecks.lowercase ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
</ListItemIcon>
<ListItemText primary="At least one lowercase letter" primaryTypographyProps={{ variant: 'caption' }} />
</ListItem>
<ListItem disableGutters sx={{ py: 0 }}>
<ListItemIcon sx={{ minWidth: 28 }}>
{pwChecks.digit ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
</ListItemIcon>
<ListItemText primary="At least one digit" primaryTypographyProps={{ variant: 'caption' }} />
</ListItem>
<ListItem disableGutters sx={{ py: 0 }}>
<ListItemIcon sx={{ minWidth: 28 }}>
{pwChecks.special ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
</ListItemIcon>
<ListItemText primary="At least one special character" primaryTypographyProps={{ variant: 'caption' }} />
</ListItem>
</List> </List>
</Box> </Box>
)} )}
<TextField <TextField fullWidth margin="normal" label="Confirm New Password" type="password" value={confirmNewPassword} onChange={(e) => setConfirmNewPassword(e.target.value)} disabled={loading} required error={pwMismatch} helperText={pwMismatch ? 'Passwords do not match' : ''} />
fullWidth margin="normal" label="Confirm New Password" type="password" <Button type="submit" fullWidth variant="contained" size="large" sx={{ mt: 3 }} disabled={loading || !pwValid || pwMismatch}>{loading ? <CircularProgress size={24} /> : 'Change Password'}</Button>
value={confirmNewPassword}
onChange={(e) => setConfirmNewPassword(e.target.value)}
disabled={loading} required
error={pwMismatch}
helperText={pwMismatch ? 'Passwords do not match' : ''}
/>
<Button
type="submit" fullWidth variant="contained" size="large"
sx={{ mt: 3 }} disabled={loading || !pwValid || pwMismatch}
>
{loading ? <CircularProgress size={24} /> : 'Change Password'}
</Button>
</Box> </Box>
) : ( ) : (
<Box component="form" onSubmit={handleSubmit} noValidate> <Box component="form" onSubmit={handleSubmit} noValidate>
<TextField <TextField fullWidth margin="normal" label="Username" autoComplete="username" value={username} onChange={(e) => setUsername(e.target.value)} disabled={loading} required autoFocus />
fullWidth margin="normal" label="Username" autoComplete="username" <TextField fullWidth margin="normal" label="Password" type={showPassword ? 'text' : 'password'} autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} disabled={loading} required InputProps={{ endAdornment: <InputAdornment position="end"><IconButton onClick={() => setShowPassword(!showPassword)} edge="end">{showPassword ? <VisibilityOff /> : <Visibility />}</IconButton></InputAdornment> }} />
value={username} onChange={(e) => setUsername(e.target.value)}
disabled={loading} required autoFocus
/>
<TextField
fullWidth margin="normal" label="Password" type={showPassword ? 'text' : 'password'}
autoComplete="current-password" value={password}
onChange={(e) => setPassword(e.target.value)} disabled={loading} required
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setShowPassword(!showPassword)} edge="end">
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
{needsMfa && ( {needsMfa && (
<TextField <TextField fullWidth margin="normal" label="MFA Code" inputMode="numeric" inputProps={{ maxLength: 6, pattern: '[0-9]*' }} value={totpCode} onChange={(e) => setTotpCode(e.target.value)} disabled={loading} required autoFocus helperText="Enter the 6-digit code from your authenticator app" />
fullWidth margin="normal" label="MFA Code" inputMode="numeric"
inputProps={{ maxLength: 6, pattern: '[0-9]*' }}
value={totpCode} onChange={(e) => setTotpCode(e.target.value)}
disabled={loading} required autoFocus
helperText="Enter the 6-digit code from your authenticator app"
/>
)} )}
<Button <Button type="submit" fullWidth variant="contained" size="large" sx={{ mt: 3 }} disabled={loading}>{loading ? <CircularProgress size={24} /> : 'Sign In'}</Button>
type="submit" fullWidth variant="contained" size="large" {ssoEnabled && (
sx={{ mt: 3 }} disabled={loading} <>
>
{loading ? <CircularProgress size={24} /> : 'Sign In'}
</Button>
<Divider sx={{ my: 3 }}>or</Divider> <Divider sx={{ my: 3 }}>or</Divider>
<Button <Button fullWidth variant="outlined" size="large" startIcon={ssoIcon} onClick={() => { window.location.href = '/api/v1/auth/sso/login' }} disabled={loading}>Sign in with {ssoDisplayName}</Button>
fullWidth variant="outlined" size="large" </>
startIcon={<CloudIcon />} )}
onClick={() => { window.location.href = '/api/v1/auth/azure/login' }}
disabled={loading}
>
Sign in with Microsoft Azure
</Button>
</Box> </Box>
)} )}
</Paper> </Paper>

View File

@ -12,15 +12,20 @@ import DeleteIcon from '@mui/icons-material/Delete'
import AddIcon from '@mui/icons-material/Add' import AddIcon from '@mui/icons-material/Add'
import CloudIcon from '@mui/icons-material/Cloud' import CloudIcon from '@mui/icons-material/Cloud'
import EmailIcon from '@mui/icons-material/Email' import EmailIcon from '@mui/icons-material/Email'
import VpnKeyIcon from '@mui/icons-material/VpnKey'
import ExploreIcon from '@mui/icons-material/Explore'
import { settingsApi } from '../api/client' import { settingsApi } from '../api/client'
import type { AzureSsoConfig, SmtpConfig, PollingConfig, NotificationConfig } from '../types' import type { OidcConfigResponse, OidcDiscoveryResult, SmtpConfig, PollingConfig, NotificationConfig } from '../types'
type AzureSsoForm = AzureSsoConfig & { client_secret?: string } type OidcForm = OidcConfigResponse & { client_secret?: string }
type SmtpForm = SmtpConfig & { password?: string } type SmtpForm = SmtpConfig & { password?: string }
const KEYCLOAK_DISCOVERY_URL = 'https://keycloak.moon-dragon.us/realms/moon-dragon.us/.well-known/openid-configuration'
export default function SettingsPage() { export default function SettingsPage() {
const [azureSso, setAzureSso] = useState<AzureSsoForm>({ const [oidc, setOidc] = useState<OidcForm>({
enabled: false, tenant_id: '', client_id: '', client_secret: '', redirect_uri: '', scopes: 'openid email profile', enabled: false, provider_type: 'azure', display_name: 'Azure AD',
discovery_url: '', client_id: '', client_secret: '', redirect_uri: '', scopes: 'openid profile email',
}) })
const [smtp, setSmtp] = useState<SmtpForm>({ const [smtp, setSmtp] = useState<SmtpForm>({
enabled: false, host: '', port: 587, username: '', password: '', from: '', tls_mode: 'starttls', enabled: false, host: '', port: 587, username: '', password: '', from: '', tls_mode: 'starttls',
@ -35,9 +40,11 @@ export default function SettingsPage() {
}) })
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [testingAzure, setTestingAzure] = useState(false) const [testingOidc, setTestingOidc] = useState(false)
const [discoveringOidc, setDiscoveringOidc] = useState(false)
const [testingSmtp, setTestingSmtp] = useState(false) const [testingSmtp, setTestingSmtp] = useState(false)
const [azureSsoTestResult, setAzureSsoTestResult] = useState<{ success: boolean; message: string } | null>(null) const [oidcTestResult, setOidcTestResult] = useState<{ success: boolean; message: string } | null>(null)
const [discoveryResult, setDiscoveryResult] = useState<OidcDiscoveryResult | null>(null)
const [smtpTestResult, setSmtpTestResult] = useState<{ success: boolean; message: string } | null>(null) const [smtpTestResult, setSmtpTestResult] = useState<{ success: boolean; message: string } | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null) const [success, setSuccess] = useState<string | null>(null)
@ -47,7 +54,7 @@ export default function SettingsPage() {
try { try {
setLoading(true) setLoading(true)
const { data } = await settingsApi.get() const { data } = await settingsApi.get()
setAzureSso({ ...data.azure_sso, client_secret: '' }) setOidc({ ...data.oidc, client_secret: '' })
setSmtp({ ...data.smtp, password: '' }) setSmtp({ ...data.smtp, password: '' })
setPolling(data.polling) setPolling(data.polling)
setIpWhitelist(data.ip_whitelist) setIpWhitelist(data.ip_whitelist)
@ -62,20 +69,80 @@ export default function SettingsPage() {
useEffect(() => { loadSettings() }, [loadSettings]) useEffect(() => { loadSettings() }, [loadSettings])
const handleSave = async () => { const handleProviderTypeChange = (providerType: string) => {
setSaving(true) let discoveryUrl = oidc.discovery_url
setError(null) let displayName = oidc.display_name
setSuccess(null)
if (providerType === 'keycloak') {
discoveryUrl = KEYCLOAK_DISCOVERY_URL
displayName = 'Keycloak'
} else if (providerType === 'azure') {
// Clear discovery URL for Azure — user must enter tenant ID pattern
discoveryUrl = ''
displayName = 'Azure AD'
} else {
// Custom — leave discovery URL as-is for user to enter
displayName = 'OIDC Provider'
}
setOidc({ ...oidc, provider_type: providerType as OidcConfigResponse['provider_type'], display_name: displayName, discovery_url: discoveryUrl })
}
const handleDiscoverOidc = async () => {
if (!oidc.discovery_url) return
setDiscoveringOidc(true)
setDiscoveryResult(null)
try { try {
const { data } = await settingsApi.discoverOidc(oidc.discovery_url)
setDiscoveryResult(data)
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Discovery failed'
setDiscoveryResult({ success: false, issuer: '', authorization_endpoint: '', token_endpoint: '', jwks_uri: '', message: msg })
} finally {
setDiscoveringOidc(false)
}
}
const handleTestOidc = async () => {
setTestingOidc(true)
setOidcTestResult(null)
try {
// Save settings first so the test uses current form values
await settingsApi.update({ await settingsApi.update({
azure_sso: { ...azureSso }, oidc: { ...oidc },
smtp: { ...smtp }, smtp: { ...smtp },
polling, polling,
ip_whitelist: ipWhitelist, ip_whitelist: ipWhitelist,
web_tls_strategy: webTlsStrategy, web_tls_strategy: webTlsStrategy,
notification: { notification: {
...notification, ...notification,
email_from: smtp.from, // Use SMTP From Address as notification sender email_from: smtp.from,
},
})
const { data } = await settingsApi.testOidc()
setOidcTestResult(data)
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Test failed'
setOidcTestResult({ success: false, message: msg })
} finally {
setTestingOidc(false)
}
}
const handleSave = async () => {
setSaving(true)
setError(null)
setSuccess(null)
try {
await settingsApi.update({
oidc: { ...oidc },
smtp: { ...smtp },
polling,
ip_whitelist: ipWhitelist,
web_tls_strategy: webTlsStrategy,
notification: {
...notification,
email_from: smtp.from,
}, },
}) })
setSuccess('Settings saved successfully') setSuccess('Settings saved successfully')
@ -90,37 +157,21 @@ export default function SettingsPage() {
} }
} }
const handleTestAzureSso = async () => {
setTestingAzure(true)
setAzureSsoTestResult(null)
try {
const { data } = await settingsApi.testAzureSso()
setAzureSsoTestResult(data)
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Test failed'
setAzureSsoTestResult({ success: false, message: msg })
} finally {
setTestingAzure(false)
}
}
const handleTestSmtp = async () => { const handleTestSmtp = async () => {
setTestingSmtp(true) setTestingSmtp(true)
setSmtpTestResult(null) setSmtpTestResult(null)
try { try {
// Save settings first so the test uses current form values
await settingsApi.update({ await settingsApi.update({
azure_sso: { ...azureSso }, oidc: { ...oidc },
smtp: { ...smtp }, smtp: { ...smtp },
polling, polling,
ip_whitelist: ipWhitelist, ip_whitelist: ipWhitelist,
web_tls_strategy: webTlsStrategy, web_tls_strategy: webTlsStrategy,
notification: { notification: {
...notification, ...notification,
email_from: smtp.from, // Use SMTP From Address as notification sender email_from: smtp.from,
}, },
}) })
// Then test SMTP
const { data } = await settingsApi.testSmtp() const { data } = await settingsApi.testSmtp()
setSmtpTestResult(data) setSmtpTestResult(data)
} catch (err: unknown) { } catch (err: unknown) {
@ -158,40 +209,124 @@ export default function SettingsPage() {
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>} {error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
{/* Section 1: Azure SSO Configuration */} {/* Section 1: OIDC Provider Configuration */}
<Accordion defaultExpanded> <Accordion defaultExpanded>
<AccordionSummary expandIcon={<ExpandMoreIcon />}> <AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography fontWeight={600}>Azure SSO Configuration</Typography> <Typography fontWeight={600}>OIDC Provider Configuration</Typography>
</AccordionSummary> </AccordionSummary>
<AccordionDetails> <AccordionDetails>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid size={12}> <Grid size={12}>
<FormControlLabel <FormControlLabel
control={<Switch checked={azureSso.enabled} onChange={(e) => setAzureSso({ ...azureSso, enabled: e.target.checked })} />} control={<Switch checked={oidc.enabled} onChange={(e) => setOidc({ ...oidc, enabled: e.target.checked })} />}
label="Enable Azure SSO" label="Enable SSO / OIDC Authentication"
/>
</Grid>
<Grid size={4}>
<FormControl fullWidth>
<InputLabel>Provider Type</InputLabel>
<Select
value={oidc.provider_type}
label="Provider Type"
onChange={(e) => handleProviderTypeChange(e.target.value)}
disabled={!oidc.enabled}
>
<MenuItem value="keycloak">Keycloak</MenuItem>
<MenuItem value="azure">Azure AD</MenuItem>
<MenuItem value="custom">Custom OIDC</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid size={4}>
<TextField
fullWidth
label="Display Name"
value={oidc.display_name}
onChange={(e) => setOidc({ ...oidc, display_name: e.target.value })}
helperText="Shown on the login button"
disabled={!oidc.enabled}
/>
</Grid>
<Grid size={12}>
<TextField
fullWidth
label="Discovery URL"
value={oidc.discovery_url}
onChange={(e) => setOidc({ ...oidc, discovery_url: e.target.value })}
placeholder={oidc.provider_type === 'azure' ? 'https://login.microsoftonline.com/<tenant_id>/v2.0/.well-known/openid-configuration' : 'https://sso.example.com/.well-known/openid-configuration'}
helperText={oidc.provider_type === 'keycloak' ? 'Auto-filled for Keycloak' : 'OIDC well-known endpoint URL'}
disabled={!oidc.enabled}
/> />
</Grid> </Grid>
<Grid size={6}> <Grid size={6}>
<TextField fullWidth label="Tenant ID" value={azureSso.tenant_id} onChange={(e) => setAzureSso({ ...azureSso, tenant_id: e.target.value })} /> <Button
variant="outlined"
onClick={handleDiscoverOidc}
disabled={discoveringOidc || !oidc.discovery_url}
startIcon={discoveringOidc ? <CircularProgress size={20} /> : <ExploreIcon />}
>
Discover Endpoints
</Button>
{discoveryResult && (
<Alert severity={discoveryResult.success ? 'success' : 'error'} sx={{ mt: 1 }}>
{discoveryResult.success
? `Discovered: ${discoveryResult.issuer}`
: discoveryResult.message || 'Discovery failed'}
</Alert>
)}
</Grid> </Grid>
<Grid size={6}> <Grid size={6}>
<TextField fullWidth label="Client ID" value={azureSso.client_id} onChange={(e) => setAzureSso({ ...azureSso, client_id: e.target.value })} /> <TextField
fullWidth
label="Client ID"
value={oidc.client_id}
onChange={(e) => setOidc({ ...oidc, client_id: e.target.value })}
required
disabled={!oidc.enabled}
/>
</Grid> </Grid>
<Grid size={6}> <Grid size={6}>
<TextField fullWidth label="Client Secret" type="password" value={azureSso.client_secret ?? ''} onChange={(e) => setAzureSso({ ...azureSso, client_secret: e.target.value })} placeholder="Enter new secret or leave masked" /> <TextField
fullWidth
label="Client Secret"
type="password"
value={oidc.client_secret ?? ''}
onChange={(e) => setOidc({ ...oidc, client_secret: e.target.value })}
placeholder="Enter new secret or leave masked"
helperText="Leave empty for public clients (e.g. Keycloak)"
disabled={!oidc.enabled}
/>
</Grid> </Grid>
<Grid size={6}> <Grid size={6}>
<TextField fullWidth label="Redirect URI" value={azureSso.redirect_uri} onChange={(e) => setAzureSso({ ...azureSso, redirect_uri: e.target.value })} helperText="e.g. https://patch-manager.example.com/api/v1/auth/azure/callback" /> <TextField
fullWidth
label="Redirect URI"
value={oidc.redirect_uri}
onChange={(e) => setOidc({ ...oidc, redirect_uri: e.target.value })}
helperText="e.g. https://patch-manager.example.com/api/v1/auth/sso/callback"
disabled={!oidc.enabled}
/>
</Grid> </Grid>
<Grid size={6}> <Grid size={6}>
<TextField fullWidth label="Scopes" value={azureSso.scopes} onChange={(e) => setAzureSso({ ...azureSso, scopes: e.target.value })} /> <TextField
fullWidth
label="Scopes"
value={oidc.scopes}
onChange={(e) => setOidc({ ...oidc, scopes: e.target.value })}
disabled={!oidc.enabled}
/>
</Grid> </Grid>
<Grid size={6}> <Grid size={6}>
<Button variant="outlined" onClick={handleTestAzureSso} disabled={testingAzure || !azureSso.tenant_id} startIcon={testingAzure ? <CircularProgress size={20} /> : <CloudIcon />}> <Button
variant="outlined"
onClick={handleTestOidc}
disabled={testingOidc || !oidc.discovery_url}
startIcon={testingOidc ? <CircularProgress size={20} /> : (oidc.provider_type === 'keycloak' ? <VpnKeyIcon /> : <CloudIcon />)}
>
Test Connection Test Connection
</Button> </Button>
{azureSsoTestResult && ( {oidcTestResult && (
<Alert severity={azureSsoTestResult.success ? 'success' : 'error'} sx={{ mt: 1 }}>{azureSsoTestResult.message}</Alert> <Alert severity={oidcTestResult.success ? 'success' : 'error'} sx={{ mt: 1 }}>{oidcTestResult.message}</Alert>
)} )}
</Grid> </Grid>
</Grid> </Grid>

View File

@ -52,13 +52,15 @@ export default function SsoCallbackPage() {
} }
// Build a full User object from the SSO subset, filling in sensible defaults // Build a full User object from the SSO subset, filling in sensible defaults
// auth_provider comes from the backend based on the OIDC provider type
const authProvider = (parsedUser.auth_provider as string) || 'azure_sso'
const user: User = { const user: User = {
id: (parsedUser.id as string) || '', id: (parsedUser.id as string) || '',
username: (parsedUser.username as string) || '', username: (parsedUser.username as string) || '',
display_name: (parsedUser.display_name as string) || '', display_name: (parsedUser.display_name as string) || '',
email: (parsedUser.email as string) || '', email: (parsedUser.email as string) || '',
role: (parsedUser.role as User['role']) || 'operator', role: (parsedUser.role as User['role']) || 'operator',
auth_provider: 'azure_sso', auth_provider: authProvider as User['auth_provider'],
mfa_enabled: (parsedUser.mfa_enabled as boolean) ?? false, mfa_enabled: (parsedUser.mfa_enabled as boolean) ?? false,
is_active: true, is_active: true,
force_password_reset: false, force_password_reset: false,

View File

@ -1,7 +1,7 @@
// Core TypeScript types — expanded per milestone // Core TypeScript types — expanded per milestone
export type UserRole = 'admin' | 'operator' export type UserRole = 'admin' | 'operator'
export type AuthProvider = 'local' | 'azure_sso' export type AuthProvider = 'local' | 'azure_sso' | 'keycloak' | 'oidc'
export type HostHealthStatus = 'pending' | 'healthy' | 'degraded' | 'unreachable' export type HostHealthStatus = 'pending' | 'healthy' | 'degraded' | 'unreachable'
export type JobStatus = 'queued' | 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled' export type JobStatus = 'queued' | 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled'
export type JobKind = 'patch_apply' | 'patch_remove' | 'reboot' | 'rollback' export type JobKind = 'patch_apply' | 'patch_remove' | 'reboot' | 'rollback'
@ -244,6 +244,7 @@ export type ReportType = 'compliance' | 'patch-history' | 'vulnerability' | 'aud
// ── Settings (M10) ────────────────────────────────────────────────────────── // ── Settings (M10) ──────────────────────────────────────────────────────────
/** @deprecated Use OidcConfigResponse instead */
export interface AzureSsoConfig { export interface AzureSsoConfig {
enabled: boolean enabled: boolean
tenant_id: string tenant_id: string
@ -252,6 +253,27 @@ export interface AzureSsoConfig {
scopes: string scopes: string
} }
export interface OidcConfigResponse {
enabled: boolean
provider_type: 'keycloak' | 'azure' | 'custom'
display_name: string
discovery_url: string
client_id: string
client_secret: string
redirect_uri: string
scopes: string
}
export interface OidcDiscoveryResult {
success: boolean
issuer: string
authorization_endpoint: string
token_endpoint: string
jwks_uri: string
userinfo_endpoint?: string | null
message?: string
}
export interface SmtpConfig { export interface SmtpConfig {
enabled: boolean enabled: boolean
host: string host: string
@ -273,7 +295,7 @@ export interface NotificationConfig {
} }
export interface SettingsResponse { export interface SettingsResponse {
azure_sso: AzureSsoConfig oidc: OidcConfigResponse
smtp: SmtpConfig smtp: SmtpConfig
polling: PollingConfig polling: PollingConfig
ip_whitelist: string[] ip_whitelist: string[]

View File

@ -0,0 +1,59 @@
-- 014_oidc_provider.sql
-- Migrate from Azure AD-specific SSO to generic OIDC provider support
-- Supports Keycloak, Azure AD, and custom OIDC providers
-- Add new auth_provider enum values for Keycloak and generic OIDC
-- Use DO blocks with exception handling for idempotency
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_enum e JOIN pg_type t ON e.enumtypid = t.oid WHERE t.typname = 'auth_provider' AND e.enumlabel = 'keycloak') THEN
ALTER TYPE auth_provider ADD VALUE 'keycloak';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_enum e JOIN pg_type t ON e.enumtypid = t.oid WHERE t.typname = 'auth_provider' AND e.enumlabel = 'oidc') THEN
ALTER TYPE auth_provider ADD VALUE 'oidc';
END IF;
END
$$;
-- Add oidc_sub column for Keycloak/custom OIDC subject IDs
ALTER TABLE users ADD COLUMN IF NOT EXISTS oidc_sub TEXT;
CREATE INDEX IF NOT EXISTS idx_users_oidc_sub ON users (oidc_sub) WHERE oidc_sub IS NOT NULL;
-- Create oidc_config table (replaces azure_sso_config)
CREATE TABLE IF NOT EXISTS oidc_config (
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT FALSE,
provider_type TEXT NOT NULL DEFAULT 'azure' CHECK (provider_type IN ('keycloak', 'azure', 'custom')),
display_name TEXT NOT NULL DEFAULT 'Azure AD',
discovery_url TEXT NOT NULL DEFAULT '',
client_id TEXT NOT NULL DEFAULT '',
-- Empty string for public clients (Keycloak); non-empty for confidential clients (Azure AD)
client_secret TEXT NOT NULL DEFAULT '',
redirect_uri TEXT NOT NULL DEFAULT '',
scopes TEXT NOT NULL DEFAULT 'openid profile email',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Migrate data from azure_sso_config if it has a row and oidc_config is empty
INSERT INTO oidc_config (enabled, provider_type, display_name, discovery_url, client_id, client_secret, redirect_uri, scopes)
SELECT
az.enabled,
'azure',
'Azure AD',
CASE
WHEN az.tenant_id IS NOT NULL AND az.tenant_id != ''
THEN 'https://login.microsoftonline.com/' || az.tenant_id || '/v2.0/.well-known/openid-configuration'
ELSE ''
END,
az.client_id,
az.client_secret,
az.redirect_uri,
az.scopes
FROM azure_sso_config az
WHERE az.id = 1
ON CONFLICT (id) DO NOTHING;
-- Ensure a default row exists if no data was migrated
INSERT INTO oidc_config (enabled, provider_type, display_name)
SELECT FALSE, 'azure', 'Azure AD'
WHERE NOT EXISTS (SELECT 1 FROM oidc_config WHERE id = 1);

View File

@ -22,7 +22,7 @@ warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; } error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VERSION="0.1.4" VERSION="0.1.5"
RELEASE="1" RELEASE="1"
PKG_NAME="linux-patch-manager" PKG_NAME="linux-patch-manager"
DEB_NAME="${PKG_NAME}_${VERSION}-${RELEASE}_amd64.deb" DEB_NAME="${PKG_NAME}_${VERSION}-${RELEASE}_amd64.deb"