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
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:
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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")))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user