From 69d2e88bbde624e47cb0d5b62134c3a11484a700 Mon Sep 17 00:00:00 2001 From: Echo Date: Wed, 13 May 2026 13:32:24 +0000 Subject: [PATCH] feat: OIDC SSO provider support (Keycloak, Azure AD, custom) - 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 --- Cargo.lock | 14 +- Cargo.toml | 2 +- crates/pm-web/src/main.rs | 12 +- crates/pm-web/src/routes/mod.rs | 2 +- crates/pm-web/src/routes/settings.rs | 331 ++++++++----- .../src/routes/{azure_sso.rs => sso.rs} | 440 ++++++++++++------ frontend/package.json | 2 +- frontend/src/api/client.ts | 33 +- frontend/src/pages/LoginPage.tsx | 231 ++------- frontend/src/pages/SettingsPage.tsx | 221 +++++++-- frontend/src/pages/SsoCallbackPage.tsx | 4 +- frontend/src/types/index.ts | 26 +- migrations/014_oidc_provider.sql | 59 +++ scripts/build-package.sh | 2 +- 14 files changed, 883 insertions(+), 496 deletions(-) rename crates/pm-web/src/routes/{azure_sso.rs => sso.rs} (56%) create mode 100644 migrations/014_oidc_provider.sql diff --git a/Cargo.lock b/Cargo.lock index 3971534..d7d4bce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2381,7 +2381,7 @@ dependencies = [ [[package]] name = "pm-agent-client" -version = "0.1.3" +version = "0.1.5" dependencies = [ "anyhow", "chrono", @@ -2398,7 +2398,7 @@ dependencies = [ [[package]] name = "pm-auth" -version = "0.1.3" +version = "0.1.5" dependencies = [ "anyhow", "argon2", @@ -2425,7 +2425,7 @@ dependencies = [ [[package]] name = "pm-ca" -version = "0.1.3" +version = "0.1.5" dependencies = [ "anyhow", "chrono", @@ -2448,7 +2448,7 @@ dependencies = [ [[package]] name = "pm-core" -version = "0.1.3" +version = "0.1.5" dependencies = [ "aes-gcm", "anyhow", @@ -2472,7 +2472,7 @@ dependencies = [ [[package]] name = "pm-reports" -version = "0.1.3" +version = "0.1.5" dependencies = [ "anyhow", "chrono", @@ -2492,7 +2492,7 @@ dependencies = [ [[package]] name = "pm-web" -version = "0.1.3" +version = "0.1.5" dependencies = [ "anyhow", "axum", @@ -2529,7 +2529,7 @@ dependencies = [ [[package]] name = "pm-worker" -version = "0.1.3" +version = "0.1.5" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index d6b9039..b8379c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ ] [workspace.package] -version = "0.1.4" +version = "0.1.5" edition = "2021" authors = ["Echo "] license = "MIT" diff --git a/crates/pm-web/src/main.rs b/crates/pm-web/src/main.rs index 44bd7e0..6370c73 100644 --- a/crates/pm-web/src/main.rs +++ b/crates/pm-web/src/main.rs @@ -10,7 +10,7 @@ use pm_auth::{ rbac::{require_auth, AuthConfig}, }; 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 serde_json::{json, Value}; use std::{net::SocketAddr, sync::Arc, time::Duration}; @@ -31,8 +31,8 @@ pub struct AppState { pub ws_tickets: Arc>, /// In-memory store for SSO PKCE sessions (state → code_verifier). pub sso_sessions: Arc>, - /// Cached Azure AD JWKS for id_token signature verification. - pub jwks_cache: Arc>, + /// Cached OIDC discovery document and JWKS for SSO id_token verification. + pub oidc_cache: Arc>, /// Internal certificate authority for mTLS client cert issuance. pub ca: Arc, } @@ -90,7 +90,7 @@ async fn main() -> anyhow::Result<()> { let ws_tickets: Arc> = Arc::new(DashMap::new()); let sso_sessions: Arc> = Arc::new(DashMap::new()); - let jwks_cache: Arc> = Arc::new(Mutex::new(JwksCache::default())); + let oidc_cache: Arc> = Arc::new(Mutex::new(OidcCache::default())); // Background task: purge expired WS tickets every 30 seconds. { @@ -137,7 +137,7 @@ async fn main() -> anyhow::Result<()> { ws_tickets, sso_sessions, ca: Arc::new(ca), - jwks_cache, + oidc_cache, }; let app = build_router(state); @@ -234,7 +234,7 @@ pub fn build_router(state: AppState) -> Router { // Public auth routes (no JWT needed) .nest("/api/v1/auth", routes::auth::public_router()) // 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) .nest("/api/v1", protected_api) // WebSocket browser endpoint — ticket-authenticated, outside JWT middleware diff --git a/crates/pm-web/src/routes/mod.rs b/crates/pm-web/src/routes/mod.rs index 07c1f50..d99e465 100644 --- a/crates/pm-web/src/routes/mod.rs +++ b/crates/pm-web/src/routes/mod.rs @@ -1,6 +1,5 @@ //! Route modules for the pm-web API. pub mod auth; -pub mod azure_sso; pub mod ca; pub mod discovery; pub mod groups; @@ -9,6 +8,7 @@ pub mod hosts; pub mod jobs; pub mod maintenance_windows; pub mod settings; +pub mod sso; pub mod status; pub mod users; pub mod ws; diff --git a/crates/pm-web/src/routes/settings.rs b/crates/pm-web/src/routes/settings.rs index dae2c9a..09b3aa7 100644 --- a/crates/pm-web/src/routes/settings.rs +++ b/crates/pm-web/src/routes/settings.rs @@ -2,7 +2,9 @@ //! //! GET /api/v1/settings — get all 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) //! GET /api/v1/settings/ip-whitelist — get 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)] pub struct SettingsResponse { - pub azure_sso: AzureSsoConfig, + pub oidc: OidcConfigResponse, pub smtp: SmtpConfig, pub polling: PollingConfig, pub ip_whitelist: Vec, @@ -44,10 +46,13 @@ pub struct SettingsResponse { } #[derive(Debug, Serialize, Deserialize)] -pub struct AzureSsoConfig { +pub struct OidcConfigResponse { 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_secret: String, // Always masked in responses pub redirect_uri: String, pub scopes: String, } @@ -70,7 +75,7 @@ pub struct PollingConfig { #[derive(Debug, Deserialize)] pub struct UpdateSettingsRequest { - pub azure_sso: Option, + pub oidc: Option, pub smtp: Option, pub polling: Option, pub ip_whitelist: Option>, @@ -93,15 +98,31 @@ pub struct NotificationConfigUpdate { } #[derive(Debug, Deserialize)] -pub struct AzureSsoConfigUpdate { +pub struct OidcConfigUpdate { pub enabled: Option, - pub tenant_id: Option, + pub provider_type: Option, + pub display_name: Option, + pub discovery_url: Option, pub client_id: Option, pub client_secret: Option, pub redirect_uri: Option, pub scopes: Option, } +#[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, +} + #[derive(Debug, Deserialize)] pub struct SmtpConfigUpdate { pub enabled: Option, @@ -131,7 +152,9 @@ pub struct IpWhitelistUpdate { pub fn router() -> Router { Router::new() .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( "/ip-whitelist", @@ -175,7 +198,7 @@ async fn load_system_config( fn build_settings_response( cfg: &HashMap, - azure: AzureSsoConfig, + oidc: OidcConfigResponse, ) -> SettingsResponse { 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(); SettingsResponse { - azure_sso: azure, + oidc, smtp: SmtpConfig { enabled: get("smtp_enabled") == "true", host: get("smtp_host"), @@ -227,16 +250,16 @@ async fn update_config_key( Ok(()) } -async fn fetch_azure_sso_config( +async fn fetch_oidc_config( pool: &sqlx::PgPool, -) -> Result)> { - 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", +) -> Result)> { + let row: Option<(bool, String, String, String, String, String, String, String)> = 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 azure_sso_config"); + tracing::error!(error = %e, "Failed to load oidc_config"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })), @@ -244,19 +267,38 @@ async fn fetch_azure_sso_config( })?; Ok(match row { - Some((enabled, tenant_id, client_id, redirect_uri, scopes)) => AzureSsoConfig { + Some(( enabled, - tenant_id, + provider_type, + display_name, + discovery_url, 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, scopes, }, - None => AzureSsoConfig { + None => OidcConfigResponse { 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_secret: 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(), state.config.security.sso_callback_url.clone(), ); - let azure = fetch_azure_sso_config(&state.db).await?; - Ok(Json(build_settings_response(&cfg, azure))) + let oidc = fetch_oidc_config(&state.db).await?; + Ok(Json(build_settings_response(&cfg, oidc))) } // ============================================================ @@ -292,56 +334,66 @@ async fn update_settings( ) -> Result, (StatusCode, Json)> { admin_only(&auth)?; - // Update Azure SSO config - // Use static queries with proper typed bindings to avoid boolean→string mismatch - if let Some(azure) = req.azure_sso { - let update_secret = azure.client_secret.as_ref().is_some_and(|s| s != MASKED); + // Update OIDC config + if let Some(oidc) = req.oidc { + let update_secret = oidc + .client_secret + .as_ref() + .is_some_and(|s| s != MASKED && !s.is_empty()); let result = if update_secret { sqlx::query( - "UPDATE azure_sso_config SET \ + "UPDATE oidc_config SET \ enabled = COALESCE($1, enabled), \ - tenant_id = COALESCE($2, tenant_id), \ - client_id = COALESCE($3, client_id), \ - client_secret = $4, \ - redirect_uri = COALESCE($5, redirect_uri), \ - scopes = COALESCE($6, scopes), \ + provider_type = COALESCE($2, provider_type), \ + display_name = COALESCE($3, display_name), \ + discovery_url = COALESCE($4, discovery_url), \ + client_id = COALESCE($5, client_id), \ + client_secret = $6, \ + redirect_uri = COALESCE($7, redirect_uri), \ + scopes = COALESCE($8, scopes), \ updated_at = NOW() \ WHERE id = 1", ) - .bind(azure.enabled) - .bind(&azure.tenant_id) - .bind(&azure.client_id) - .bind(azure.client_secret.as_deref().unwrap_or("")) - .bind(&azure.redirect_uri) - .bind(&azure.scopes) + .bind(oidc.enabled) + .bind(&oidc.provider_type) + .bind(&oidc.display_name) + .bind(&oidc.discovery_url) + .bind(&oidc.client_id) + .bind(oidc.client_secret.as_deref().unwrap_or("")) + .bind(&oidc.redirect_uri) + .bind(&oidc.scopes) .execute(&state.db) .await } else { sqlx::query( - "UPDATE azure_sso_config SET \ + "UPDATE oidc_config SET \ enabled = COALESCE($1, enabled), \ - tenant_id = COALESCE($2, tenant_id), \ - client_id = COALESCE($3, client_id), \ - redirect_uri = COALESCE($4, redirect_uri), \ - scopes = COALESCE($5, scopes), \ + provider_type = COALESCE($2, provider_type), \ + display_name = COALESCE($3, display_name), \ + discovery_url = COALESCE($4, discovery_url), \ + client_id = COALESCE($5, client_id), \ + redirect_uri = COALESCE($6, redirect_uri), \ + scopes = COALESCE($7, scopes), \ updated_at = NOW() \ WHERE id = 1", ) - .bind(azure.enabled) - .bind(&azure.tenant_id) - .bind(&azure.client_id) - .bind(&azure.redirect_uri) - .bind(&azure.scopes) + .bind(oidc.enabled) + .bind(&oidc.provider_type) + .bind(&oidc.display_name) + .bind(&oidc.discovery_url) + .bind(&oidc.client_id) + .bind(&oidc.redirect_uri) + .bind(&oidc.scopes) .execute(&state.db) .await }; 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, - 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, Some(auth.user_id), Some(&auth.username), - Some("azure_sso"), + Some("oidc"), Some("1"), - json!({ "section": "azure_sso" }), + json!({ "section": "oidc" }), None, None, ) @@ -497,55 +549,30 @@ async fn update_settings( "sso_callback_url".to_string(), state.config.security.sso_callback_url.clone(), ); - let azure = fetch_azure_sso_config(&state.db).await?; - Ok(Json(build_settings_response(&cfg, azure))) + let oidc = fetch_oidc_config(&state.db).await?; + 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, auth: AuthUser, + Json(req): Json, ) -> Result, (StatusCode, Json)> { admin_only(&auth)?; - let row: Option<(String, String)> = sqlx::query_as( - "SELECT tenant_id, client_id 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 (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" - }))); + if req.discovery_url.is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + Json( + json!({ "error": { "code": "bad_request", "message": "discovery_url is required" } }), + ), + )); } - let url = format!( - "https://login.microsoftonline.com/{}/v2.0/.well-known/openid-configuration", - tenant_id - ); - let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .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, + auth: AuthUser, +) -> Result, (StatusCode, Json)> { + 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() => { let body: Value = resp.json().await.unwrap_or(json!({})); let issuer = body.get("issuer").and_then(|v| v.as_str()).unwrap_or(""); - if issuer.contains(&tenant_id) { - Ok(Json(json!({ - "success": true, - "message": "Azure AD tenant verified successfully", - "issuer": issuer - }))) - } else { - Ok(Json(json!({ - "success": true, - "message": "Azure AD endpoint reached, but issuer does not match tenant_id", - "issuer": issuer - }))) - } + let provider_label = match provider_type.as_str() { + "keycloak" => "Keycloak", + "azure" => "Azure AD", + _ => "OIDC", + }; + Ok(Json(json!({ + "success": true, + "message": format!("{} provider verified successfully", provider_label), + "issuer": issuer, + "provider_type": provider_type, + }))) }, Ok(resp) => Ok(Json(json!({ "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!({ "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, + auth: AuthUser, +) -> Result, (StatusCode, Json)> { + test_oidc(state, auth).await +} + // ============================================================ // POST /api/v1/settings/smtp/test // ============================================================ diff --git a/crates/pm-web/src/routes/azure_sso.rs b/crates/pm-web/src/routes/sso.rs similarity index 56% rename from crates/pm-web/src/routes/azure_sso.rs rename to crates/pm-web/src/routes/sso.rs index 92a63c6..4eef122 100644 --- a/crates/pm-web/src/routes/azure_sso.rs +++ b/crates/pm-web/src/routes/sso.rs @@ -1,8 +1,12 @@ -//! Azure SSO OAuth2/OIDC flow routes. +//! Generic OIDC SSO routes (Keycloak, Azure AD, Custom). //! //! Public routes (no auth required): -//! GET /api/v1/auth/azure/login — redirect to Azure AD authorization URL -//! GET /api/v1/auth/azure/callback — handle Azure AD callback, redirect to frontend SPA +//! GET /api/v1/auth/sso/login — redirect to OIDC provider authorization URL +//! 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::{ extract::State, @@ -51,6 +55,7 @@ struct TokenResponse { struct IdTokenClaims { email: Option, name: Option, + sub: Option, oid: Option, preferred_username: Option, } @@ -65,23 +70,51 @@ struct DbUserForSso { mfa_enabled: bool, } -/// Cache for Azure AD JWKS (JSON Web Key Set) with TTL-based refresh. -pub struct JwksCache { - pub keys: Option, - pub fetched_at: Option>, +/// OIDC provider configuration from database. +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct OidcConfig { + 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, + pub fetched_at: chrono::DateTime, +} + +/// Cache for OIDC discovery documents and JWKS with TTL-based refresh. +pub struct OidcCache { + pub discovery: Option, + pub jwks: Option, + pub jwks_fetched_at: Option>, +} + +impl Default for OidcCache { fn default() -> Self { Self { - keys: None, - fetched_at: None, + discovery: None, + jwks: None, + jwks_fetched_at: None, } } } /// JWKS cache TTL in seconds (1 hour). const JWKS_CACHE_TTL_SECS: i64 = 3600; +/// Discovery cache TTL in seconds (1 hour). +const DISCOVERY_CACHE_TTL_SECS: i64 = 3600; // ============================================================ // Router @@ -89,52 +122,55 @@ const JWKS_CACHE_TTL_SECS: i64 = 3600; pub fn public_router() -> Router { Router::new() - .route("/login", get(azure_login)) - .route("/callback", get(azure_callback)) + .route("/login", get(sso_login)) + .route("/callback", get(sso_callback)) +} + +/// Backward-compatible Azure SSO routes — redirect to generic SSO endpoints. +pub fn azure_compat_router() -> Router { + 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, ) -> Result)> { - // Read Azure SSO config from DB - 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 config = load_oidc_config(&state.db).await?; - let (enabled, tenant_id, client_id, redirect_uri, scopes) = match row { - Some(r) => r, - None => { + if !config.enabled { + return Err(( + StatusCode::FORBIDDEN, + Json(json!({ "error": { "code": "forbidden", "message": "SSO is not enabled" } })), + )); + } + + if config.discovery_url.is_empty() { + return Err(( + StatusCode::FORBIDDEN, + Json( + 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::FORBIDDEN, + StatusCode::INTERNAL_SERVER_ERROR, Json( - json!({ "error": { "code": "forbidden", "message": "Azure SSO is not configured" } }), + 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) let mut verifier_bytes = [0u8; 32]; rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut verifier_bytes); @@ -156,19 +192,23 @@ async fn azure_login( }, ); - // Build authorization URL - let encoded_scopes = urlencoding::encode(&scopes); + // Build authorization URL from discovery + let encoded_scopes = urlencoding::encode(&config.scopes); 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={}", - tenant_id, client_id, redirect_uri, encoded_scopes, code_challenge, state_token + "{}?client_id={}&response_type=code&redirect_uri={}&scope={}&code_challenge={}&code_challenge_method=S256&state={}", + 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)) } // ============================================================ -// GET /api/v1/auth/azure/callback +// GET /api/v1/auth/sso/callback // ============================================================ #[derive(Debug, Deserialize)] @@ -179,13 +219,12 @@ struct CallbackParams { error_description: Option, } -async fn azure_callback( +async fn sso_callback( State(state): State, axum::extract::Query(params): axum::extract::Query, ) -> Result { let callback_url = &state.config.security.sso_callback_url; - // Helper to build error redirect let error_redirect = |code: &str, message: &str| -> Redirect { let url = format!( "{}?error={}&error_description={}", @@ -196,10 +235,9 @@ async fn azure_callback( Redirect::to(&url) }; - // Check for error from Azure AD if let Some(error) = params.error { 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)); } @@ -213,7 +251,6 @@ async fn azure_callback( 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) { Some(s) => s, None => { @@ -224,33 +261,28 @@ async fn azure_callback( }, }; - // Read Azure SSO config (including client_secret for token exchange) - let row: Option<(bool, String, String, String, String)> = match sqlx::query_as( - "SELECT enabled, tenant_id, client_id, client_secret, redirect_uri FROM azure_sso_config WHERE id = 1", - ) - .fetch_optional(&state.db) - .await - { - Ok(r) => r, - Err(e) => { - tracing::error!(error = %e, "Failed to load azure_sso_config"); - return Err(error_redirect("internal_error", "Database error")); - } + let config = match load_oidc_config(&state.db).await { + Ok(c) => c, + Err(_) => { + return Err(error_redirect( + "internal_error", + "Failed to load OIDC config", + )) + }, }; - let (_enabled, tenant_id, client_id, client_secret, redirect_uri) = match row { - Some(r) => r, - None => { - return Err(error_redirect("internal_error", "Azure SSO not configured")); + let discovery = match fetch_discovery(&state).await { + Ok(d) => d, + Err(e) => { + tracing::error!(error = %e, "Failed to fetch OIDC discovery"); + return Err(error_redirect( + "internal_error", + "Failed to fetch OIDC discovery", + )); }, }; // Exchange code for tokens - let token_url = format!( - "https://login.microsoftonline.com/{}/oauth2/v2.0/token", - tenant_id - ); - let client = match reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .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()), ("code", code.clone()), - ("redirect_uri", redirect_uri.clone()), - ("client_id", client_id.clone()), - ("client_secret", client_secret.clone()), + ("redirect_uri", config.redirect_uri.clone()), + ("client_id", config.client_id.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(¶ms_vec) + .send() + .await + { Ok(r) => r, Err(e) => { 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 { Some(t) => t, 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, Err(e) => { 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 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()); - 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( "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 = match sqlx::query_as( 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(auth_provider) .fetch_optional(&state.db) .await { @@ -356,16 +409,17 @@ async fn azure_callback( }, Some(u) => u, None => { - // Auto-create user with role=operator, auth_provider=azure_sso let id: Uuid = match sqlx::query_scalar( - r#"INSERT INTO users (username, display_name, email, role, auth_provider, azure_oid) - VALUES ($1, $2, $3, 'operator', 'azure_sso', $4) + r#"INSERT INTO users (username, display_name, email, role, auth_provider, azure_oid, oidc_sub) + VALUES ($1, $2, $3, 'operator', $4, $5, $6) RETURNING id"#, ) .bind(&preferred_username) .bind(&name) .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) .await { @@ -380,10 +434,10 @@ async fn azure_callback( &state.db, AuditAction::UserCreated, None, - Some("azure_sso"), + Some(auth_provider), Some("user"), Some(&id.to_string()), - json!({ "auth_provider": "azure_sso", "email": email }), + json!({ "auth_provider": auth_provider, "email": email }), 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( - "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) .execute(&state.db) .await @@ -413,7 +468,6 @@ async fn azure_callback( 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_token = match issue_access_token( user.id, @@ -447,22 +501,21 @@ async fn azure_callback( Some(&user.username), None, None, - json!({ "auth_provider": "azure_sso" }), + json!({ "auth_provider": auth_provider }), None, None, ) .await; - // Build user JSON for query parameter let user_json = json!({ "id": user.id.to_string(), "username": user.username, "display_name": user.display_name, "role": user.role, + "auth_provider": auth_provider, "mfa_enabled": user.mfa_enabled, }); - // Redirect to frontend SPA with tokens as query parameters let redirect_url = format!( "{}?access_token={}&refresh_token={}&token_type=Bearer&expires_in={}&user={}", 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. -/// -/// Steps: -/// 1. Decode JWT header to extract `kid` (key ID) -/// 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`) -/// 5. Validate issuer, audience, and expiry via `jsonwebtoken::decode` +async fn azure_login_redirect( + State(state): State, +) -> Result)> { + sso_login(State(state)).await +} + +async fn azure_callback_redirect( + State(state): State, + axum::extract::Query(params): axum::extract::Query, +) -> Result { + sso_callback(State(state), axum::extract::Query(params)).await +} + +// ============================================================ +// Database helpers +// ============================================================ + +async fn load_oidc_config(pool: &sqlx::PgPool) -> Result)> { + let row: Option = 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 { + 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( token: &str, - tenant_id: &str, - client_id: &str, - jwks_cache: &Arc>, + config: &OidcConfig, + discovery: &OidcDiscovery, + oidc_cache: &Arc>, ) -> Result { - // 1. Decode JWT header to get the kid 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")?; - // 2. Check JWKS cache — fetch if expired or missing let jwks = { - let cache = jwks_cache.lock().await; - let needs_fetch = match (&cache.keys, &cache.fetched_at) { + let cache = oidc_cache.lock().await; + let needs_fetch = match (&cache.jwks, &cache.jwks_fetched_at) { (None, _) => true, (Some(_), None) => true, (Some(_), Some(fetched)) => { @@ -511,21 +681,17 @@ async fn verify_id_token( }; if needs_fetch { - // Drop lock before making async HTTP request drop(cache); - - let jwks_value = fetch_jwks(tenant_id).await?; - - let mut cache = jwks_cache.lock().await; - cache.keys = Some(jwks_value); - cache.fetched_at = Some(Utc::now()); - cache.keys.clone().unwrap() + let jwks_value = fetch_jwks(&discovery.jwks_uri).await?; + let mut cache = oidc_cache.lock().await; + cache.jwks = Some(jwks_value); + cache.jwks_fetched_at = Some(Utc::now()); + cache.jwks.clone().unwrap() } else { - cache.keys.clone().unwrap() + cache.jwks.clone().unwrap() } }; - // 3. Find the matching JWK by kid let keys_array = jwks .get("keys") .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())) .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 .get("n") .and_then(|v| v.as_str()) @@ -550,36 +715,25 @@ async fn verify_id_token( let decoding_key = DecodingKey::from_rsa_components(n, e) .map_err(|e| format!("Failed to construct RSA decoding key: {}", e))?; - // 5. Configure validation rules let mut validation = Validation::new(Algorithm::RS256); - validation.iss = Some(HashSet::from([format!( - "https://login.microsoftonline.com/{}/v2.0", - tenant_id - )])); - validation.aud = Some(HashSet::from([client_id.to_string()])); - validation.leeway = 60; // 60 seconds clock skew tolerance + validation.iss = Some(HashSet::from([discovery.issuer.clone()])); + validation.aud = Some(HashSet::from([config.client_id.clone()])); + validation.leeway = 60; - // 6. Decode and verify the JWT let token_data = decode::(token, &decoding_key, &validation) .map_err(|e| format!("JWT signature verification failed: {}", e))?; Ok(token_data.claims) } -/// Fetch the JWKS from the Azure AD discovery endpoint. -async fn fetch_jwks(tenant_id: &str) -> Result { - let jwks_url = format!( - "https://login.microsoftonline.com/{}/discovery/v2.0/keys", - tenant_id - ); - +async fn fetch_jwks(jwks_uri: &str) -> Result { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .build() .map_err(|e| format!("Failed to build HTTP client for JWKS fetch: {}", e))?; let resp = client - .get(&jwks_url) + .get(jwks_uri) .send() .await .map_err(|e| format!("JWKS fetch request failed: {}", e))?; diff --git a/frontend/package.json b/frontend/package.json index 4bfe802..5276cd2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "patch-manager-ui", "private": true, - "version": "0.1.4", + "version": "0.1.5", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 435c2f5..4cdd09c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -212,6 +212,8 @@ export const reportsApi = { }), } // ── Settings API (M10) ──────────────────────────────────────────────────── + +/** @deprecated Use OidcConfigResponse instead */ export interface AzureSsoConfig { enabled: boolean tenant_id: string @@ -220,6 +222,27 @@ export interface AzureSsoConfig { 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 { enabled: boolean host: string @@ -241,12 +264,13 @@ export interface NotificationConfig { } export interface SettingsResponse { - azure_sso: AzureSsoConfig + oidc: OidcConfigResponse smtp: SmtpConfig polling: PollingConfig ip_whitelist: string[] web_tls_strategy: string notification: NotificationConfig + sso_callback_url?: string } export interface TestResult { @@ -267,11 +291,14 @@ export interface AuditIntegrityResult { export const settingsApi = { get: () => apiClient.get('/settings'), update: (data: Partial & { - azure_sso?: AzureSsoConfig & { client_secret?: string } + oidc?: OidcConfigResponse & { client_secret?: string } smtp?: SmtpConfig & { password?: string } notification?: NotificationConfig }) => apiClient.put('/settings', data), - testAzureSso: () => apiClient.post('/settings/azure-sso/test'), + discoverOidc: (discoveryUrl: string) => apiClient.post('/settings/sso/discover', { discovery_url: discoveryUrl }), + testOidc: () => apiClient.post('/settings/sso/test'), + /** @deprecated Use testOidc instead */ + testAzureSso: () => apiClient.post('/settings/sso/test'), testSmtp: () => apiClient.post('/settings/smtp/test'), getIpWhitelist: () => apiClient.get<{ entries: string[] }>('/settings/ip-whitelist'), updateIpWhitelist: (entries: string[]) => apiClient.put<{ entries: string[] }>('/settings/ip-whitelist', { entries }), diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 2183037..00afbed 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React, { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' import { Box, Button, Container, TextField, Typography, @@ -9,70 +9,32 @@ import { import { Visibility, VisibilityOff, Check as CheckIcon, Close as CloseIcon, - Cloud as CloudIcon, + Cloud as CloudIcon, VpnKey as KeyIcon, } from '@mui/icons-material' -import { authApi } from '../api/client' +import { authApi, settingsApi } from '../api/client' import { useAuthStore } from '../store/authStore' import type { User } from '../types' -/** Extract a human-readable error message from an Axios error. */ function getErrorMessage(err: unknown): string { - // Network error — no response at all (server unreachable, CORS, DNS failure) if (err instanceof Error && err.message === 'Network Error') { 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 status = axiosErr.response?.status const code = axiosErr.response?.data?.error?.code const msg = axiosErr.response?.data?.error?.message - - // Rate limited - if (status === 429) { - return 'Too many login attempts. Please wait a moment and try again.' - } - - // MFA required - if (code === 'mfa_required') { - return 'MFA_REQUIRED' // sentinel — caller checks this - } - - // 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.' - } - + if (status === 429) return 'Too many login attempts. Please wait a moment and try again.' + if (code === 'mfa_required') return 'MFA_REQUIRED' + if (code === 'password_reset_required') return 'PASSWORD_RESET_REQUIRED' + if (code === 'account_locked') return 'ACCOUNT_LOCKED' + if (code === 'account_disabled') return 'This account has been disabled. Contact your administrator.' + if (msg) return msg + 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.' } -/** Password strength checker */ function checkPasswordStrength(password: string) { return { length: password.length >= 8, @@ -100,7 +62,9 @@ export default function LoginPage() { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) - // Force change password state + const [ssoEnabled, setSsoEnabled] = useState(false) + const [ssoDisplayName, setSsoDisplayName] = useState('SSO') + const [newPassword, setNewPassword] = useState('') const [confirmNewPassword, setConfirmNewPassword] = useState('') const [showNewPassword, setShowNewPassword] = useState(false) @@ -110,11 +74,17 @@ export default function LoginPage() { const pwValid = isPasswordValid(pwChecks) 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) => { e.preventDefault() setLoading(true) setError(null) - try { const res = await authApi.login(username, password, needsMfa ? totpCode : undefined) const { access_token, refresh_token, user } = res.data @@ -144,7 +114,6 @@ export default function LoginPage() { if (!pwValid || pwMismatch) return setLoading(true) setError(null) - try { await authApi.forceChangePassword(username, password, newPassword) setPasswordChanged(true) @@ -177,6 +146,8 @@ export default function LoginPage() { setConfirmNewPassword('') } + const ssoIcon = ssoDisplayName.toLowerCase().includes('keycloak') ? : + return ( @@ -185,155 +156,51 @@ export default function LoginPage() { {error && ( - setError(null)} - > + setError(null)}> {error} )} {passwordChanged ? ( - - Password changed successfully! Please log in with your new password. - - + Password changed successfully! Please log in with your new password. + ) : forcePasswordReset ? ( - - Change Your Password - - - Your password has expired and must be changed before you can log in. - - - - setNewPassword(e.target.value)} - disabled={loading} required - InputProps={{ - endAdornment: ( - - setShowNewPassword(!showNewPassword)} edge="end"> - {showNewPassword ? : } - - - ), - }} - /> + Change Your Password + Your password has expired and must be changed before you can log in. + + + setNewPassword(e.target.value)} disabled={loading} required InputProps={{ endAdornment: setShowNewPassword(!showNewPassword)} edge="end">{showNewPassword ? : } }} /> {newPassword && ( - - - {pwChecks.length ? : } - - - - - - {pwChecks.uppercase ? : } - - - - - - {pwChecks.lowercase ? : } - - - - - - {pwChecks.digit ? : } - - - - - - {pwChecks.special ? : } - - - + {pwChecks.length ? : } + {pwChecks.uppercase ? : } + {pwChecks.lowercase ? : } + {pwChecks.digit ? : } + {pwChecks.special ? : } )} - setConfirmNewPassword(e.target.value)} - disabled={loading} required - error={pwMismatch} - helperText={pwMismatch ? 'Passwords do not match' : ''} - /> - + setConfirmNewPassword(e.target.value)} disabled={loading} required error={pwMismatch} helperText={pwMismatch ? 'Passwords do not match' : ''} /> + ) : ( - setUsername(e.target.value)} - disabled={loading} required autoFocus - /> - setPassword(e.target.value)} disabled={loading} required - InputProps={{ - endAdornment: ( - - setShowPassword(!showPassword)} edge="end"> - {showPassword ? : } - - - ), - }} - /> + setUsername(e.target.value)} disabled={loading} required autoFocus /> + setPassword(e.target.value)} disabled={loading} required InputProps={{ endAdornment: setShowPassword(!showPassword)} edge="end">{showPassword ? : } }} /> {needsMfa && ( - setTotpCode(e.target.value)} - disabled={loading} required autoFocus - helperText="Enter the 6-digit code from your authenticator app" - /> + setTotpCode(e.target.value)} disabled={loading} required autoFocus helperText="Enter the 6-digit code from your authenticator app" /> + )} + + {ssoEnabled && ( + <> + or + + )} - - or - )} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index cf0660a..935298b 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -12,15 +12,20 @@ import DeleteIcon from '@mui/icons-material/Delete' import AddIcon from '@mui/icons-material/Add' import CloudIcon from '@mui/icons-material/Cloud' 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 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 } +const KEYCLOAK_DISCOVERY_URL = 'https://keycloak.moon-dragon.us/realms/moon-dragon.us/.well-known/openid-configuration' + export default function SettingsPage() { - const [azureSso, setAzureSso] = useState({ - enabled: false, tenant_id: '', client_id: '', client_secret: '', redirect_uri: '', scopes: 'openid email profile', + const [oidc, setOidc] = useState({ + 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({ 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 [testingAzure, setTestingAzure] = useState(false) + const [testingOidc, setTestingOidc] = useState(false) + const [discoveringOidc, setDiscoveringOidc] = 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(null) const [smtpTestResult, setSmtpTestResult] = useState<{ success: boolean; message: string } | null>(null) const [error, setError] = useState(null) const [success, setSuccess] = useState(null) @@ -47,7 +54,7 @@ export default function SettingsPage() { try { setLoading(true) const { data } = await settingsApi.get() - setAzureSso({ ...data.azure_sso, client_secret: '' }) + setOidc({ ...data.oidc, client_secret: '' }) setSmtp({ ...data.smtp, password: '' }) setPolling(data.polling) setIpWhitelist(data.ip_whitelist) @@ -62,20 +69,80 @@ export default function SettingsPage() { useEffect(() => { loadSettings() }, [loadSettings]) - const handleSave = async () => { - setSaving(true) - setError(null) - setSuccess(null) + const handleProviderTypeChange = (providerType: string) => { + let discoveryUrl = oidc.discovery_url + let displayName = oidc.display_name + + 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 { + 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({ - azure_sso: { ...azureSso }, + oidc: { ...oidc }, smtp: { ...smtp }, polling, ip_whitelist: ipWhitelist, web_tls_strategy: webTlsStrategy, 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') @@ -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 () => { setTestingSmtp(true) setSmtpTestResult(null) try { - // Save settings first so the test uses current form values await settingsApi.update({ - azure_sso: { ...azureSso }, + oidc: { ...oidc }, smtp: { ...smtp }, polling, ip_whitelist: ipWhitelist, web_tls_strategy: webTlsStrategy, 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() setSmtpTestResult(data) } catch (err: unknown) { @@ -158,40 +209,124 @@ export default function SettingsPage() { {error && setError(null)}>{error}} - {/* Section 1: Azure SSO Configuration */} + {/* Section 1: OIDC Provider Configuration */} }> - Azure SSO Configuration + OIDC Provider Configuration setAzureSso({ ...azureSso, enabled: e.target.checked })} />} - label="Enable Azure SSO" + control={ setOidc({ ...oidc, enabled: e.target.checked })} />} + label="Enable SSO / OIDC Authentication" + /> + + + + Provider Type + + + + + setOidc({ ...oidc, display_name: e.target.value })} + helperText="Shown on the login button" + disabled={!oidc.enabled} + /> + + + setOidc({ ...oidc, discovery_url: e.target.value })} + placeholder={oidc.provider_type === 'azure' ? 'https://login.microsoftonline.com//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} /> - setAzureSso({ ...azureSso, tenant_id: e.target.value })} /> + + {discoveryResult && ( + + {discoveryResult.success + ? `Discovered: ${discoveryResult.issuer}` + : discoveryResult.message || 'Discovery failed'} + + )} - setAzureSso({ ...azureSso, client_id: e.target.value })} /> + setOidc({ ...oidc, client_id: e.target.value })} + required + disabled={!oidc.enabled} + /> - setAzureSso({ ...azureSso, client_secret: e.target.value })} placeholder="Enter new secret or leave masked" /> + 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} + /> - setAzureSso({ ...azureSso, redirect_uri: e.target.value })} helperText="e.g. https://patch-manager.example.com/api/v1/auth/azure/callback" /> + setOidc({ ...oidc, redirect_uri: e.target.value })} + helperText="e.g. https://patch-manager.example.com/api/v1/auth/sso/callback" + disabled={!oidc.enabled} + /> - setAzureSso({ ...azureSso, scopes: e.target.value })} /> + setOidc({ ...oidc, scopes: e.target.value })} + disabled={!oidc.enabled} + /> - - {azureSsoTestResult && ( - {azureSsoTestResult.message} + {oidcTestResult && ( + {oidcTestResult.message} )} diff --git a/frontend/src/pages/SsoCallbackPage.tsx b/frontend/src/pages/SsoCallbackPage.tsx index 2dabab7..0d55305 100644 --- a/frontend/src/pages/SsoCallbackPage.tsx +++ b/frontend/src/pages/SsoCallbackPage.tsx @@ -52,13 +52,15 @@ export default function SsoCallbackPage() { } // 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 = { id: (parsedUser.id as string) || '', username: (parsedUser.username as string) || '', display_name: (parsedUser.display_name as string) || '', email: (parsedUser.email as string) || '', 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, is_active: true, force_password_reset: false, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7df809d..123fbf0 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,7 +1,7 @@ // Core TypeScript types — expanded per milestone 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 JobStatus = 'queued' | 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled' export type JobKind = 'patch_apply' | 'patch_remove' | 'reboot' | 'rollback' @@ -244,6 +244,7 @@ export type ReportType = 'compliance' | 'patch-history' | 'vulnerability' | 'aud // ── Settings (M10) ────────────────────────────────────────────────────────── +/** @deprecated Use OidcConfigResponse instead */ export interface AzureSsoConfig { enabled: boolean tenant_id: string @@ -252,6 +253,27 @@ export interface AzureSsoConfig { 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 { enabled: boolean host: string @@ -273,7 +295,7 @@ export interface NotificationConfig { } export interface SettingsResponse { - azure_sso: AzureSsoConfig + oidc: OidcConfigResponse smtp: SmtpConfig polling: PollingConfig ip_whitelist: string[] diff --git a/migrations/014_oidc_provider.sql b/migrations/014_oidc_provider.sql new file mode 100644 index 0000000..4b76c28 --- /dev/null +++ b/migrations/014_oidc_provider.sql @@ -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); diff --git a/scripts/build-package.sh b/scripts/build-package.sh index 8eb80ee..3c629a3 100755 --- a/scripts/build-package.sh +++ b/scripts/build-package.sh @@ -22,7 +22,7 @@ warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; } PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -VERSION="0.1.4" +VERSION="0.1.5" RELEASE="1" PKG_NAME="linux-patch-manager" DEB_NAME="${PKG_NAME}_${VERSION}-${RELEASE}_amd64.deb"