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)
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -2328,6 +2328,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
|
"jsonwebtoken",
|
||||||
"lettre",
|
"lettre",
|
||||||
"pm-auth",
|
"pm-auth",
|
||||||
"pm-ca",
|
"pm-ca",
|
||||||
|
|||||||
@ -11,7 +11,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["Echo <echo@moon-dragon.us>"]
|
authors = ["Echo <echo@moon-dragon.us>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@ -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.
|
# point these paths to your certificate/key to use your own cert.
|
||||||
web_tls_cert_path = "/etc/patch-manager/tls/web.crt"
|
web_tls_cert_path = "/etc/patch-manager/tls/web.crt"
|
||||||
web_tls_key_path = "/etc/patch-manager/tls/web.key"
|
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"
|
||||||
|
|||||||
@ -80,6 +80,9 @@ pub struct SecurityConfig {
|
|||||||
pub web_tls_cert_path: String,
|
pub web_tls_cert_path: String,
|
||||||
/// Web UI TLS key path
|
/// Web UI TLS key path
|
||||||
pub web_tls_key_path: String,
|
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 {
|
impl AppConfig {
|
||||||
@ -105,6 +108,10 @@ fn default_health_check_poll_interval() -> u64 {
|
|||||||
300
|
300
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_sso_callback_url() -> String {
|
||||||
|
"http://localhost:5173/auth/sso/callback".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for AppConfig {
|
impl Default for AppConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@ -142,6 +149,7 @@ impl Default for AppConfig {
|
|||||||
ca_key_path: "/etc/patch-manager/ca/ca.key".to_string(),
|
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_cert_path: "/etc/patch-manager/tls/web.crt".to_string(),
|
||||||
web_tls_key_path: "/etc/patch-manager/tls/web.key".to_string(),
|
web_tls_key_path: "/etc/patch-manager/tls/web.key".to_string(),
|
||||||
|
sso_callback_url: default_sso_callback_url(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,5 +38,6 @@ lettre = { version = "0.11", default-features = false, features = ["tokio1-rustl
|
|||||||
rand = { workspace = true }
|
rand = { workspace = true }
|
||||||
base64 = { workspace = true }
|
base64 = { workspace = true }
|
||||||
sha2 = { workspace = true }
|
sha2 = { workspace = true }
|
||||||
|
jsonwebtoken = { workspace = true }
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
|
|||||||
@ -10,10 +10,11 @@ use pm_auth::{
|
|||||||
rbac::{require_auth, AuthConfig},
|
rbac::{require_auth, AuthConfig},
|
||||||
};
|
};
|
||||||
use pm_core::{config::AppConfig, db, logging, request_id::request_id_middleware};
|
use pm_core::{config::AppConfig, db, logging, request_id::request_id_middleware};
|
||||||
use routes::azure_sso::SsoSession;
|
use routes::azure_sso::{JwksCache, SsoSession};
|
||||||
use routes::ws::WsTicket;
|
use routes::ws::WsTicket;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::{net::SocketAddr, sync::Arc, time::Duration};
|
use std::{net::SocketAddr, sync::Arc, time::Duration};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
use tower_http::{
|
use tower_http::{
|
||||||
services::{ServeDir, ServeFile},
|
services::{ServeDir, ServeFile},
|
||||||
trace::TraceLayer,
|
trace::TraceLayer,
|
||||||
@ -30,6 +31,8 @@ pub struct AppState {
|
|||||||
pub ws_tickets: Arc<DashMap<String, WsTicket>>,
|
pub ws_tickets: Arc<DashMap<String, WsTicket>>,
|
||||||
/// In-memory store for SSO PKCE sessions (state → code_verifier).
|
/// In-memory store for SSO PKCE sessions (state → code_verifier).
|
||||||
pub sso_sessions: Arc<DashMap<String, SsoSession>>,
|
pub sso_sessions: Arc<DashMap<String, SsoSession>>,
|
||||||
|
/// Cached Azure AD JWKS for id_token signature verification.
|
||||||
|
pub jwks_cache: Arc<Mutex<JwksCache>>,
|
||||||
/// Internal certificate authority for mTLS client cert issuance.
|
/// Internal certificate authority for mTLS client cert issuance.
|
||||||
pub ca: Arc<pm_ca::CertAuthority>,
|
pub ca: Arc<pm_ca::CertAuthority>,
|
||||||
}
|
}
|
||||||
@ -87,6 +90,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
let ws_tickets: Arc<DashMap<String, WsTicket>> = Arc::new(DashMap::new());
|
let ws_tickets: Arc<DashMap<String, WsTicket>> = Arc::new(DashMap::new());
|
||||||
let sso_sessions: Arc<DashMap<String, SsoSession>> = Arc::new(DashMap::new());
|
let sso_sessions: Arc<DashMap<String, SsoSession>> = Arc::new(DashMap::new());
|
||||||
|
let jwks_cache: Arc<Mutex<JwksCache>> = Arc::new(Mutex::new(JwksCache::default()));
|
||||||
|
|
||||||
// Background task: purge expired WS tickets every 30 seconds.
|
// 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 {
|
let state = AppState {
|
||||||
db: pool,
|
db: pool,
|
||||||
config: Arc::new(config.clone()),
|
config: Arc::new(config.clone()),
|
||||||
@ -114,6 +137,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
ws_tickets,
|
ws_tickets,
|
||||||
sso_sessions,
|
sso_sessions,
|
||||||
ca: Arc::new(ca),
|
ca: Arc::new(ca),
|
||||||
|
jwks_cache,
|
||||||
};
|
};
|
||||||
|
|
||||||
let app = build_router(state);
|
let app = build_router(state);
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Public routes (no auth required):
|
//! Public routes (no auth required):
|
||||||
//! GET /api/v1/auth/azure/login — redirect to Azure AD authorization URL
|
//! GET /api/v1/auth/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::{
|
use axum::{
|
||||||
extract::State,
|
extract::State,
|
||||||
@ -13,11 +13,15 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
|
||||||
use pm_auth::{jwt::issue_access_token, refresh};
|
use pm_auth::{jwt::issue_access_token, refresh};
|
||||||
use pm_core::audit::{log_event, AuditAction};
|
use pm_core::audit::{log_event, AuditAction};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
@ -61,6 +65,24 @@ struct DbUserForSso {
|
|||||||
mfa_enabled: bool,
|
mfa_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cache for Azure AD JWKS (JSON Web Key Set) with TTL-based refresh.
|
||||||
|
pub struct JwksCache {
|
||||||
|
pub keys: Option<serde_json::Value>,
|
||||||
|
pub fetched_at: Option<chrono::DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// Router
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@ -160,69 +182,61 @@ struct CallbackParams {
|
|||||||
async fn azure_callback(
|
async fn azure_callback(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
axum::extract::Query(params): axum::extract::Query<CallbackParams>,
|
axum::extract::Query(params): axum::extract::Query<CallbackParams>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Redirect, Redirect> {
|
||||||
|
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
|
// Check for error from Azure AD
|
||||||
if let Some(error) = params.error {
|
if let Some(error) = params.error {
|
||||||
let desc = params.error_description.unwrap_or_default();
|
let desc = params.error_description.unwrap_or_default();
|
||||||
return Err((
|
let message = format!("Azure AD error: {} - {}", error, desc);
|
||||||
StatusCode::BAD_REQUEST,
|
return Err(error_redirect("sso_error", &message));
|
||||||
Json(
|
|
||||||
json!({ "error": { "code": "sso_error", "message": format!("Azure AD error: {} - {}", error, desc) } }),
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let code = params.code.ok_or_else(|| {
|
let code = match params.code {
|
||||||
(
|
Some(c) => c,
|
||||||
StatusCode::BAD_REQUEST,
|
None => return Err(error_redirect("bad_request", "Missing authorization code")),
|
||||||
Json(json!({ "error": { "code": "bad_request", "message": "Missing authorization code" } })),
|
};
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let state_token = params.state.ok_or_else(|| {
|
let state_token = match params.state {
|
||||||
(
|
Some(s) => s,
|
||||||
StatusCode::BAD_REQUEST,
|
None => return Err(error_redirect("bad_request", "Missing state parameter")),
|
||||||
Json(
|
};
|
||||||
json!({ "error": { "code": "bad_request", "message": "Missing state parameter" } }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Look up code_verifier from sso_sessions
|
// Look up code_verifier from sso_sessions
|
||||||
let sso_session = state
|
let sso_session = match state.sso_sessions.remove(&state_token).map(|(_, v)| v) {
|
||||||
.sso_sessions
|
Some(s) => s,
|
||||||
.remove(&state_token)
|
None => return Err(error_redirect("bad_request", "Invalid or expired state token")),
|
||||||
.map(|(_, v)| v)
|
};
|
||||||
.ok_or_else(|| {
|
|
||||||
(
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
Json(json!({ "error": { "code": "bad_request", "message": "Invalid or expired state token" } })),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Read Azure SSO config (including client_secret for token exchange)
|
// 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",
|
"SELECT enabled, tenant_id, client_id, client_secret, redirect_uri FROM azure_sso_config WHERE id = 1",
|
||||||
)
|
)
|
||||||
.fetch_optional(&state.db)
|
.fetch_optional(&state.db)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
{
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
tracing::error!(error = %e, "Failed to load azure_sso_config");
|
tracing::error!(error = %e, "Failed to load azure_sso_config");
|
||||||
(
|
return Err(error_redirect("internal_error", "Database error"));
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
}
|
||||||
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
};
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let (_enabled, tenant_id, client_id, client_secret, redirect_uri) = match row {
|
let (_enabled, tenant_id, client_id, client_secret, redirect_uri) = match row {
|
||||||
Some(r) => r,
|
Some(r) => r,
|
||||||
None => {
|
None => {
|
||||||
return Err((
|
return Err(error_redirect("internal_error", "Azure SSO not configured"));
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(
|
|
||||||
json!({ "error": { "code": "internal_error", "message": "Azure SSO not configured" } }),
|
|
||||||
),
|
|
||||||
));
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -232,16 +246,16 @@ async fn azure_callback(
|
|||||||
tenant_id
|
tenant_id
|
||||||
);
|
);
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
let client = match reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| {
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
tracing::error!(error = %e, "Failed to build HTTP client");
|
tracing::error!(error = %e, "Failed to build HTTP client");
|
||||||
(
|
return Err(error_redirect("internal_error", "HTTP client error"));
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
}
|
||||||
Json(json!({ "error": { "code": "internal_error", "message": "HTTP client error" } })),
|
};
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let params = [
|
let params = [
|
||||||
("grant_type", "authorization_code".to_string()),
|
("grant_type", "authorization_code".to_string()),
|
||||||
@ -254,57 +268,47 @@ async fn azure_callback(
|
|||||||
|
|
||||||
let form_params: Vec<(&str, String)> = params.to_vec();
|
let form_params: Vec<(&str, String)> = params.to_vec();
|
||||||
|
|
||||||
let token_resp = client
|
let token_resp = match client
|
||||||
.post(&token_url)
|
.post(&token_url)
|
||||||
.form(&form_params)
|
.form(&form_params)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
{
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
tracing::error!(error = %e, "Token exchange request failed");
|
tracing::error!(error = %e, "Token exchange request failed");
|
||||||
(
|
return Err(error_redirect("sso_error", &format!("Token exchange failed: {}", e)));
|
||||||
StatusCode::BAD_GATEWAY,
|
}
|
||||||
Json(json!({ "error": { "code": "sso_error", "message": format!("Token exchange failed: {}", e) } })),
|
};
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if !token_resp.status().is_success() {
|
if !token_resp.status().is_success() {
|
||||||
let status = token_resp.status();
|
let status = token_resp.status();
|
||||||
let body = token_resp.text().await.unwrap_or_default();
|
let body = token_resp.text().await.unwrap_or_default();
|
||||||
tracing::error!(status = %status, body = %body, "Token exchange failed");
|
tracing::error!(status = %status, body = %body, "Token exchange failed");
|
||||||
return Err((
|
return Err(error_redirect("sso_error", &format!("Token exchange failed: HTTP {}", status)));
|
||||||
StatusCode::BAD_GATEWAY,
|
|
||||||
Json(
|
|
||||||
json!({ "error": { "code": "sso_error", "message": format!("Token exchange failed: HTTP {}", status) } }),
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let token_data: TokenResponse = token_resp
|
let token_data: TokenResponse = match token_resp.json().await {
|
||||||
.json()
|
Ok(d) => d,
|
||||||
.await
|
Err(e) => {
|
||||||
.map_err(|e| {
|
|
||||||
tracing::error!(error = %e, "Failed to parse token response");
|
tracing::error!(error = %e, "Failed to parse token response");
|
||||||
(
|
return Err(error_redirect("internal_error", "Failed to parse token response"));
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
}
|
||||||
Json(json!({ "error": { "code": "internal_error", "message": "Failed to parse token response" } })),
|
};
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Decode id_token JWT (without verification — trust HTTPS channel)
|
// Verify id_token JWT signature using Azure AD JWKS and validate claims
|
||||||
let id_token = token_data.id_token.ok_or_else(|| {
|
let id_token = match token_data.id_token {
|
||||||
(
|
Some(t) => t,
|
||||||
StatusCode::BAD_GATEWAY,
|
None => return Err(error_redirect("sso_error", "No id_token in response")),
|
||||||
Json(json!({ "error": { "code": "sso_error", "message": "No id_token in response" } })),
|
};
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let claims = decode_jwt_payload(&id_token).map_err(|e| {
|
let claims = match verify_id_token(&id_token, &tenant_id, &client_id, &state.jwks_cache).await {
|
||||||
tracing::error!(error = %e, "Failed to decode id_token");
|
Ok(c) => c,
|
||||||
(
|
Err(e) => {
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
tracing::error!(error = %e, "Failed to verify id_token");
|
||||||
Json(json!({ "error": { "code": "internal_error", "message": "Failed to decode id_token" } })),
|
return Err(error_redirect("internal_error", "Failed to verify id_token"));
|
||||||
)
|
}
|
||||||
})?;
|
};
|
||||||
|
|
||||||
let email = claims.email.unwrap_or_default();
|
let email = claims.email.unwrap_or_default();
|
||||||
let name = claims.name.unwrap_or_default();
|
let name = claims.name.unwrap_or_default();
|
||||||
@ -312,43 +316,33 @@ async fn azure_callback(
|
|||||||
let preferred_username = claims.preferred_username.unwrap_or_else(|| email.clone());
|
let preferred_username = claims.preferred_username.unwrap_or_else(|| email.clone());
|
||||||
|
|
||||||
if email.is_empty() || oid.is_empty() {
|
if email.is_empty() || oid.is_empty() {
|
||||||
return Err((
|
return Err(error_redirect("sso_error", "Missing email or oid in id_token"));
|
||||||
StatusCode::BAD_GATEWAY,
|
|
||||||
Json(
|
|
||||||
json!({ "error": { "code": "sso_error", "message": "Missing email or oid in id_token" } }),
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up or create user
|
// Look up or create user
|
||||||
let user_opt: Option<DbUserForSso> = sqlx::query_as(
|
let user_opt: Option<DbUserForSso> = match sqlx::query_as(
|
||||||
r#"SELECT id, username, display_name, role, is_active, mfa_enabled
|
r#"SELECT id, username, display_name, role, is_active, mfa_enabled
|
||||||
FROM users WHERE email = $1 AND auth_provider = 'azure_sso'"#,
|
FROM users WHERE email = $1 AND auth_provider = 'azure_sso'"#,
|
||||||
)
|
)
|
||||||
.bind(&email)
|
.bind(&email)
|
||||||
.fetch_optional(&state.db)
|
.fetch_optional(&state.db)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
{
|
||||||
|
Ok(o) => o,
|
||||||
|
Err(e) => {
|
||||||
tracing::error!(error = %e, "Failed to look up SSO user");
|
tracing::error!(error = %e, "Failed to look up SSO user");
|
||||||
(
|
return Err(error_redirect("internal_error", "Database error"));
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
}
|
||||||
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
};
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let user = match user_opt {
|
let user = match user_opt {
|
||||||
Some(u) if !u.is_active => {
|
Some(u) if !u.is_active => {
|
||||||
return Err((
|
return Err(error_redirect("account_disabled", "Account is disabled"));
|
||||||
StatusCode::FORBIDDEN,
|
|
||||||
Json(
|
|
||||||
json!({ "error": { "code": "account_disabled", "message": "Account is disabled" } }),
|
|
||||||
),
|
|
||||||
));
|
|
||||||
},
|
},
|
||||||
Some(u) => u,
|
Some(u) => u,
|
||||||
None => {
|
None => {
|
||||||
// Auto-create user with role=operator, auth_provider=azure_sso
|
// 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)
|
r#"INSERT INTO users (username, display_name, email, role, auth_provider, azure_oid)
|
||||||
VALUES ($1, $2, $3, 'operator', 'azure_sso', $4)
|
VALUES ($1, $2, $3, 'operator', 'azure_sso', $4)
|
||||||
RETURNING id"#,
|
RETURNING id"#,
|
||||||
@ -359,13 +353,13 @@ async fn azure_callback(
|
|||||||
.bind(&oid)
|
.bind(&oid)
|
||||||
.fetch_one(&state.db)
|
.fetch_one(&state.db)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
{
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(e) => {
|
||||||
tracing::error!(error = %e, "Failed to create SSO user");
|
tracing::error!(error = %e, "Failed to create SSO user");
|
||||||
(
|
return Err(error_redirect("internal_error", "Failed to create user"));
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
}
|
||||||
Json(json!({ "error": { "code": "internal_error", "message": "Failed to create user" } })),
|
};
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
&state.db,
|
&state.db,
|
||||||
@ -392,47 +386,41 @@ async fn azure_callback(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Update last_login_at and azure_oid
|
// 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",
|
"UPDATE users SET last_login_at = NOW(), azure_oid = COALESCE(azure_oid, $1) WHERE id = $2",
|
||||||
)
|
)
|
||||||
.bind(&oid)
|
.bind(&oid)
|
||||||
.bind(user.id)
|
.bind(user.id)
|
||||||
.execute(&state.db)
|
.execute(&state.db)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
{
|
||||||
tracing::error!(error = %e, "Failed to update last_login_at");
|
tracing::error!(error = %e, "Failed to update last_login_at");
|
||||||
(
|
return Err(error_redirect("internal_error", "Database error"));
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
}
|
||||||
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Issue JWT access token + refresh token
|
// Issue JWT access token + refresh token
|
||||||
let access_ttl = state.config.security.jwt_access_ttl_secs as i64;
|
let access_ttl = state.config.security.jwt_access_ttl_secs as i64;
|
||||||
let access_token = issue_access_token(
|
let access_token = match issue_access_token(
|
||||||
user.id,
|
user.id,
|
||||||
&user.username,
|
&user.username,
|
||||||
&user.role,
|
&user.role,
|
||||||
access_ttl,
|
access_ttl,
|
||||||
&state.signing_key_pem,
|
&state.signing_key_pem,
|
||||||
)
|
) {
|
||||||
.map_err(|e| {
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
tracing::error!(error = %e, "Failed to issue access token");
|
tracing::error!(error = %e, "Failed to issue access token");
|
||||||
(
|
return Err(error_redirect("internal_error", "Token issuance failed"));
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
}
|
||||||
Json(json!({ "error": { "code": "internal_error", "message": "Token issuance failed" } })),
|
};
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let raw_refresh = refresh::issue(&state.db, user.id, None, None)
|
let raw_refresh = match refresh::issue(&state.db, user.id, None, None).await {
|
||||||
.await
|
Ok(r) => r,
|
||||||
.map_err(|e| {
|
Err(e) => {
|
||||||
tracing::error!(error = %e, "Failed to issue refresh token");
|
tracing::error!(error = %e, "Failed to issue refresh token");
|
||||||
(
|
return Err(error_redirect("internal_error", "Refresh token issuance failed"));
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
}
|
||||||
Json(json!({ "error": { "code": "internal_error", "message": "Refresh token issuance failed" } })),
|
};
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
&state.db,
|
&state.db,
|
||||||
@ -447,42 +435,145 @@ async fn azure_callback(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
Ok(Json(json!({
|
// Build user JSON for query parameter
|
||||||
"access_token": access_token,
|
let user_json = json!({
|
||||||
"refresh_token": raw_refresh.0,
|
|
||||||
"token_type": "Bearer",
|
|
||||||
"expires_in": access_ttl,
|
|
||||||
"user": {
|
|
||||||
"id": user.id.to_string(),
|
"id": user.id.to_string(),
|
||||||
"username": user.username,
|
"username": user.username,
|
||||||
"display_name": user.display_name,
|
"display_name": user.display_name,
|
||||||
"role": user.role,
|
"role": user.role,
|
||||||
"mfa_enabled": user.mfa_enabled,
|
"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))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Helpers
|
// JWT Verification Helpers
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
/// Decode JWT payload without verification (trust HTTPS channel from Azure AD).
|
/// Verify the id_token JWT signature using Azure AD JWKS and validate standard claims.
|
||||||
fn decode_jwt_payload(token: &str) -> Result<IdTokenClaims, String> {
|
///
|
||||||
let parts: Vec<&str> = token.split('.').collect();
|
/// Steps:
|
||||||
if parts.len() != 3 {
|
/// 1. Decode JWT header to extract `kid` (key ID)
|
||||||
return Err("Invalid JWT format".to_string());
|
/// 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<Mutex<JwksCache>>,
|
||||||
|
) -> Result<IdTokenClaims, String> {
|
||||||
|
// 1. Decode JWT header to get the kid
|
||||||
|
let header = decode_header(token)
|
||||||
|
.map_err(|e| format!("Failed to decode JWT header: {}", e))?;
|
||||||
|
|
||||||
|
let 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
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let payload_b64 = parts[1];
|
if needs_fetch {
|
||||||
// Add padding if needed
|
// Drop lock before making async HTTP request
|
||||||
let mut payload_b64_padded = payload_b64.to_string();
|
drop(cache);
|
||||||
while payload_b64_padded.len() % 4 != 0 {
|
|
||||||
payload_b64_padded.push('=');
|
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()
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let payload_bytes = base64::engine::general_purpose::STANDARD
|
// 3. Find the matching JWK by kid
|
||||||
.decode(&payload_b64_padded)
|
let keys_array = jwks
|
||||||
.map_err(|e| format!("Base64 decode error: {}", e))?;
|
.get("keys")
|
||||||
|
.ok_or("JWKS response missing 'keys' array")?
|
||||||
|
.as_array()
|
||||||
|
.ok_or("JWKS 'keys' is not an array")?;
|
||||||
|
|
||||||
serde_json::from_slice(&payload_bytes).map_err(|e| format!("JSON parse error: {}", e))
|
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::<IdTokenClaims>(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<serde_json::Value, String> {
|
||||||
|
let jwks_url = format!(
|
||||||
|
"https://login.microsoftonline.com/{}/discovery/v2.0/keys",
|
||||||
|
tenant_id
|
||||||
|
);
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.json::<serde_json::Value>()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse JWKS response: {}", e))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,6 +40,7 @@ pub struct SettingsResponse {
|
|||||||
pub ip_whitelist: Vec<String>,
|
pub ip_whitelist: Vec<String>,
|
||||||
pub web_tls_strategy: String,
|
pub web_tls_strategy: String,
|
||||||
pub notification: NotificationConfig,
|
pub notification: NotificationConfig,
|
||||||
|
pub sso_callback_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@ -202,6 +203,7 @@ fn build_settings_response(
|
|||||||
email_from: get("notification_email_from"),
|
email_from: get("notification_email_from"),
|
||||||
recipients,
|
recipients,
|
||||||
},
|
},
|
||||||
|
sso_callback_url: get("sso_callback_url"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -269,6 +271,9 @@ async fn get_settings(
|
|||||||
) -> Result<Json<SettingsResponse>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<SettingsResponse>, (StatusCode, Json<Value>)> {
|
||||||
admin_only(&auth)?;
|
admin_only(&auth)?;
|
||||||
let cfg = load_system_config(&state.db).await?;
|
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?;
|
let azure = fetch_azure_sso_config(&state.db).await?;
|
||||||
Ok(Json(build_settings_response(&cfg, azure)))
|
Ok(Json(build_settings_response(&cfg, azure)))
|
||||||
}
|
}
|
||||||
@ -488,6 +493,9 @@ async fn update_settings(
|
|||||||
|
|
||||||
// Return updated settings
|
// Return updated settings
|
||||||
let cfg = load_system_config(&state.db).await?;
|
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?;
|
let azure = fetch_azure_sso_config(&state.db).await?;
|
||||||
Ok(Json(build_settings_response(&cfg, azure)))
|
Ok(Json(build_settings_response(&cfg, azure)))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta http-equiv="Content-Security-Policy"
|
<meta http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' wss:;" />
|
content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:; connect-src 'self' wss:;" />
|
||||||
<title>Linux Patch Manager</title>
|
<title>Linux Patch Manager</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "patch-manager-ui",
|
"name": "patch-manager-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { darkTheme } from './theme/theme'
|
|||||||
import { useAuthStore } from './store/authStore'
|
import { useAuthStore } from './store/authStore'
|
||||||
import AppLayout from './components/AppLayout'
|
import AppLayout from './components/AppLayout'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
|
import SsoCallbackPage from './pages/SsoCallbackPage'
|
||||||
import MfaSetupPage from './pages/MfaSetupPage'
|
import MfaSetupPage from './pages/MfaSetupPage'
|
||||||
import HostsPage from './pages/HostsPage'
|
import HostsPage from './pages/HostsPage'
|
||||||
import HostDetailPage from './pages/HostDetailPage'
|
import HostDetailPage from './pages/HostDetailPage'
|
||||||
@ -89,6 +90,7 @@ function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
{/* Public */}
|
{/* Public */}
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/auth/sso/callback" element={<SsoCallbackPage />} />
|
||||||
|
|
||||||
{/* Protected — wrapped in AppLayout with sidebar navigation */}
|
{/* Protected — wrapped in AppLayout with sidebar navigation */}
|
||||||
<Route element={<RequireAuth><AppLayout /></RequireAuth>}>
|
<Route element={<RequireAuth><AppLayout /></RequireAuth>}>
|
||||||
|
|||||||
@ -139,7 +139,7 @@ export default function AppLayout() {
|
|||||||
<Divider />
|
<Divider />
|
||||||
<Box sx={{ p: 1.5 }}>
|
<Box sx={{ p: 1.5 }}>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
Linux Patch Manager v0.1.0
|
Linux Patch Manager v0.1.3
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@ -4,11 +4,13 @@ import {
|
|||||||
Box, Button, Container, TextField, Typography,
|
Box, Button, Container, TextField, Typography,
|
||||||
Alert, CircularProgress, Paper, InputAdornment, IconButton,
|
Alert, CircularProgress, Paper, InputAdornment, IconButton,
|
||||||
List, ListItem, ListItemIcon, ListItemText,
|
List, ListItem, ListItemIcon, ListItemText,
|
||||||
|
Divider,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import {
|
import {
|
||||||
Visibility, VisibilityOff,
|
Visibility, VisibilityOff,
|
||||||
Check as CheckIcon, Close as CloseIcon,
|
Check as CheckIcon, Close as CloseIcon,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
|
import { Cloud as CloudIcon } from '@mui/icons-material'
|
||||||
import { authApi } from '../api/client'
|
import { authApi } from '../api/client'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
import type { User } from '../types'
|
import type { User } from '../types'
|
||||||
@ -323,6 +325,15 @@ export default function LoginPage() {
|
|||||||
>
|
>
|
||||||
{loading ? <CircularProgress size={24} /> : 'Sign In'}
|
{loading ? <CircularProgress size={24} /> : 'Sign In'}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Divider sx={{ my: 3 }}>or</Divider>
|
||||||
|
<Button
|
||||||
|
fullWidth variant="outlined" size="large"
|
||||||
|
startIcon={<CloudIcon />}
|
||||||
|
onClick={() => { window.location.href = '/api/v1/auth/azure/login' }}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Sign in with Microsoft Azure
|
||||||
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
import { ContentCopy as CopyIcon } from '@mui/icons-material'
|
import { ContentCopy as CopyIcon } from '@mui/icons-material'
|
||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
import { authApi } from '../api/client'
|
import { authApi } from '../api/client'
|
||||||
|
import { useAuthStore } from '../store/authStore'
|
||||||
|
|
||||||
const STEPS = ['Get your QR code', 'Verify code', 'Done']
|
const STEPS = ['Get your QR code', 'Verify code', 'Done']
|
||||||
|
|
||||||
@ -23,6 +24,7 @@ export default function MfaSetupPage() {
|
|||||||
authApi.getMfaSetup()
|
authApi.getMfaSetup()
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setSetup(res.data)
|
setSetup(res.data)
|
||||||
|
console.log('[MFA Setup] Success:', res.status, res.data)
|
||||||
// Generate QR code from otpauth URI
|
// Generate QR code from otpauth URI
|
||||||
if (res.data.otp_uri) {
|
if (res.data.otp_uri) {
|
||||||
QRCode.toDataURL(res.data.otp_uri, {
|
QRCode.toDataURL(res.data.otp_uri, {
|
||||||
@ -31,10 +33,31 @@ export default function MfaSetupPage() {
|
|||||||
color: { dark: '#000000', light: '#ffffff' },
|
color: { dark: '#000000', light: '#ffffff' },
|
||||||
})
|
})
|
||||||
.then((url) => setQrDataUrl(url))
|
.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 = () => {
|
const handleCopySecret = () => {
|
||||||
|
|||||||
105
frontend/src/pages/SsoCallbackPage.tsx
Normal file
105
frontend/src/pages/SsoCallbackPage.tsx
Normal file
@ -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<string | null>(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<string, unknown>
|
||||||
|
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 (
|
||||||
|
<Container maxWidth="xs" sx={{ mt: 12 }}>
|
||||||
|
<Paper elevation={4} sx={{ p: 4 }}>
|
||||||
|
<Typography variant="h5" fontWeight={700} mb={3} align="center">
|
||||||
|
🐉 Linux Patch Manager
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{processing ? (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 4 }}>
|
||||||
|
<CircularProgress size={48} sx={{ mb: 2 }} />
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
Completing sign-in…
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box>
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
onClick={() => navigate('/login', { replace: true })}
|
||||||
|
>
|
||||||
|
Back to Login
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState, useMemo } from 'react'
|
import { useEffect, useState, useMemo } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Box, Button, Chip, CircularProgress, Container, Dialog, DialogActions,
|
Box, Button, Chip, CircularProgress, Container, Dialog, DialogActions,
|
||||||
DialogContent, DialogContentText, DialogTitle, FormControlLabel, IconButton,
|
DialogContent, DialogContentText, DialogTitle, FormControlLabel, IconButton,
|
||||||
@ -75,6 +76,7 @@ function PasswordStrengthIndicator({ password }: { password: string }) {
|
|||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const currentUser = useAuthStore(s => s.user)
|
const currentUser = useAuthStore(s => s.user)
|
||||||
|
const navigate = useNavigate()
|
||||||
const isAdmin = currentUser?.role === 'admin'
|
const isAdmin = currentUser?.role === 'admin'
|
||||||
|
|
||||||
const [users, setUsers] = useState<User[]>([])
|
const [users, setUsers] = useState<User[]>([])
|
||||||
@ -327,8 +329,17 @@ export default function UsersPage() {
|
|||||||
color={u.role === 'admin' ? 'primary' : 'default'} />
|
color={u.role === 'admin' ? 'primary' : 'default'} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Chip size="small" label={u.mfa_enabled ? 'On' : 'Off'}
|
{u.mfa_enabled ? (
|
||||||
color={u.mfa_enabled ? 'success' : 'warning'} />
|
<Chip size="small" label="On" color="success" />
|
||||||
|
) : currentUser?.id === u.id ? (
|
||||||
|
<Tooltip title="Enable MFA">
|
||||||
|
<Chip size="small" label="Off" color="warning"
|
||||||
|
sx={{ cursor: 'pointer', '&:hover': { opacity: 0.8 } }}
|
||||||
|
onClick={() => navigate('/mfa/setup')} />
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Chip size="small" label="Off" color="default" />
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Chip size="small" label={u.is_active ? 'Active' : 'Disabled'}
|
<Chip size="small" label={u.is_active ? 'Active' : 'Disabled'}
|
||||||
@ -460,24 +471,41 @@ export default function UsersPage() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* MFA status & disable */}
|
{/* MFA status */}
|
||||||
<Box sx={{ mt: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ mt: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Typography variant="body2" color="text.secondary">MFA Status:</Typography>
|
<Typography variant="body2" color="text.secondary">MFA Status:</Typography>
|
||||||
<Chip size="small"
|
<Chip size="small"
|
||||||
label={editUser?.mfa_enabled ? 'Enabled' : 'Disabled'}
|
label={editUser?.mfa_enabled ? 'Enabled' : 'Disabled'}
|
||||||
color={editUser?.mfa_enabled ? 'success' : 'default'}
|
color={editUser?.mfa_enabled ? 'success' : 'default'}
|
||||||
/>
|
/>
|
||||||
{editUser?.mfa_enabled && (
|
{editUser?.mfa_enabled ? (
|
||||||
<Button size="small" color="error" variant="outlined"
|
<Button size="small" color="error" variant="outlined"
|
||||||
onClick={() => editUser && handleMfaDisable(editUser)}>
|
onClick={() => editUser && handleMfaDisable(editUser)}>
|
||||||
Disable MFA
|
Disable MFA
|
||||||
</Button>
|
</Button>
|
||||||
|
) : (
|
||||||
|
currentUser?.id === editUser?.id ? (
|
||||||
|
<Button size="small" color="primary" variant="outlined"
|
||||||
|
onClick={() => navigate('/mfa/setup')}>
|
||||||
|
Enable MFA
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
User must enable MFA from their own profile settings.
|
||||||
|
</Typography>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{editUser?.mfa_enabled && (
|
{editUser?.mfa_enabled ? (
|
||||||
<Typography variant="caption" color="warning.main" sx={{ display: 'block', mt: 0.5 }}>
|
<Typography variant="caption" color="warning.main" sx={{ display: 'block', mt: 0.5 }}>
|
||||||
Disabling MFA reduces account security for this user.
|
Disabling MFA reduces account security for this user.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
) : (
|
||||||
|
currentUser?.id === editUser?.id && (
|
||||||
|
<Typography variant="caption" color="info.main" sx={{ display: 'block', mt: 0.5 }}>
|
||||||
|
You will be guided through authenticator app setup.
|
||||||
|
</Typography>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,5 +1,27 @@
|
|||||||
# Linux Patch Manager — Lessons Learned
|
# 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
|
## 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.
|
**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.
|
**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.
|
**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.
|
**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.
|
**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
|
||||||
@ -1,61 +1,45 @@
|
|||||||
# Target Host for Service Health Checks
|
# SSO Implementation Fix Plan
|
||||||
|
|
||||||
## Overview
|
## Issues Identified
|
||||||
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.
|
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
|
### Phase 2: Frontend SSO Integration (Issues 1, 2, 3) — COMPLETE ✅
|
||||||
- [ ] Create `migrations/011_health_check_target_host.sql`
|
- [x] 2a: Add SSO callback page component (SsoCallbackPage.tsx)
|
||||||
- [ ] Add `target_host_id UUID REFERENCES hosts(id) ON DELETE SET NULL` column
|
- [x] 2b: Add SSO callback route to App.tsx (public route, no auth required)
|
||||||
- [ ] Add partial index on `target_host_id` where NOT NULL
|
- [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`)
|
### Phase 3: JWT Signature Verification (Issue 6) — COMPLETE ✅
|
||||||
- [ ] Add `target_host_id: Option<Uuid>` to `HealthCheck` struct
|
- [x] 3a: Add JWKS client dependency to pm-web/Cargo.toml
|
||||||
- [ ] Add `target_host_id: Option<Uuid>` to `CreateHealthCheckRequest`
|
- [x] 3b: Implement id_token signature verification in azure_sso.rs
|
||||||
- [ ] Add `target_host_id: Option<Uuid>` to `UpdateHealthCheckRequest`
|
- [x] 3c: Verify backend compiles with `cargo check`
|
||||||
- [ ] Add `target_host_id` to all HealthCheck SELECT queries
|
|
||||||
|
|
||||||
### 3. API Routes (`crates/pm-web/src/routes/health_checks.rs`)
|
### Phase 4: Integration Testing and Verification — COMPLETE ✅
|
||||||
- [ ] Create: add `target_host_id` to INSERT, validate target host exists + is healthy
|
- [x] 4a: Backend code review — all changes verified manually
|
||||||
- [ ] Update: add `target_host_id` to COALESCE UPDATE
|
- [x] 4b: Frontend TypeScript compilation — passes cleanly
|
||||||
- [ ] List/Get: add `target_host_id` to SELECT columns
|
- [x] 4c: SSO login flow reviewed end-to-end (backend redirect → frontend callback → auth store)
|
||||||
- [ ] Test endpoint (`run_service_check`): when `target_host_id` is Some, query that host's IP/port
|
- [x] 4d: SSO session cleanup verified (10-minute expiry, 60-second purge interval)
|
||||||
- [ ] Audit log: include `target_host_id` in audit JSON
|
- [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`)
|
## Lessons Learned
|
||||||
- [ ] Add `target_host_id: Option<Uuid>` to `HealthCheckRow`
|
- **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.
|
||||||
- [ ] 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)
|
- **URLSearchParams.get() already decodes** — Don't double-decode with decodeURIComponent() when using URLSearchParams.
|
||||||
- [ ] Add `target_ip_address` and `target_agent_port` fields to HealthCheckRow
|
- **JWKS caching prevents rate-limiting** — Azure AD JWKS endpoint should be cached with TTL (1 hour) to avoid fetching on every SSO login.
|
||||||
- [ ] `run_service_check`: use target host IP/port when available
|
- **tokio::sync::Mutex over std::sync::Mutex** — Axum handlers must be Send; std::sync::MutexGuard is not Send across await points.
|
||||||
- [ ] `check_host_health_checks`: no change needed (results count toward owning host)
|
- **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.
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user