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

View File

@@ -9,9 +9,21 @@ use dioxus::prelude::*;
#[server(endpoint = "check-auth")]
pub async fn check_auth() -> Result<AuthInfo, ServerFnError> {
use super::auth::LOGGED_IN_USER_SESS_KEY;
use super::server_state::ServerState;
use super::user_state::UserStateInner;
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 user_state: Option<UserStateInner> = session

View File

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

View File

@@ -1,5 +1,3 @@
use super::error::DashboardError;
/// Keycloak OpenID Connect settings.
#[derive(Debug)]
pub struct KeycloakConfig {
@@ -11,13 +9,18 @@ pub struct KeycloakConfig {
}
impl KeycloakConfig {
pub fn from_env() -> Result<Self, DashboardError> {
Ok(Self {
url: required_env("KEYCLOAK_URL")?,
realm: required_env("KEYCLOAK_REALM")?,
client_id: required_env("KEYCLOAK_CLIENT_ID")?,
redirect_uri: required_env("REDIRECT_URI")?,
app_url: required_env("APP_URL")?,
pub fn from_env() -> Option<Self> {
let url = std::env::var("KEYCLOAK_URL").ok()?;
let realm = std::env::var("KEYCLOAK_REALM").ok()?;
let client_id = std::env::var("KEYCLOAK_CLIENT_ID").ok()?;
let redirect_uri = std::env::var("REDIRECT_URI").ok()?;
let app_url = std::env::var("APP_URL").ok()?;
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();
let config = config::load_config()?;
let keycloak: &'static KeycloakConfig =
Box::leak(Box::new(KeycloakConfig::from_env()?));
let keycloak: Option<&'static KeycloakConfig> =
KeycloakConfig::from_env().map(|kc| &*Box::leak(Box::new(kc)));
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 {
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))
.serve_dioxus_application(ServeConfig::new(), app)
.layer(Extension(PendingOAuthStore::default()))
.layer(Extension(server_state))
.layer(middleware::from_fn(require_auth))
.layer(Extension(server_state))
.layer(session);
axum::serve(listener, router.into_make_service())

View File

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