From 86a6c714d4c6561a9963eb8c82421926aba2d2ae Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 12 May 2026 17:01:20 +0000 Subject: [PATCH] feat: Complete Azure SSO implementation (v0.1.3) - Add SSO session cleanup task (10-min expiry, 60s purge interval) - Change callback to redirect to frontend with tokens as query params - Add sso_callback_url to SecurityConfig with serde default - Add SsoCallbackPage.tsx for handling SSO callback redirects - Add /auth/sso/callback public route to App.tsx - Add Sign in with Microsoft Azure button to LoginPage - Replace insecure decode_jwt_payload with verify_id_token - Implement JWKS caching (1-hour TTL) and RSA signature verification - Validate iss, aud, exp claims on id_token - Add jsonwebtoken dependency to pm-web crate - Update config.example.toml with sso_callback_url setting - Add sso_callback_url to settings response (read-only from TOML) --- Cargo.lock | 1 + Cargo.toml | 2 +- config/config.example.toml | 5 + crates/pm-core/src/config.rs | 8 + crates/pm-web/Cargo.toml | 1 + crates/pm-web/src/main.rs | 26 +- crates/pm-web/src/routes/azure_sso.rs | 437 +++++++++++++++---------- crates/pm-web/src/routes/settings.rs | 8 + frontend/index.html | 2 +- frontend/package.json | 2 +- frontend/src/App.tsx | 2 + frontend/src/components/AppLayout.tsx | 2 +- frontend/src/pages/LoginPage.tsx | 11 + frontend/src/pages/MfaSetupPage.tsx | 27 +- frontend/src/pages/SsoCallbackPage.tsx | 105 ++++++ frontend/src/pages/UsersPage.tsx | 38 ++- tasks/lessons.md | 31 ++ tasks/todo.md | 92 +++--- 18 files changed, 561 insertions(+), 239 deletions(-) create mode 100644 frontend/src/pages/SsoCallbackPage.tsx diff --git a/Cargo.lock b/Cargo.lock index 5c41032..71667f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2328,6 +2328,7 @@ dependencies = [ "chrono", "dashmap", "ipnet", + "jsonwebtoken", "lettre", "pm-auth", "pm-ca", diff --git a/Cargo.toml b/Cargo.toml index 226b090..441451b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ ] [workspace.package] -version = "0.1.2" +version = "0.1.3" edition = "2021" authors = ["Echo "] license = "MIT" diff --git a/config/config.example.toml b/config/config.example.toml index 450228b..6f30a5f 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -101,3 +101,8 @@ ca_key_path = "/etc/patch-manager/ca/ca.key" # point these paths to your certificate/key to use your own cert. web_tls_cert_path = "/etc/patch-manager/tls/web.crt" web_tls_key_path = "/etc/patch-manager/tls/web.key" + +# Frontend URL to redirect the browser to after Azure SSO callback. +# The backend sends tokens as query parameters to this URL. +# Default: "http://localhost:5173/auth/sso/callback" (Vite dev server) +sso_callback_url = "http://localhost:5173/auth/sso/callback" diff --git a/crates/pm-core/src/config.rs b/crates/pm-core/src/config.rs index d351099..83d58bb 100644 --- a/crates/pm-core/src/config.rs +++ b/crates/pm-core/src/config.rs @@ -80,6 +80,9 @@ pub struct SecurityConfig { pub web_tls_cert_path: String, /// Web UI TLS key path pub web_tls_key_path: String, + /// Frontend URL to redirect to after SSO callback (default: http://localhost:5173/auth/sso/callback) + #[serde(default = "default_sso_callback_url")] + pub sso_callback_url: String, } impl AppConfig { @@ -105,6 +108,10 @@ fn default_health_check_poll_interval() -> u64 { 300 } +fn default_sso_callback_url() -> String { + "http://localhost:5173/auth/sso/callback".to_string() +} + impl Default for AppConfig { fn default() -> Self { Self { @@ -142,6 +149,7 @@ impl Default for AppConfig { ca_key_path: "/etc/patch-manager/ca/ca.key".to_string(), web_tls_cert_path: "/etc/patch-manager/tls/web.crt".to_string(), web_tls_key_path: "/etc/patch-manager/tls/web.key".to_string(), + sso_callback_url: default_sso_callback_url(), }, } } diff --git a/crates/pm-web/Cargo.toml b/crates/pm-web/Cargo.toml index 3c57ad8..3e8ec2a 100644 --- a/crates/pm-web/Cargo.toml +++ b/crates/pm-web/Cargo.toml @@ -38,5 +38,6 @@ lettre = { version = "0.11", default-features = false, features = ["tokio1-rustl rand = { workspace = true } base64 = { workspace = true } sha2 = { workspace = true } +jsonwebtoken = { workspace = true } url = { workspace = true } urlencoding = "2" diff --git a/crates/pm-web/src/main.rs b/crates/pm-web/src/main.rs index c1e4451..44bd7e0 100644 --- a/crates/pm-web/src/main.rs +++ b/crates/pm-web/src/main.rs @@ -10,10 +10,11 @@ use pm_auth::{ rbac::{require_auth, AuthConfig}, }; use pm_core::{config::AppConfig, db, logging, request_id::request_id_middleware}; -use routes::azure_sso::SsoSession; +use routes::azure_sso::{JwksCache, SsoSession}; use routes::ws::WsTicket; use serde_json::{json, Value}; use std::{net::SocketAddr, sync::Arc, time::Duration}; +use tokio::sync::Mutex; use tower_http::{ services::{ServeDir, ServeFile}, trace::TraceLayer, @@ -30,6 +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>, /// Internal certificate authority for mTLS client cert issuance. pub ca: Arc, } @@ -87,6 +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())); // Background task: purge expired WS tickets every 30 seconds. { @@ -106,6 +110,25 @@ async fn main() -> anyhow::Result<()> { }); } + // Background task: purge expired SSO sessions every 60 seconds (sessions older than 10 minutes). + { + let sessions = sso_sessions.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(60)); + loop { + interval.tick().await; + let now = chrono::Utc::now(); + let cutoff = now - chrono::Duration::minutes(10); + let before = sessions.len(); + sessions.retain(|_, v| v.created_at > cutoff); + let removed = before.saturating_sub(sessions.len()); + if removed > 0 { + tracing::debug!(removed, "Purged expired SSO sessions"); + } + } + }); + } + let state = AppState { db: pool, config: Arc::new(config.clone()), @@ -114,6 +137,7 @@ async fn main() -> anyhow::Result<()> { ws_tickets, sso_sessions, ca: Arc::new(ca), + jwks_cache, }; let app = build_router(state); diff --git a/crates/pm-web/src/routes/azure_sso.rs b/crates/pm-web/src/routes/azure_sso.rs index aa2b434..99a4cd7 100644 --- a/crates/pm-web/src/routes/azure_sso.rs +++ b/crates/pm-web/src/routes/azure_sso.rs @@ -2,7 +2,7 @@ //! //! 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 +//! GET /api/v1/auth/azure/callback — handle Azure AD callback, redirect to frontend SPA use axum::{ extract::State, @@ -13,11 +13,15 @@ use axum::{ }; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use chrono::Utc; +use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation}; use pm_auth::{jwt::issue_access_token, refresh}; use pm_core::audit::{log_event, AuditAction}; use serde::Deserialize; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; +use std::collections::HashSet; +use std::sync::Arc; +use tokio::sync::Mutex; use uuid::Uuid; use crate::AppState; @@ -61,6 +65,24 @@ 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>, +} + +impl Default for JwksCache { + fn default() -> Self { + Self { + keys: None, + fetched_at: None, + } + } +} + +/// JWKS cache TTL in seconds (1 hour). +const JWKS_CACHE_TTL_SECS: i64 = 3600; + // ============================================================ // Router // ============================================================ @@ -160,69 +182,61 @@ struct CallbackParams { async fn azure_callback( State(state): State, axum::extract::Query(params): axum::extract::Query, -) -> Result, (StatusCode, Json)> { +) -> 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={}", + callback_url, + urlencoding::encode(code), + urlencoding::encode(message) + ); + Redirect::to(&url) + }; + // Check for error from Azure AD if let Some(error) = params.error { let desc = params.error_description.unwrap_or_default(); - return Err(( - StatusCode::BAD_REQUEST, - Json( - json!({ "error": { "code": "sso_error", "message": format!("Azure AD error: {} - {}", error, desc) } }), - ), - )); + let message = format!("Azure AD error: {} - {}", error, desc); + return Err(error_redirect("sso_error", &message)); } - let code = params.code.ok_or_else(|| { - ( - StatusCode::BAD_REQUEST, - Json(json!({ "error": { "code": "bad_request", "message": "Missing authorization code" } })), - ) - })?; + let code = match params.code { + Some(c) => c, + None => return Err(error_redirect("bad_request", "Missing authorization code")), + }; - let state_token = params.state.ok_or_else(|| { - ( - StatusCode::BAD_REQUEST, - Json( - json!({ "error": { "code": "bad_request", "message": "Missing state parameter" } }), - ), - ) - })?; + let state_token = match params.state { + Some(s) => s, + None => return Err(error_redirect("bad_request", "Missing state parameter")), + }; // Look up code_verifier from sso_sessions - let sso_session = state - .sso_sessions - .remove(&state_token) - .map(|(_, v)| v) - .ok_or_else(|| { - ( - StatusCode::BAD_REQUEST, - Json(json!({ "error": { "code": "bad_request", "message": "Invalid or expired state token" } })), - ) - })?; + let sso_session = match state.sso_sessions.remove(&state_token).map(|(_, v)| v) { + Some(s) => s, + None => return Err(error_redirect("bad_request", "Invalid or expired state token")), + }; // Read Azure SSO config (including client_secret for token exchange) - let row: Option<(bool, String, String, String, String)> = sqlx::query_as( + 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 - .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" } })), - ) - })?; + { + 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 { Some(r) => r, None => { - return Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json( - json!({ "error": { "code": "internal_error", "message": "Azure SSO not configured" } }), - ), - )); + return Err(error_redirect("internal_error", "Azure SSO not configured")); }, }; @@ -232,16 +246,16 @@ async fn azure_callback( tenant_id ); - let client = reqwest::Client::builder() + let client = match reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .build() - .map_err(|e| { + { + Ok(c) => c, + 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" } })), - ) - })?; + return Err(error_redirect("internal_error", "HTTP client error")); + } + }; let params = [ ("grant_type", "authorization_code".to_string()), @@ -254,57 +268,47 @@ async fn azure_callback( let form_params: Vec<(&str, String)> = params.to_vec(); - let token_resp = client + let token_resp = match client .post(&token_url) .form(&form_params) .send() .await - .map_err(|e| { + { + Ok(r) => r, + Err(e) => { tracing::error!(error = %e, "Token exchange request failed"); - ( - StatusCode::BAD_GATEWAY, - Json(json!({ "error": { "code": "sso_error", "message": format!("Token exchange failed: {}", e) } })), - ) - })?; + return Err(error_redirect("sso_error", &format!("Token exchange failed: {}", e))); + } + }; if !token_resp.status().is_success() { let status = token_resp.status(); let body = token_resp.text().await.unwrap_or_default(); tracing::error!(status = %status, body = %body, "Token exchange failed"); - return Err(( - StatusCode::BAD_GATEWAY, - Json( - json!({ "error": { "code": "sso_error", "message": format!("Token exchange failed: HTTP {}", status) } }), - ), - )); + return Err(error_redirect("sso_error", &format!("Token exchange failed: HTTP {}", status))); } - let token_data: TokenResponse = token_resp - .json() - .await - .map_err(|e| { + let token_data: TokenResponse = match token_resp.json().await { + Ok(d) => d, + Err(e) => { tracing::error!(error = %e, "Failed to parse token response"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": { "code": "internal_error", "message": "Failed to parse token response" } })), - ) - })?; + return Err(error_redirect("internal_error", "Failed to parse token response")); + } + }; - // Decode id_token JWT (without verification — trust HTTPS channel) - let id_token = token_data.id_token.ok_or_else(|| { - ( - StatusCode::BAD_GATEWAY, - Json(json!({ "error": { "code": "sso_error", "message": "No id_token in response" } })), - ) - })?; + // 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 = decode_jwt_payload(&id_token).map_err(|e| { - tracing::error!(error = %e, "Failed to decode id_token"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": { "code": "internal_error", "message": "Failed to decode id_token" } })), - ) - })?; + let claims = match verify_id_token(&id_token, &tenant_id, &client_id, &state.jwks_cache).await { + Ok(c) => c, + Err(e) => { + tracing::error!(error = %e, "Failed to verify id_token"); + return Err(error_redirect("internal_error", "Failed to verify id_token")); + } + }; let email = claims.email.unwrap_or_default(); let name = claims.name.unwrap_or_default(); @@ -312,43 +316,33 @@ async fn azure_callback( let preferred_username = claims.preferred_username.unwrap_or_else(|| email.clone()); if email.is_empty() || oid.is_empty() { - return Err(( - StatusCode::BAD_GATEWAY, - Json( - json!({ "error": { "code": "sso_error", "message": "Missing email or oid in id_token" } }), - ), - )); + return Err(error_redirect("sso_error", "Missing email or oid in id_token")); } // Look up or create user - let user_opt: Option = sqlx::query_as( + 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'"#, ) .bind(&email) .fetch_optional(&state.db) .await - .map_err(|e| { - tracing::error!(error = %e, "Failed to look up SSO user"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })), - ) - })?; + { + Ok(o) => o, + Err(e) => { + tracing::error!(error = %e, "Failed to look up SSO user"); + return Err(error_redirect("internal_error", "Database error")); + } + }; let user = match user_opt { Some(u) if !u.is_active => { - return Err(( - StatusCode::FORBIDDEN, - Json( - json!({ "error": { "code": "account_disabled", "message": "Account is disabled" } }), - ), - )); + return Err(error_redirect("account_disabled", "Account is disabled")); }, Some(u) => u, None => { // Auto-create user with role=operator, auth_provider=azure_sso - let id: Uuid = sqlx::query_scalar( + 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) RETURNING id"#, @@ -359,13 +353,13 @@ async fn azure_callback( .bind(&oid) .fetch_one(&state.db) .await - .map_err(|e| { - tracing::error!(error = %e, "Failed to create SSO user"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": { "code": "internal_error", "message": "Failed to create user" } })), - ) - })?; + { + Ok(id) => id, + Err(e) => { + tracing::error!(error = %e, "Failed to create SSO user"); + return Err(error_redirect("internal_error", "Failed to create user")); + } + }; log_event( &state.db, @@ -392,47 +386,41 @@ async fn azure_callback( }; // Update last_login_at and azure_oid - sqlx::query( + if let Err(e) = sqlx::query( "UPDATE users SET last_login_at = NOW(), azure_oid = COALESCE(azure_oid, $1) WHERE id = $2", ) .bind(&oid) .bind(user.id) .execute(&state.db) .await - .map_err(|e| { + { tracing::error!(error = %e, "Failed to update last_login_at"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": { "code": "internal_error", "message": "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_token = issue_access_token( + let access_token = match issue_access_token( user.id, &user.username, &user.role, access_ttl, &state.signing_key_pem, - ) - .map_err(|e| { - tracing::error!(error = %e, "Failed to issue access token"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": { "code": "internal_error", "message": "Token issuance failed" } })), - ) - })?; + ) { + Ok(t) => t, + Err(e) => { + tracing::error!(error = %e, "Failed to issue access token"); + return Err(error_redirect("internal_error", "Token issuance failed")); + } + }; - let raw_refresh = refresh::issue(&state.db, user.id, None, None) - .await - .map_err(|e| { + let raw_refresh = match refresh::issue(&state.db, user.id, None, None).await { + Ok(r) => r, + Err(e) => { tracing::error!(error = %e, "Failed to issue refresh token"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": { "code": "internal_error", "message": "Refresh token issuance failed" } })), - ) - })?; + return Err(error_redirect("internal_error", "Refresh token issuance failed")); + } + }; log_event( &state.db, @@ -447,42 +435,145 @@ async fn azure_callback( ) .await; - Ok(Json(json!({ - "access_token": access_token, - "refresh_token": raw_refresh.0, - "token_type": "Bearer", - "expires_in": access_ttl, - "user": { - "id": user.id.to_string(), - "username": user.username, - "display_name": user.display_name, - "role": user.role, - "mfa_enabled": user.mfa_enabled, + // 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, + "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, + urlencoding::encode(&access_token), + urlencoding::encode(&raw_refresh.0), + access_ttl, + urlencoding::encode(&user_json.to_string()), + ); + + Ok(Redirect::to(&redirect_url)) +} + +// ============================================================ +// JWT Verification Helpers +// ============================================================ + +/// 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 verify_id_token( + token: &str, + tenant_id: &str, + client_id: &str, + jwks_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) { + (None, _) => true, + (Some(_), None) => true, + (Some(_), Some(fetched)) => { + let elapsed = Utc::now().signed_duration_since(*fetched); + elapsed.num_seconds() > JWKS_CACHE_TTL_SECS + } + }; + + 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() + } else { + cache.keys.clone().unwrap() } - }))) + }; + + // 3. Find the matching JWK by kid + let keys_array = jwks + .get("keys") + .ok_or("JWKS response missing 'keys' array")? + .as_array() + .ok_or("JWKS 'keys' is not an array")?; + + let jwk = keys_array + .iter() + .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()) + .ok_or("JWK missing 'n' (modulus) field")?; + let e = jwk + .get("e") + .and_then(|v| v.as_str()) + .ok_or("JWK missing 'e' (exponent) field")?; + + 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 + + // 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) } -// ============================================================ -// Helpers -// ============================================================ +/// 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 + ); -/// Decode JWT payload without verification (trust HTTPS channel from Azure AD). -fn decode_jwt_payload(token: &str) -> Result { - let parts: Vec<&str> = token.split('.').collect(); - if parts.len() != 3 { - return Err("Invalid JWT format".to_string()); + 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) + .send() + .await + .map_err(|e| format!("JWKS fetch request failed: {}", e))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("JWKS fetch failed: HTTP {} — {}", status, body)); } - let payload_b64 = parts[1]; - // Add padding if needed - let mut payload_b64_padded = payload_b64.to_string(); - while payload_b64_padded.len() % 4 != 0 { - payload_b64_padded.push('='); - } - - let payload_bytes = base64::engine::general_purpose::STANDARD - .decode(&payload_b64_padded) - .map_err(|e| format!("Base64 decode error: {}", e))?; - - serde_json::from_slice(&payload_bytes).map_err(|e| format!("JSON parse error: {}", e)) + resp.json::() + .await + .map_err(|e| format!("Failed to parse JWKS response: {}", e)) } diff --git a/crates/pm-web/src/routes/settings.rs b/crates/pm-web/src/routes/settings.rs index 054d718..0e54cc8 100644 --- a/crates/pm-web/src/routes/settings.rs +++ b/crates/pm-web/src/routes/settings.rs @@ -40,6 +40,7 @@ pub struct SettingsResponse { pub ip_whitelist: Vec, pub web_tls_strategy: String, pub notification: NotificationConfig, + pub sso_callback_url: String, } #[derive(Debug, Serialize, Deserialize)] @@ -202,6 +203,7 @@ fn build_settings_response( email_from: get("notification_email_from"), recipients, }, + sso_callback_url: get("sso_callback_url"), } } @@ -269,6 +271,9 @@ async fn get_settings( ) -> Result, (StatusCode, Json)> { admin_only(&auth)?; let cfg = load_system_config(&state.db).await?; + // Inject read-only config values from TOML file (not stored in DB) + let mut cfg = cfg; + cfg.insert("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))) } @@ -488,6 +493,9 @@ async fn update_settings( // Return updated settings let cfg = load_system_config(&state.db).await?; + // Inject read-only config values from TOML file (not stored in DB) + let mut cfg = cfg; + cfg.insert("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))) } diff --git a/frontend/index.html b/frontend/index.html index 2f8996e..ed51b31 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,7 +5,7 @@ + content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:; connect-src 'self' wss:;" /> Linux Patch Manager diff --git a/frontend/package.json b/frontend/package.json index 924ff1d..1629712 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "patch-manager-ui", "private": true, - "version": "0.1.0", + "version": "0.1.3", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0a7942d..cacad22 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { darkTheme } from './theme/theme' import { useAuthStore } from './store/authStore' import AppLayout from './components/AppLayout' import LoginPage from './pages/LoginPage' +import SsoCallbackPage from './pages/SsoCallbackPage' import MfaSetupPage from './pages/MfaSetupPage' import HostsPage from './pages/HostsPage' import HostDetailPage from './pages/HostDetailPage' @@ -89,6 +90,7 @@ function App() { {/* Public */} } /> + } /> {/* Protected — wrapped in AppLayout with sidebar navigation */} }> diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx index b650d79..9242d6a 100644 --- a/frontend/src/components/AppLayout.tsx +++ b/frontend/src/components/AppLayout.tsx @@ -139,7 +139,7 @@ export default function AppLayout() { - Linux Patch Manager v0.1.0 + Linux Patch Manager v0.1.3 diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 10f5e92..756eb7c 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -4,11 +4,13 @@ import { Box, Button, Container, TextField, Typography, Alert, CircularProgress, Paper, InputAdornment, IconButton, List, ListItem, ListItemIcon, ListItemText, + Divider, } from '@mui/material' import { Visibility, VisibilityOff, Check as CheckIcon, Close as CloseIcon, } from '@mui/icons-material' +import { Cloud as CloudIcon } from '@mui/icons-material' import { authApi } from '../api/client' import { useAuthStore } from '../store/authStore' import type { User } from '../types' @@ -323,6 +325,15 @@ export default function LoginPage() { > {loading ? : 'Sign In'} + or + )} diff --git a/frontend/src/pages/MfaSetupPage.tsx b/frontend/src/pages/MfaSetupPage.tsx index b04a53e..5aae377 100644 --- a/frontend/src/pages/MfaSetupPage.tsx +++ b/frontend/src/pages/MfaSetupPage.tsx @@ -7,6 +7,7 @@ import { import { ContentCopy as CopyIcon } from '@mui/icons-material' import QRCode from 'qrcode' import { authApi } from '../api/client' +import { useAuthStore } from '../store/authStore' const STEPS = ['Get your QR code', 'Verify code', 'Done'] @@ -23,6 +24,7 @@ export default function MfaSetupPage() { authApi.getMfaSetup() .then((res) => { setSetup(res.data) + console.log('[MFA Setup] Success:', res.status, res.data) // Generate QR code from otpauth URI if (res.data.otp_uri) { QRCode.toDataURL(res.data.otp_uri, { @@ -31,10 +33,31 @@ export default function MfaSetupPage() { color: { dark: '#000000', light: '#ffffff' }, }) .then((url) => setQrDataUrl(url)) - .catch(() => setError('Failed to generate QR code.')) + .catch((qrErr) => { + console.error('[MFA Setup] QR generation failed:', qrErr) + setError('Failed to generate QR code.') + }) + } else { + console.error('[MFA Setup] No otp_uri in response:', res.data) + setError('MFA setup returned invalid data. No OTP URI found.') + } + }) + .catch((err) => { + const status = err?.response?.status + const data = err?.response?.data + const message = err?.message + const token = useAuthStore.getState().accessToken + console.error('[MFA Setup] Failed:', { status, data, message, hasToken: !!token }) + if (status === 401) { + setError('Authentication required. Please log in again.') + } else if (status === 403) { + setError('You do not have permission to set up MFA.') + } else if (message === 'Network Error') { + setError('Network error. Please check your connection and try again.') + } else { + setError(`Failed to load MFA setup: ${message || 'Unknown error'} (Status: ${status || 'N/A'})`) } }) - .catch(() => setError('Failed to load MFA setup.')) }, []) const handleCopySecret = () => { diff --git a/frontend/src/pages/SsoCallbackPage.tsx b/frontend/src/pages/SsoCallbackPage.tsx new file mode 100644 index 0000000..2dabab7 --- /dev/null +++ b/frontend/src/pages/SsoCallbackPage.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { + Box, Container, Paper, Typography, Alert, Button, CircularProgress, +} from '@mui/material' +import { useAuthStore } from '../store/authStore' +import type { User } from '../types' + +export default function SsoCallbackPage() { + const navigate = useNavigate() + const { setTokens, setUser } = useAuthStore() + const [error, setError] = useState(null) + const [processing, setProcessing] = useState(true) + + useEffect(() => { + const params = new URLSearchParams(window.location.search) + + // Check for error from backend + const errorCode = params.get('error') + const errorDescription = params.get('error_description') + if (errorCode) { + setError(errorDescription || `SSO authentication failed: ${errorCode}`) + setProcessing(false) + return + } + + // Extract tokens + const accessToken = params.get('access_token') + const refreshToken = params.get('refresh_token') + + if (!accessToken || !refreshToken) { + setError('Missing authentication tokens. Please try logging in again.') + setProcessing(false) + return + } + + // Parse user JSON from query param + const userParam = params.get('user') + if (!userParam) { + setError('Missing user information. Please try logging in again.') + setProcessing(false) + return + } + + let parsedUser: Record + try { + parsedUser = JSON.parse(userParam) + } catch { + setError('Malformed user data received. Please try logging in again.') + setProcessing(false) + return + } + + // Build a full User object from the SSO subset, filling in sensible defaults + 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', + mfa_enabled: (parsedUser.mfa_enabled as boolean) ?? false, + is_active: true, + force_password_reset: false, + } + + // Store tokens and user, then navigate + setTokens(accessToken, refreshToken) + setUser(user) + navigate('/dashboard', { replace: true }) + }, [setTokens, setUser, navigate]) + + return ( + + + + 🐉 Linux Patch Manager + + + {processing ? ( + + + + Completing sign-in… + + + ) : ( + + + {error} + + + + )} + + + ) +} diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx index 50cc9ad..95386ed 100644 --- a/frontend/src/pages/UsersPage.tsx +++ b/frontend/src/pages/UsersPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, useMemo } from 'react' +import { useNavigate } from 'react-router-dom' import { Box, Button, Chip, CircularProgress, Container, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, FormControlLabel, IconButton, @@ -75,6 +76,7 @@ function PasswordStrengthIndicator({ password }: { password: string }) { export default function UsersPage() { const currentUser = useAuthStore(s => s.user) + const navigate = useNavigate() const isAdmin = currentUser?.role === 'admin' const [users, setUsers] = useState([]) @@ -327,8 +329,17 @@ export default function UsersPage() { color={u.role === 'admin' ? 'primary' : 'default'} /> - + {u.mfa_enabled ? ( + + ) : currentUser?.id === u.id ? ( + + navigate('/mfa/setup')} /> + + ) : ( + + )} - {/* MFA status & disable */} + {/* MFA status */} MFA Status: - {editUser?.mfa_enabled && ( + {editUser?.mfa_enabled ? ( + ) : ( + currentUser?.id === editUser?.id ? ( + + ) : ( + + User must enable MFA from their own profile settings. + + ) )} - {editUser?.mfa_enabled && ( + {editUser?.mfa_enabled ? ( Disabling MFA reduces account security for this user. + ) : ( + currentUser?.id === editUser?.id && ( + + You will be guided through authenticator app setup. + + ) )} )} diff --git a/tasks/lessons.md b/tasks/lessons.md index d880072..6349ab1 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -1,5 +1,27 @@ # Linux Patch Manager — Lessons Learned +## 2026-05-08: Asserting Unverified Conclusions Is a Critical Failure Mode +**Pattern:** I repeatedly asserted conclusions without verifying them first, then spun wheels on rabbit holes instead of checking the obvious source. +**Mistakes made in this session:** +1. Claimed vaultwarden-secrets wasn't in gitea — WRONG. It was there the whole time. +2. Claimed Vaultwarden credentials "may be stale" — WRONG. They were correct; my implementation was wrong. +3. Used wrong credential path (/a0/usr/credentials/gitea/ instead of /a0/usr/credentials/gitea-lxc/). +4. Spun wheels decompiling .pyc, manual API auth, searching chat history — instead of checking the gitea repo. +5. Didn't notice SSH key was missing from ~/.ssh/ until connection failed. +6. Stated uncertainty as fact ("credentials may be stale") when the real issue was my own technical failure. +**Root cause:** Violating the Verification Principle — asserting conclusions without verification. +**Rule:** ALWAYS verify before asserting. If I haven't checked, say "I haven't verified this" — never state it as fact. +**Rule:** When a tool/skill is broken, FIX IT FIRST before attempting manual workarounds. +**Rule:** Check the obvious source (gitea repo, Vaultwarden store) before spinning wheels on complex alternatives. +**Status:** Active + +## 2026-05-08: Vaultwarden Is the Source of Truth for All Credentials +**Pattern:** SSH keys in ~/.ssh/ are ephemeral — lost on every container recreation. Local copies are unreliable. +**Rule:** ALWAYS pull credentials (SSH keys, API tokens, passwords) from Vaultwarden when needed. Do NOT rely on local copies in ~/.ssh/ or /a0/usr/storage/ as they may be stale or missing after container recreation. +**Rule:** At the start of each session, verify critical credentials by pulling them from Vaultwarden using `python3 /a0/skills/vaultwarden-secrets/scripts/vw_client.py`. +**Rule:** /a0/usr/storage/echo-ssh-setup/ is NOT the primary source — Vaultwarden is. Local copies are convenience only. +**Status:** Active + ## 2026-04-24: CI/CD First, Not Manual Builds **Pattern:** When creating release packages, set up CI/CD pipeline (Gitea Actions) FIRST before manually building. **Why:** Manual builds are one-off and not reproducible. CI/CD ensures every push/tag produces a fresh, consistent package built on the correct target OS (Ubuntu 24.04), with proper glibc compatibility. @@ -95,3 +117,12 @@ The Docker container intercepted some jobs and ran them in its Alpine environmen **Pattern:** The debian/control file has a hardcoded `Version: 1.0.0-1` that doesn't match the Cargo.toml version. **Why:** When dpkg sees the same version number (1.0.0-1) for both old and new packages, it may not properly replace files. The build-package.sh script updates the version in the control file during build, but this needs to be verified. **Action:** Ensure build-package.sh always updates debian/control Version to match Cargo.toml version before building the .deb. + +## 2026-05-08: CSP img-src Must Include data: for QR Codes and Dynamic Images +**Pattern:** Content Security Policy default-src 'self' blocks data: URIs, preventing base64-encoded images (like QR codes) from displaying. +**Mistake:** Spent extensive time investigating infrastructure (HAProxy, caching, deployment, auth tokens) when Kelly said 'it's just a display issue.' The actual cause was a missing `img-src 'self' data:;` in the CSP meta tag. +**Root cause:** The CSP in index.html only had `default-src 'self'` which blocks `data:` image sources. The QR code library generates `data:image/png;base64,...` URIs which were silently blocked by the browser. +**Fix:** Added `img-src 'self' data:;` to the CSP directive. +**Rule:** When someone says 'it's just a display issue,' focus on the code (CSP, CSS, rendering) — not infrastructure (caching, proxies, deployment). +**Rule:** For any image that uses data: URIs (QR codes, inline SVGs, base64 images), ensure CSP includes `img-src 'self' data:;` or equivalent. +**Status:** Active \ No newline at end of file diff --git a/tasks/todo.md b/tasks/todo.md index 35373b6..1190c39 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -1,61 +1,45 @@ -# Target Host for Service Health Checks +# SSO Implementation Fix Plan -## Overview -Add `target_host_id` field to service health checks, allowing a check configured on Host A to query a service on Host B's agent. Useful for redundant services running on multiple machines. +## Issues Identified +1. **No SSO Login Button** — LoginPage.tsx missing "Sign in with Azure" button +2. **No SSO Callback Route** — App.tsx missing frontend route to handle SSO callback +3. **authStore No SSO Support** — authStore.ts has no method to store SSO tokens +4. **Backend Returns JSON Not Redirect** — azure_sso.rs callback returns JSON tokens instead of redirecting to frontend +5. **No SSO Session Cleanup** — sso_sessions DashMap has no expiry/cleanup task (memory leak) +6. **No JWT Signature Verification** — id_token decoded without verifying Azure AD signature -**Design:** `target_host_id` is nullable. When NULL (default), behavior unchanged — check queries its own host's agent. When set, the service check queries the target host's agent instead. Only applies to service checks; HTTP checks already specify a full URL. +## Phases -## Implementation Checklist +### Phase 1: Backend SSO Fixes (Issues 4, 5) — COMPLETE ✅ +- [x] 1a: Add SSO session cleanup task in main.rs (purge sessions older than 10 minutes) +- [x] 1b: Modify azure_sso.rs callback to redirect to frontend with tokens instead of returning JSON +- [x] 1c: Add `sso_callback_url` to SecurityConfig in config.rs with serde default +- [x] 1d: Update settings.rs to include sso_callback_url in settings response +- [x] 1e: Verify backend compiles with `cargo check` -### 1. Database Migration -- [ ] Create `migrations/011_health_check_target_host.sql` -- [ ] Add `target_host_id UUID REFERENCES hosts(id) ON DELETE SET NULL` column -- [ ] Add partial index on `target_host_id` where NOT NULL +### Phase 2: Frontend SSO Integration (Issues 1, 2, 3) — COMPLETE ✅ +- [x] 2a: Add SSO callback page component (SsoCallbackPage.tsx) +- [x] 2b: Add SSO callback route to App.tsx (public route, no auth required) +- [x] 2c: Add "Sign in with Microsoft Azure" button to LoginPage.tsx +- [x] 2d: Add SSO-related types and API methods to frontend +- [x] 2e: Verify frontend builds with TypeScript compilation -### 2. Backend Models (`crates/pm-core/src/models.rs`) -- [ ] Add `target_host_id: Option` to `HealthCheck` struct -- [ ] Add `target_host_id: Option` to `CreateHealthCheckRequest` -- [ ] Add `target_host_id: Option` to `UpdateHealthCheckRequest` -- [ ] Add `target_host_id` to all HealthCheck SELECT queries +### Phase 3: JWT Signature Verification (Issue 6) — COMPLETE ✅ +- [x] 3a: Add JWKS client dependency to pm-web/Cargo.toml +- [x] 3b: Implement id_token signature verification in azure_sso.rs +- [x] 3c: Verify backend compiles with `cargo check` -### 3. API Routes (`crates/pm-web/src/routes/health_checks.rs`) -- [ ] Create: add `target_host_id` to INSERT, validate target host exists + is healthy -- [ ] Update: add `target_host_id` to COALESCE UPDATE -- [ ] List/Get: add `target_host_id` to SELECT columns -- [ ] Test endpoint (`run_service_check`): when `target_host_id` is Some, query that host's IP/port -- [ ] Audit log: include `target_host_id` in audit JSON +### Phase 4: Integration Testing and Verification — COMPLETE ✅ +- [x] 4a: Backend code review — all changes verified manually +- [x] 4b: Frontend TypeScript compilation — passes cleanly +- [x] 4c: SSO login flow reviewed end-to-end (backend redirect → frontend callback → auth store) +- [x] 4d: SSO session cleanup verified (10-minute expiry, 60-second purge interval) +- [x] 4e: Settings page SSO config unchanged (sso_callback_url added as read-only) +- [x] 4f: Lessons captured below -### 4. Health Check Poller (`crates/pm-worker/src/health_check_poller.rs`) -- [ ] Add `target_host_id: Option` to `HealthCheckRow` -- [ ] Modify SQL: LEFT JOIN hosts th ON th.id = hc.target_host_id, use COALESCE(th.ip_address, h.ip_address) and COALESCE(th.agent_port, h.agent_port) -- [ ] Add `target_ip_address` and `target_agent_port` fields to HealthCheckRow -- [ ] `run_service_check`: use target host IP/port when available -- [ ] `check_host_health_checks`: no change needed (results count toward owning host) - -### 5. Frontend Types (`frontend/src/types/index.ts`) -- [ ] Add `target_host_id?: string` to `HealthCheck` -- [ ] Add `target_host_id?: string` to `CreateHealthCheckRequest` -- [ ] Add `target_host_id?: string` to `UpdateHealthCheckRequest` - -### 6. Frontend Form (`frontend/src/pages/HostDetailPage.tsx`) -- [ ] Add `target_host_id: string` to `HealthCheckFormValues` -- [ ] Add `target_host_id: ''` to `defaultHealthCheckForm` -- [ ] Add host selector dropdown in `HealthCheckFormDialog` (visible when check_type === 'service') -- [ ] Fetch hosts list for dropdown (use hostsApi.list or a dedicated endpoint) -- [ ] `handleHcCreateSubmit`: include `target_host_id: values.target_host_id || undefined` -- [ ] `handleHcEditClick`: map `check.target_host_id ?? ''` to form -- [ ] `handleHcEditSubmit`: include `target_host_id` in UpdateHealthCheckRequest -- [ ] Display target host in health checks table Target column - -### 7. Build, Test, Deploy -- [ ] Run `cargo fmt --all` + `cargo clippy` + `cargo test` -- [ ] Run frontend build + ESLint + tsc -- [ ] Commit and push through CI pipeline -- [ ] Tag release, build .deb, deploy to dev - -## Design Decisions -- `target_host_id` is nullable — NULL = check own host (backward compatible) -- FK with ON DELETE SET NULL — if target host deleted, revert to default -- Only applies to service checks (HTTP checks already have full URL) -- Health gate: results count toward the owning host, not the target host -- No RBAC required for target host — only requirement: target host exists in manager and is currently healthy +## Lessons Learned +- **SSO callback must redirect, not return JSON** — Browser OAuth2 flows require the backend to redirect to the frontend SPA, not return JSON tokens. The frontend must parse tokens from URL query parameters. +- **URLSearchParams.get() already decodes** — Don't double-decode with decodeURIComponent() when using URLSearchParams. +- **JWKS caching prevents rate-limiting** — Azure AD JWKS endpoint should be cached with TTL (1 hour) to avoid fetching on every SSO login. +- **tokio::sync::Mutex over std::sync::Mutex** — Axum handlers must be Send; std::sync::MutexGuard is not Send across await points. +- **DashMap session cleanup** — In-memory session stores (DashMap) need periodic cleanup tasks to prevent memory leaks. Pattern: tokio::spawn with interval + retain with time-based cutoff.