@@ -125,6 +125,12 @@ pub struct AppState {
125125 pub alert_log: Arc<std::sync::RwLock<Vec<AlertLogEntry>>>,
126126 /// Password reset tokens (in-memory, 30-minute expiry)
127127 pub password_reset_tokens: Arc<crate::auth::PasswordResetTokens>,
128+ /// OIDC pending authentication flows (state token → flow data, 5-minute TTL)
129+ pub oidc_pending_flows: Arc<std::sync::RwLock<std::collections::HashMap<String, crate::auth::oidc::OidcPendingFlow>>>,
130+ /// Image update watcher cache (container name → last check result)
131+ pub image_watcher_cache: Arc<std::sync::RwLock<std::collections::HashMap<String, crate::containers::image_watcher::ImageCheckResult>>>,
132+ /// Integration framework state
133+ pub integrations: Arc<crate::integrations::IntegrationState>,
128134}
129135
130136#[derive(Clone, Serialize, Deserialize)]
@@ -15198,6 +15204,261 @@ pub async fn token_events(req: HttpRequest, state: web::Data<AppState>) -> HttpR
1519815204 HttpResponse::Ok().json(entries)
1519915205}
1520015206
15207+ // ═══════════════════════════════════════════════════════════════════════════
15208+ // ─── OIDC Authentication Endpoints ───
15209+ // ═══════════════════════════════════════════════════════════════════════════
15210+
15211+ /// GET /api/auth/oidc/providers — list available OIDC providers (no auth)
15212+ pub async fn oidc_providers() -> HttpResponse {
15213+ if !crate::compat::platform_ready() {
15214+ return HttpResponse::Ok().json(serde_json::json!([]));
15215+ }
15216+ let config = crate::auth::oidc::OidcConfig::load();
15217+ if !config.enabled {
15218+ return HttpResponse::Ok().json(serde_json::json!([]));
15219+ }
15220+ let providers: Vec<serde_json::Value> = config.providers.iter()
15221+ .filter(|p| p.enabled)
15222+ .map(|p| serde_json::json!({
15223+ "id": p.id,
15224+ "name": p.name,
15225+ "button_label": format!("Sign in with {}", p.name),
15226+ }))
15227+ .collect();
15228+ HttpResponse::Ok().json(providers)
15229+ }
15230+
15231+ /// GET /api/auth/oidc/login/{provider_id} — initiate OIDC login (no auth, returns 302)
15232+ pub async fn oidc_login(
15233+ req: HttpRequest,
15234+ state: web::Data<AppState>,
15235+ path: web::Path<String>,
15236+ ) -> HttpResponse {
15237+ let provider_id = path.into_inner();
15238+ let config = crate::auth::oidc::OidcConfig::load();
15239+ if !config.enabled {
15240+ return HttpResponse::BadRequest().json(serde_json::json!({ "error": "OIDC is not enabled" }));
15241+ }
15242+ let provider = match config.providers.iter().find(|p| p.id == provider_id && p.enabled) {
15243+ Some(p) => p.clone(),
15244+ None => return HttpResponse::NotFound().json(serde_json::json!({ "error": "Provider not found" })),
15245+ };
15246+
15247+ let conn = req.connection_info();
15248+ let redirect_uri_base = format!("{}://{}", conn.scheme(), conn.host());
15249+
15250+ match crate::auth::oidc::build_auth_url(&provider, &redirect_uri_base).await {
15251+ Ok((auth_url, pending)) => {
15252+ let csrf_state = pending.csrf_state.clone();
15253+ state.oidc_pending_flows.write().unwrap().insert(csrf_state, pending);
15254+ HttpResponse::Found().append_header(("Location", auth_url)).finish()
15255+ }
15256+ Err(e) => {
15257+ error!("OIDC build_auth_url failed: {}", e);
15258+ HttpResponse::InternalServerError().json(serde_json::json!({ "error": e }))
15259+ }
15260+ }
15261+ }
15262+
15263+ #[derive(Deserialize)]
15264+ pub struct OidcCallbackQuery {
15265+ pub code: String,
15266+ pub state: String,
15267+ }
15268+
15269+ /// GET /api/auth/oidc/callback — OIDC callback (no auth, returns 302 to dashboard)
15270+ pub async fn oidc_callback(
15271+ req: HttpRequest,
15272+ state: web::Data<AppState>,
15273+ query: web::Query<OidcCallbackQuery>,
15274+ ) -> HttpResponse {
15275+ // Look up and remove the pending flow (single use)
15276+ let pending = {
15277+ let mut flows = state.oidc_pending_flows.write().unwrap();
15278+ flows.remove(&query.state)
15279+ };
15280+ let pending = match pending {
15281+ Some(p) => p,
15282+ None => return HttpResponse::BadRequest().json(serde_json::json!({
15283+ "error": "Invalid or expired OIDC state token"
15284+ })),
15285+ };
15286+
15287+ // Find the provider
15288+ let config = crate::auth::oidc::OidcConfig::load();
15289+ let provider = match config.providers.iter().find(|p| p.id == pending.provider_id) {
15290+ Some(p) => p.clone(),
15291+ None => return HttpResponse::InternalServerError().json(serde_json::json!({
15292+ "error": "OIDC provider no longer configured"
15293+ })),
15294+ };
15295+
15296+ let conn = req.connection_info();
15297+ let redirect_uri_base = format!("{}://{}", conn.scheme(), conn.host());
15298+
15299+ // Exchange the authorization code for claims
15300+ let claims = match crate::auth::oidc::exchange_code(
15301+ &provider, &query.code, &pending, &redirect_uri_base, &state.cluster_secret,
15302+ ).await {
15303+ Ok(c) => c,
15304+ Err(e) => {
15305+ error!("OIDC code exchange failed: {}", e);
15306+ return HttpResponse::InternalServerError().json(serde_json::json!({ "error": e }));
15307+ }
15308+ };
15309+
15310+ // Extract username from claims (preferred_username → email → sub)
15311+ let username = claims.get("preferred_username")
15312+ .or_else(|| claims.get("email"))
15313+ .or_else(|| claims.get("sub"))
15314+ .and_then(|v| v.as_str())
15315+ .unwrap_or("oidc-user")
15316+ .to_string();
15317+
15318+ // Map IdP claims to a WolfStack role using the provider's role_claim + role_mappings
15319+ let role = crate::auth::oidc::map_claims_to_role(&claims, &provider);
15320+ tracing::info!("OIDC login: user='{}', provider='{}', role='{}'", username, provider.id, role);
15321+
15322+ // Create session and set cookie
15323+ let token = state.sessions.create_session(&username);
15324+ let mut cookie = Cookie::build("wolfstack_session", &token)
15325+ .path("/")
15326+ .http_only(true)
15327+ .same_site(actix_web::cookie::SameSite::Lax)
15328+ .max_age(actix_web::cookie::time::Duration::hours(8))
15329+ .finish();
15330+ if state.tls_enabled {
15331+ cookie.set_secure(true);
15332+ }
15333+
15334+ HttpResponse::Found()
15335+ .cookie(cookie)
15336+ .append_header(("Location", "/index.html"))
15337+ .finish()
15338+ }
15339+
15340+ // ═══════════════════════════════════════════════════════════════════════════
15341+ // ─── Image Watcher Endpoints ───
15342+ // ═══════════════════════════════════════════════════════════════════════════
15343+
15344+ /// GET /api/image-watcher/config
15345+ pub async fn image_watcher_config_get(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
15346+ if let Err(resp) = require_auth(&req, &state) { return resp; }
15347+ HttpResponse::Ok().json(crate::containers::image_watcher::ImageWatcherConfig::load())
15348+ }
15349+
15350+ /// PUT /api/image-watcher/config
15351+ pub async fn image_watcher_config_save(
15352+ req: HttpRequest,
15353+ state: web::Data<AppState>,
15354+ body: web::Json<crate::containers::image_watcher::ImageWatcherConfig>,
15355+ ) -> HttpResponse {
15356+ if let Err(resp) = require_auth(&req, &state) { return resp; }
15357+ match body.into_inner().save() {
15358+ Ok(()) => HttpResponse::Ok().json(serde_json::json!({ "message": "Image watcher config saved" })),
15359+ Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e })),
15360+ }
15361+ }
15362+
15363+ /// GET /api/image-watcher/status
15364+ pub async fn image_watcher_status(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
15365+ if let Err(resp) = require_auth(&req, &state) { return resp; }
15366+ let cache = state.image_watcher_cache.read().unwrap();
15367+ let results: Vec<&crate::containers::image_watcher::ImageCheckResult> = cache.values().collect();
15368+ HttpResponse::Ok().json(results)
15369+ }
15370+
15371+ /// POST /api/image-watcher/check/{container}
15372+ pub async fn image_watcher_check(
15373+ req: HttpRequest,
15374+ state: web::Data<AppState>,
15375+ path: web::Path<String>,
15376+ ) -> HttpResponse {
15377+ if let Err(resp) = require_auth(&req, &state) { return resp; }
15378+ let container = path.into_inner();
15379+ match crate::containers::image_watcher::check_container_update(&container).await {
15380+ Ok(result) => {
15381+ state.image_watcher_cache.write().unwrap()
15382+ .insert(result.container_name.clone(), result.clone());
15383+ HttpResponse::Ok().json(result)
15384+ }
15385+ Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e })),
15386+ }
15387+ }
15388+
15389+ // ═══════════════════════════════════════════════════════════════════════════
15390+ // ─── Integration Framework Endpoints ───
15391+ // ═══════════════════════════════════════════════════════════════════════════
15392+
15393+ /// GET /api/integrations/connectors — list available connector types
15394+ pub async fn integrations_connectors(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
15395+ if let Err(resp) = require_auth(&req, &state) { return resp; }
15396+ let infos: Vec<crate::integrations::ConnectorInfo> = state.integrations.connectors
15397+ .values().map(|c| c.info()).collect();
15398+ HttpResponse::Ok().json(infos)
15399+ }
15400+
15401+ /// GET /api/integrations — list configured integration instances
15402+ pub async fn integrations_list(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
15403+ if let Err(resp) = require_auth(&req, &state) { return resp; }
15404+ let instances = state.integrations.list_instances();
15405+ let result: Vec<serde_json::Value> = instances.iter().map(|inst| {
15406+ let health = state.integrations.get_health(&inst.id);
15407+ serde_json::json!({
15408+ "instance": inst,
15409+ "health": health,
15410+ })
15411+ }).collect();
15412+ HttpResponse::Ok().json(result)
15413+ }
15414+
15415+ /// POST /api/integrations — create a new integration instance
15416+ pub async fn integrations_create(
15417+ req: HttpRequest,
15418+ state: web::Data<AppState>,
15419+ body: web::Json<crate::integrations::IntegrationInstance>,
15420+ ) -> HttpResponse {
15421+ if let Err(resp) = require_auth(&req, &state) { return resp; }
15422+ match state.integrations.create_instance(body.into_inner()) {
15423+ Ok(inst) => HttpResponse::Ok().json(inst),
15424+ Err(e) => HttpResponse::BadRequest().json(serde_json::json!({ "error": e })),
15425+ }
15426+ }
15427+
15428+ /// GET /api/integrations/{id} — get a single integration instance
15429+ pub async fn integrations_get(
15430+ req: HttpRequest,
15431+ state: web::Data<AppState>,
15432+ path: web::Path<String>,
15433+ ) -> HttpResponse {
15434+ if let Err(resp) = require_auth(&req, &state) { return resp; }
15435+ let id = path.into_inner();
15436+ match state.integrations.get_instance(&id) {
15437+ Some(inst) => {
15438+ let health = state.integrations.get_health(&id);
15439+ HttpResponse::Ok().json(serde_json::json!({
15440+ "instance": inst,
15441+ "health": health,
15442+ }))
15443+ }
15444+ None => HttpResponse::NotFound().json(serde_json::json!({ "error": "Integration not found" })),
15445+ }
15446+ }
15447+
15448+ /// DELETE /api/integrations/{id} — delete an integration instance
15449+ pub async fn integrations_delete(
15450+ req: HttpRequest,
15451+ state: web::Data<AppState>,
15452+ path: web::Path<String>,
15453+ ) -> HttpResponse {
15454+ if let Err(resp) = require_auth(&req, &state) { return resp; }
15455+ let id = path.into_inner();
15456+ match state.integrations.delete_instance(&id) {
15457+ Ok(()) => HttpResponse::Ok().json(serde_json::json!({ "message": "Integration deleted" })),
15458+ Err(e) => HttpResponse::NotFound().json(serde_json::json!({ "error": e })),
15459+ }
15460+ }
15461+
1520115462/// Configure all API routes
1520215463pub fn configure(cfg: &mut web::ServiceConfig) {
1520315464 cfg
@@ -15212,6 +15473,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
1521215473 .route("/api/auth/smtp-configured", web::get().to(smtp_configured))
1521315474 .route("/api/auth/forgot-password", web::post().to(forgot_password))
1521415475 .route("/api/auth/reset-password", web::post().to(reset_password))
15476+ // OIDC Authentication (no auth required)
15477+ .route("/api/auth/oidc/providers", web::get().to(oidc_providers))
15478+ .route("/api/auth/oidc/login/{provider_id}", web::get().to(oidc_login))
15479+ .route("/api/auth/oidc/callback", web::get().to(oidc_callback))
1521515480 // User management (auth required)
1521615481 .route("/api/auth/config", web::get().to(get_auth_config))
1521715482 .route("/api/auth/config", web::post().to(save_auth_config))
@@ -15268,6 +15533,11 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
1526815533 .route("/api/containers/{runtime}/{id}/updates/check", web::post().to(container_updates_check))
1526915534 .route("/api/containers/{runtime}/{id}/updates/apply", web::post().to(container_updates_apply))
1527015535 .route("/api/containers/{runtime}/{id}/exec", web::post().to(container_exec))
15536+ // Image Watcher (auth required)
15537+ .route("/api/image-watcher/config", web::get().to(image_watcher_config_get))
15538+ .route("/api/image-watcher/config", web::put().to(image_watcher_config_save))
15539+ .route("/api/image-watcher/status", web::get().to(image_watcher_status))
15540+ .route("/api/image-watcher/check/{container}", web::post().to(image_watcher_check))
1527115541 // Certificates
1527215542 .route("/api/certificates", web::post().to(request_certificate))
1527315543 .route("/api/certificates/list", web::get().to(list_certificates))
@@ -15604,6 +15874,12 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
1560415874 .route("/api/alerts/config", web::get().to(alerts_config_get))
1560515875 .route("/api/alerts/config", web::post().to(alerts_config_save))
1560615876 .route("/api/alerts/test", web::post().to(alerts_test))
15877+ // Integrations (auth required)
15878+ .route("/api/integrations/connectors", web::get().to(integrations_connectors))
15879+ .route("/api/integrations", web::get().to(integrations_list))
15880+ .route("/api/integrations", web::post().to(integrations_create))
15881+ .route("/api/integrations/{id}", web::get().to(integrations_get))
15882+ .route("/api/integrations/{id}", web::delete().to(integrations_delete))
1560715883 // WolfRun — container orchestration
1560815884 .route("/api/wolfrun/services", web::get().to(wolfrun_list))
1560915885 .route("/api/wolfrun/services", web::post().to(wolfrun_create))
0 commit comments