Make Keycloak authentication optional for local development
Some checks failed
CI / Format (push) Failing after 2s
CI / Clippy (push) Successful in 2m54s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / Clippy (pull_request) Successful in 3m4s
CI / Security Audit (pull_request) Has been skipped
CI / Tests (pull_request) Has been skipped
CI / Format (pull_request) Failing after 2s

When KEYCLOAK_URL is not set, the dashboard runs without auth,
treating all users as authenticated "Local User". Auth middleware
and check-auth endpoint gracefully skip when Keycloak is unconfigured.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-03-08 14:32:29 +01:00
parent 94552d1626
commit b8b0f13d8d
6 changed files with 56 additions and 23 deletions

View File

@@ -75,7 +75,9 @@ pub async fn auth_login(
Extension(pending): Extension<PendingOAuthStore>, Extension(pending): Extension<PendingOAuthStore>,
Query(params): Query<HashMap<String, String>>, Query(params): Query<HashMap<String, String>>,
) -> Result<impl IntoResponse, DashboardError> { ) -> Result<impl IntoResponse, DashboardError> {
let kc = state.keycloak; let kc = state.keycloak.ok_or(DashboardError::Other(
"Keycloak not configured".into(),
))?;
let csrf_state = generate_state(); let csrf_state = generate_state();
let code_verifier = generate_code_verifier(); let code_verifier = generate_code_verifier();
let code_challenge = derive_code_challenge(&code_verifier); let code_challenge = derive_code_challenge(&code_verifier);
@@ -126,7 +128,9 @@ pub async fn auth_callback(
Extension(pending): Extension<PendingOAuthStore>, Extension(pending): Extension<PendingOAuthStore>,
Query(params): Query<HashMap<String, String>>, Query(params): Query<HashMap<String, String>>,
) -> Result<impl IntoResponse, DashboardError> { ) -> Result<impl IntoResponse, DashboardError> {
let kc = state.keycloak; let kc = state.keycloak.ok_or(DashboardError::Other(
"Keycloak not configured".into(),
))?;
let returned_state = params let returned_state = params
.get("state") .get("state")
@@ -210,7 +214,9 @@ pub async fn logout(
session: Session, session: Session,
Extension(state): Extension<ServerState>, Extension(state): Extension<ServerState>,
) -> Result<impl IntoResponse, DashboardError> { ) -> Result<impl IntoResponse, DashboardError> {
let kc = state.keycloak; let kc = state.keycloak.ok_or(DashboardError::Other(
"Keycloak not configured".into(),
))?;
session session
.flush() .flush()

View File

@@ -9,9 +9,21 @@ use dioxus::prelude::*;
#[server(endpoint = "check-auth")] #[server(endpoint = "check-auth")]
pub async fn check_auth() -> Result<AuthInfo, ServerFnError> { pub async fn check_auth() -> Result<AuthInfo, ServerFnError> {
use super::auth::LOGGED_IN_USER_SESS_KEY; use super::auth::LOGGED_IN_USER_SESS_KEY;
use super::server_state::ServerState;
use super::user_state::UserStateInner; use super::user_state::UserStateInner;
use dioxus_fullstack::FullstackContext; use dioxus_fullstack::FullstackContext;
let state: ServerState = FullstackContext::extract().await?;
// When Keycloak is not configured, treat as always authenticated
if state.keycloak.is_none() {
return Ok(AuthInfo {
authenticated: true,
name: "Local User".into(),
..Default::default()
});
}
let session: tower_sessions::Session = FullstackContext::extract().await?; let session: tower_sessions::Session = FullstackContext::extract().await?;
let user_state: Option<UserStateInner> = session let user_state: Option<UserStateInner> = session

View File

@@ -2,18 +2,30 @@ use axum::{
extract::Request, extract::Request,
middleware::Next, middleware::Next,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
Extension,
}; };
use reqwest::StatusCode; use reqwest::StatusCode;
use tower_sessions::Session; use tower_sessions::Session;
use super::auth::LOGGED_IN_USER_SESS_KEY; use super::auth::LOGGED_IN_USER_SESS_KEY;
use super::server_state::ServerState;
use super::user_state::UserStateInner; use super::user_state::UserStateInner;
const PUBLIC_API_ENDPOINTS: &[&str] = &["/api/check-auth"]; const PUBLIC_API_ENDPOINTS: &[&str] = &["/api/check-auth"];
/// Axum middleware that enforces authentication on `/api/` server /// Axum middleware that enforces authentication on `/api/` server
/// function endpoints. /// function endpoints. Skips auth entirely when Keycloak is not configured.
pub async fn require_auth(session: Session, request: Request, next: Next) -> Response { pub async fn require_auth(
Extension(state): Extension<ServerState>,
session: Session,
request: Request,
next: Next,
) -> Response {
// Skip auth when Keycloak is not configured
if state.keycloak.is_none() {
return next.run(request).await;
}
let path = request.uri().path(); let path = request.uri().path();
if path.starts_with("/api/") && !PUBLIC_API_ENDPOINTS.contains(&path) { if path.starts_with("/api/") && !PUBLIC_API_ENDPOINTS.contains(&path) {

View File

@@ -1,5 +1,3 @@
use super::error::DashboardError;
/// Keycloak OpenID Connect settings. /// Keycloak OpenID Connect settings.
#[derive(Debug)] #[derive(Debug)]
pub struct KeycloakConfig { pub struct KeycloakConfig {
@@ -11,13 +9,18 @@ pub struct KeycloakConfig {
} }
impl KeycloakConfig { impl KeycloakConfig {
pub fn from_env() -> Result<Self, DashboardError> { pub fn from_env() -> Option<Self> {
Ok(Self { let url = std::env::var("KEYCLOAK_URL").ok()?;
url: required_env("KEYCLOAK_URL")?, let realm = std::env::var("KEYCLOAK_REALM").ok()?;
realm: required_env("KEYCLOAK_REALM")?, let client_id = std::env::var("KEYCLOAK_CLIENT_ID").ok()?;
client_id: required_env("KEYCLOAK_CLIENT_ID")?, let redirect_uri = std::env::var("REDIRECT_URI").ok()?;
redirect_uri: required_env("REDIRECT_URI")?, let app_url = std::env::var("APP_URL").ok()?;
app_url: required_env("APP_URL")?, Some(Self {
url,
realm,
client_id,
redirect_uri,
app_url,
}) })
} }
@@ -50,7 +53,3 @@ impl KeycloakConfig {
} }
} }
fn required_env(name: &str) -> Result<String, DashboardError> {
std::env::var(name)
.map_err(|_| DashboardError::Config(format!("{name} is required but not set")))
}

View File

@@ -18,11 +18,15 @@ pub fn server_start(app: fn() -> Element) -> Result<(), DashboardError> {
dotenvy::dotenv().ok(); dotenvy::dotenv().ok();
let config = config::load_config()?; let config = config::load_config()?;
let keycloak: &'static KeycloakConfig = let keycloak: Option<&'static KeycloakConfig> =
Box::leak(Box::new(KeycloakConfig::from_env()?)); KeycloakConfig::from_env().map(|kc| &*Box::leak(Box::new(kc)));
let db = Database::connect(&config.mongodb_uri, &config.mongodb_database).await?; let db = Database::connect(&config.mongodb_uri, &config.mongodb_database).await?;
tracing::info!("Keycloak configured for realm '{}'", keycloak.realm); if let Some(kc) = keycloak {
tracing::info!("Keycloak configured for realm '{}'", kc.realm);
} else {
tracing::warn!("Keycloak not configured - dashboard is unprotected");
}
let server_state: ServerState = ServerStateInner { let server_state: ServerState = ServerStateInner {
agent_api_url: config.agent_api_url.clone(), agent_api_url: config.agent_api_url.clone(),
@@ -54,8 +58,8 @@ pub fn server_start(app: fn() -> Element) -> Result<(), DashboardError> {
.route("/logout", get(logout)) .route("/logout", get(logout))
.serve_dioxus_application(ServeConfig::new(), app) .serve_dioxus_application(ServeConfig::new(), app)
.layer(Extension(PendingOAuthStore::default())) .layer(Extension(PendingOAuthStore::default()))
.layer(Extension(server_state))
.layer(middleware::from_fn(require_auth)) .layer(middleware::from_fn(require_auth))
.layer(Extension(server_state))
.layer(session); .layer(session);
axum::serve(listener, router.into_make_service()) axum::serve(listener, router.into_make_service())

View File

@@ -20,7 +20,7 @@ pub struct ServerStateInner {
pub db: Database, pub db: Database,
pub config: DashboardConfig, pub config: DashboardConfig,
pub agent_api_url: String, pub agent_api_url: String,
pub keycloak: &'static KeycloakConfig, pub keycloak: Option<&'static KeycloakConfig>,
} }
impl From<ServerStateInner> for ServerState { impl From<ServerStateInner> for ServerState {