//! Forms auth layer — sessions, login, setup, middleware. //! //! Three states: //! //! * **Unconfigured** (`AdminStore::is_configured() == false`). The //! middleware passes every request through — there's no one to gate //! against. The UI's `/api/me` returns `setup_required: true` and the //! front-end pushes the operator into the first-run flow. //! * **Logged in**. The session cookie maps to an in-memory session //! record with an idle expiry; `/api/me` returns the username. //! * **Logged out**. The middleware bounces `/api/*` (with the PXE //! allowlist below) to `401 Unauthorized`; the front-end intercepts //! that and shows `/login`. //! //! Allowlist for unauthenticated access *after* the admin is set up: //! //! * everything outside `/api/*` (the WebUI bundle, asset chrome, PXE //! script endpoints, the bundled iPXE/wimboot binaries, ISO bytes, //! liveness/readiness probes, the Prometheus scrape) — these are //! read-only or PXE-essential and breaking them locks out booting //! machines that have no way to authenticate; //! * `/api/setup`, `/api/login`, `/api/me` (the auth surface itself); //! * `/api/queue/join`, `/api/queue/poll/:entry_id` (iPXE long-poll for //! Queued Deployment — the iPXE client can't send a session cookie). //! //! Everything else inside `/api/*` requires a valid session. use crate::state::AppState; use axum::{ body::Body, extract::{Request, State}, http::{header, HeaderValue, StatusCode}, middleware::Next, response::{IntoResponse, Response}, Json, }; use openpxe_core::AdminPublic; use parking_lot::RwLock; use serde::{Deserialize, Serialize}; use serde_json::json; use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, Instant}; use uuid::Uuid; /// Idle session lifetime. Sliding — every authenticated request resets /// the expiry. 24h is the Sonarr default and matches what most operators /// expect for an on-prem admin console. const SESSION_TTL: Duration = Duration::from_hours(24); /// Name of the cookie we set/read. Distinct from a generic `session=` /// to avoid collisions with anything else sharing the host. pub const SESSION_COOKIE: &str = "openpxe_session"; #[derive(Debug, Clone)] struct Session { username: String, expires_at: Instant, } /// In-memory session table. Cheap to clone (Arc-shared) and contention /// is rare — operators sign in once per browser session. #[derive(Debug, Clone, Default)] pub struct SessionStore { inner: Arc>>, } impl SessionStore { /// Mint a fresh session for `username` and return the opaque cookie /// value. UUID v4 gives us 122 random bits — comfortably more than /// the 64-128 bits typical for session IDs. #[must_use] pub fn create(&self, username: &str) -> String { let id = Uuid::new_v4().simple().to_string(); let session = Session { username: username.to_string(), expires_at: Instant::now() + SESSION_TTL, }; self.inner.write().insert(id.clone(), session); id } /// Resolve a cookie value to the owning username, refreshing the /// idle timer. Returns `None` for missing / expired sessions and /// proactively evicts the expired entry so the map doesn't grow /// unbounded across long-lived deployments. pub fn touch(&self, id: &str) -> Option { let mut g = self.inner.write(); let s = g.get_mut(id)?; if s.expires_at <= Instant::now() { g.remove(id); return None; } s.expires_at = Instant::now() + SESSION_TTL; Some(s.username.clone()) } /// Invalidate one session (the user's `/api/logout`). pub fn revoke(&self, id: &str) { self.inner.write().remove(id); } /// Invalidate every session — used after a credentials rotation so /// stale cookies for the old password can't keep operating. pub fn revoke_all(&self) { self.inner.write().clear(); } /// Periodic / opportunistic GC. Not currently scheduled (we evict /// on touch), but exposed for a future janitor task. pub fn gc(&self) { let now = Instant::now(); self.inner.write().retain(|_, s| s.expires_at > now); } #[must_use] pub fn len(&self) -> usize { self.inner.read().len() } #[must_use] pub fn is_empty(&self) -> bool { self.len() == 0 } } // ── Cookie helpers ──────────────────────────────────────────────────────── fn cookie_attrs(value: &str, max_age: Option) -> String { // Same flags FleetDM and Sonarr ship by default: // - HttpOnly: blocks JS access (XSS containment) // - SameSite=Lax: allows top-level GET navigations from the IdP // to land authenticated when SSO arrives, but blocks // cross-site POST CSRF; // - Path=/: the cookie applies to the whole app; // - no Secure flag yet — many operators host on plain http:// // LAN IPs (Unraid templates default to that); we'll add Secure // opportunistically when we add a TLS terminator option. // SESSION_TTL fits in 32 bits comfortably (24h ≈ 86400 seconds); we // never overflow i64, but clippy's `cast_possible_wrap` lint wants // us to be explicit. `cast_signed` is the documented form. let lifetime = max_age.unwrap_or_else(|| SESSION_TTL.as_secs().cast_signed()); format!("{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={lifetime}") } /// Build the `Set-Cookie` header value that establishes a fresh operator /// session with the default 24h TTL. Exposed so the SAML ACS handler can /// attach an operator session to its post-login redirect, exactly as the /// Forms-login path does via [`login_response`]. #[must_use] pub fn session_cookie(session: &str) -> String { cookie_attrs(session, None) } fn parse_cookie(headers: &axum::http::HeaderMap) -> Option { // `Cookie: a=b; c=d` parsing — small enough not to drag in a crate. // Two-step strip (name, then '=') keeps this allocation-free per // candidate and can't match a longer cookie name sharing the prefix. let raw = headers.get(header::COOKIE)?.to_str().ok()?; for part in raw.split(';') { let part = part.trim(); if let Some(v) = part .strip_prefix(SESSION_COOKIE) .and_then(|rest| rest.strip_prefix('=')) { return Some(v.to_string()); } } None } /// Does this request carry a live operator session? Used by endpoints /// outside the `/api/*` middleware that still want to honor a logged-in /// operator (e.g. browser-testing a token-gated answer file, v0.7.0). pub(crate) fn session_authenticated(state: &AppState, headers: &axum::http::HeaderMap) -> bool { parse_cookie(headers).is_some_and(|t| state.sessions.touch(&t).is_some()) } // ── Middleware ──────────────────────────────────────────────────────────── /// Return `true` if `path` is on the allowlist and should bypass the /// session check. The middleware applies this rule only when the admin /// account is configured; before then everything is open. fn is_public_path(path: &str) -> bool { // Non-API paths: WebUI bundle, PXE chain, ISO bytes, health probes, // metrics. All read-only / PXE-essential. if !path.starts_with("/api/") { return true; } // Auth surface and iPXE long-poll endpoints (no cookie available). // The SAML SP endpoints are pre-auth by nature — the operator hasn't a // session yet when they start (or arrive from) the IdP. `/api/sso` // (the config GET/PUT, no trailing slash) stays gated. matches!( path, "/api/setup" | "/api/login" | "/api/logout" | "/api/me" | "/api/sso/login" | "/api/sso/acs" | "/api/sso/metadata" ) || path.starts_with("/api/queue/join") || path.starts_with("/api/queue/poll/") } /// Axum middleware: gate `/api/*` behind a valid session, with the /// allowlist above. `State` reaches in for the admin store + /// session store. pub async fn require_auth( State(state): State, req: Request, next: Next, ) -> Response { // Bypass entirely while unconfigured. The /api/setup endpoint is // the only one that can flip this back to "configured", and it // refuses to run a second time. Tests + fresh installs ride this // path. if !state.admin.is_configured() { return next.run(req).await; } let path = req.uri().path(); if is_public_path(path) { return next.run(req).await; } // Authenticated path. The cookie must be present, map to a live // session, and the TTL refresh happens as a side-effect. let token = parse_cookie(req.headers()); if let Some(t) = token { if state.sessions.touch(&t).is_some() { return next.run(req).await; } } ( StatusCode::UNAUTHORIZED, Json(json!({ "error": "authentication required" })), ) .into_response() } // ── Handlers ────────────────────────────────────────────────────────────── #[derive(Debug, Deserialize)] pub struct SetupBody { pub username: String, pub password: String, } /// First-run setup. Refuses to run once an admin already exists — that /// guards against a leaked WebUI being re-bootstrapped by an attacker /// who's seen the deployment URL. After bootstrap, the new session /// cookie is set so the operator goes straight to the dashboard. pub async fn api_setup(State(state): State, Json(body): Json) -> Response { if state.admin.is_configured() { return ( StatusCode::CONFLICT, Json(json!({ "error": "admin account already configured" })), ) .into_response(); } // bcrypt hashing is ~100-200 ms of pure CPU (and `bootstrap` also // persists to disk synchronously) — keep it off the async workers. let admin = state.admin.clone(); let result = tokio::task::spawn_blocking(move || admin.bootstrap(&body.username, &body.password)) .await .unwrap_or_else(|e| Err(openpxe_core::Error::Other(e.into()))); match result { Ok(pub_) => { let session = state.sessions.create(&pub_.username); login_response(StatusCode::CREATED, &pub_, &session) } Err(openpxe_core::Error::Invalid(msg)) => { (StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response() } Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("{e}") })), ) .into_response(), } } #[derive(Debug, Deserialize)] pub struct LoginBody { pub username: String, pub password: String, } pub async fn api_login(State(state): State, Json(body): Json) -> Response { // Brief, deliberately vague — "invalid credentials" rather than // "no such user" / "wrong password". Same anti-enumeration posture // as Sonarr/Radarr. The bcrypt verify is ~100-200 ms of pure CPU on // an unauthenticated endpoint, so it runs on the blocking pool. let admin = state.admin.clone(); let verdict = tokio::task::spawn_blocking(move || admin.verify(&body.username, &body.password)) .await .unwrap_or_else(|e| Err(openpxe_core::Error::Other(e.into()))); let pub_ = match verdict { Ok(Some(u)) => u, Ok(None) => { return ( StatusCode::UNAUTHORIZED, Json(json!({ "error": "invalid username or password" })), ) .into_response(); } Err(e) => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("{e}") })), ) .into_response(); } }; let session = state.sessions.create(&pub_.username); login_response(StatusCode::OK, &pub_, &session) } pub async fn api_logout(State(state): State, headers: axum::http::HeaderMap) -> Response { if let Some(t) = parse_cookie(&headers) { state.sessions.revoke(&t); } // Stomp the cookie unconditionally — even if the request didn't // carry one, the browser shouldn't keep a stale value. let mut resp = StatusCode::NO_CONTENT.into_response(); resp.headers_mut().insert( header::SET_COOKIE, HeaderValue::from_str(&cookie_attrs("", Some(0))).unwrap(), ); resp } /// Status surface for the front-end shell. Returns four cases: /// /// * `setup_required: true` — no admin yet; show first-run page. /// * `authenticated: false` — admin exists, no session; show login. /// * `authenticated: true` + `user` — let the dashboard load. pub async fn api_me(State(state): State, headers: axum::http::HeaderMap) -> Response { // v0.5.0: include branding bootstrap so the pre-auth login/setup // screens can render the FleetDM-style full-width custom logo (and // cache-bust it) without an extra round trip. `/api/me` is public, // and the logo asset is public, so this leaks nothing sensitive. let has_custom_logo = state.branding.has_any_web_logo(); let logo_rev = state.branding.logo_rev(); // v0.5.9: ship the non-sensitive SSO descriptor with every /api/me so // the pre-auth login screen can render the "Sign in with …" button // reliably. Previously the button keyed off the auth-gated /api/sso, // which 401s when logged out — the button only survived on a stale // in-memory config and vanished on any fresh login-page load. let sso = state.sso.login_info(); if !state.admin.is_configured() { return ( StatusCode::OK, Json(json!({ "setup_required": true, "authenticated": false, "has_custom_logo": has_custom_logo, "logo_rev": logo_rev, "sso": sso, })), ) .into_response(); } let token = parse_cookie(&headers); let username = token.as_deref().and_then(|t| state.sessions.touch(t)); match username { Some(u) => ( StatusCode::OK, Json(json!({ "setup_required": false, "authenticated": true, "user": state.admin.snapshot(), "session_user": u, "has_custom_logo": has_custom_logo, "logo_rev": logo_rev, "sso": sso, })), ) .into_response(), None => ( StatusCode::OK, Json(json!({ "setup_required": false, "authenticated": false, "has_custom_logo": has_custom_logo, "logo_rev": logo_rev, "sso": sso, })), ) .into_response(), } } #[derive(Debug, Deserialize)] pub struct UpdateCredentialsBody { pub current_password: String, #[serde(default)] pub new_username: Option, #[serde(default)] pub new_password: Option, } /// Rotate the admin's username and/or password. Auth middleware has /// already proved the caller owns a session; we additionally require /// the *current* password to prove "person at the keyboard right now". /// On success we issue a fresh session cookie keyed to the (possibly /// new) username and revoke every prior session so a stolen cookie /// from before the rotation stops working. pub async fn api_update_credentials( State(state): State, Json(body): Json, ) -> Response { if !state.admin.is_configured() { return ( StatusCode::CONFLICT, Json(json!({ "error": "no admin configured" })), ) .into_response(); } // Two bcrypt operations (verify current + hash new) plus a sync disk // persist — run the lot on the blocking pool. let admin = state.admin.clone(); let result = tokio::task::spawn_blocking(move || { admin.update_credentials( &body.current_password, body.new_username.as_deref(), body.new_password.as_deref(), ) }) .await .unwrap_or_else(|e| Err(openpxe_core::Error::Other(e.into()))); match result { Ok(pub_) => { state.sessions.revoke_all(); let session = state.sessions.create(&pub_.username); login_response(StatusCode::OK, &pub_, &session) } Err(openpxe_core::Error::Invalid(msg)) => { (StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response() } Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("{e}") })), ) .into_response(), } } #[derive(Debug, Serialize)] struct LoginPayload<'a> { user: &'a AdminPublic, authenticated: bool, } fn login_response(status: StatusCode, user: &AdminPublic, session: &str) -> Response { let body = Json(LoginPayload { user, authenticated: true, }); let mut resp = (status, body).into_response(); resp.headers_mut().insert( header::SET_COOKIE, HeaderValue::from_str(&cookie_attrs(session, None)).unwrap(), ); resp } // ── Tests ───────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; #[test] fn session_create_touch_revoke() { let s = SessionStore::default(); assert!(s.is_empty()); let t = s.create("admin"); assert_eq!(s.len(), 1); assert_eq!(s.touch(&t).as_deref(), Some("admin")); s.revoke(&t); assert!(s.is_empty()); // Stale token doesn't error, just returns None. assert!(s.touch(&t).is_none()); } #[test] fn session_revoke_all_clears() { let s = SessionStore::default(); let _ = s.create("a"); let _ = s.create("b"); assert_eq!(s.len(), 2); s.revoke_all(); assert!(s.is_empty()); } #[test] fn public_path_allowlist() { // PXE + chrome paths bypass auth. for p in [ "/", "/assets/app.js", "/boot.ipxe", "/boot/fake.ipxe", "/iso/fake.iso", "/ipxe/snponly.efi", "/healthz", "/readyz", "/metrics", // v0.4.6: iPXE fetches this for `console --picture` before // it can possibly have a session cookie. "/branding/pxe-logo", ] { assert!(is_public_path(p), "expected {p} to be public"); } // Auth surface itself is public. for p in ["/api/setup", "/api/login", "/api/logout", "/api/me"] { assert!(is_public_path(p), "expected {p} to be public"); } // v0.5.1: SAML SP endpoints are pre-auth (no session yet). for p in ["/api/sso/login", "/api/sso/acs", "/api/sso/metadata"] { assert!(is_public_path(p), "expected {p} to be public"); } // iPXE long-poll endpoints are public (no cookie available). assert!(is_public_path("/api/queue/join")); assert!(is_public_path("/api/queue/poll/abc")); // Everything else under /api/* must auth. for p in [ "/api/isos", "/api/isos/x/category", "/api/storage/disk", "/api/branding/logo", "/api/sso", "/api/hosts", ] { assert!(!is_public_path(p), "expected {p} to require auth"); } } #[test] fn cookie_parse_picks_session_value() { let mut h = axum::http::HeaderMap::new(); h.insert( header::COOKIE, HeaderValue::from_str(&format!("foo=bar; {SESSION_COOKIE}=abc123; baz=qux")).unwrap(), ); assert_eq!(parse_cookie(&h).as_deref(), Some("abc123")); // Different name → None. let mut h2 = axum::http::HeaderMap::new(); h2.insert(header::COOKIE, HeaderValue::from_str("foo=bar").unwrap()); assert!(parse_cookie(&h2).is_none()); // No cookie header at all → None. assert!(parse_cookie(&axum::http::HeaderMap::new()).is_none()); } }