feat: add Keycloak authentication for dashboard and API endpoints (#2)
Dashboard: OAuth2/OIDC login flow with PKCE, session-based auth middleware protecting all server function endpoints, check-auth server function for frontend auth state, login page gate in AppShell, user info in sidebar. Agent API: JWT validation middleware using Keycloak JWKS endpoint, conditionally enabled when KEYCLOAK_URL and KEYCLOAK_REALM are set. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com> Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
228
compliance-dashboard/src/infrastructure/auth.rs
Normal file
228
compliance-dashboard/src/infrastructure/auth.rs
Normal file
@@ -0,0 +1,228 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
use axum::{
|
||||
extract::Query,
|
||||
response::{IntoResponse, Redirect},
|
||||
Extension,
|
||||
};
|
||||
use rand::Rng;
|
||||
use tower_sessions::Session;
|
||||
use url::Url;
|
||||
|
||||
use super::{
|
||||
error::DashboardError,
|
||||
server_state::ServerState,
|
||||
user_state::{User, UserStateInner},
|
||||
};
|
||||
|
||||
pub const LOGGED_IN_USER_SESS_KEY: &str = "logged-in-user";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PendingOAuthEntry {
|
||||
pub(crate) redirect_url: Option<String>,
|
||||
pub(crate) code_verifier: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PendingOAuthStore(Arc<RwLock<HashMap<String, PendingOAuthEntry>>>);
|
||||
|
||||
impl PendingOAuthStore {
|
||||
pub(crate) fn insert(&self, state: String, entry: PendingOAuthEntry) {
|
||||
#[allow(clippy::expect_used)]
|
||||
self.0
|
||||
.write()
|
||||
.expect("pending oauth store lock poisoned")
|
||||
.insert(state, entry);
|
||||
}
|
||||
|
||||
pub(crate) fn take(&self, state: &str) -> Option<PendingOAuthEntry> {
|
||||
#[allow(clippy::expect_used)]
|
||||
self.0
|
||||
.write()
|
||||
.expect("pending oauth store lock poisoned")
|
||||
.remove(state)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn generate_state() -> String {
|
||||
let bytes: [u8; 32] = rand::rng().random();
|
||||
bytes.iter().fold(String::with_capacity(64), |mut acc, b| {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(acc, "{b:02x}");
|
||||
acc
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn generate_code_verifier() -> String {
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
let bytes: [u8; 32] = rand::rng().random();
|
||||
URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn derive_code_challenge(verifier: &str) -> String {
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use sha2::{Digest, Sha256};
|
||||
let digest = Sha256::digest(verifier.as_bytes());
|
||||
URL_SAFE_NO_PAD.encode(digest)
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
pub async fn auth_login(
|
||||
Extension(state): Extension<ServerState>,
|
||||
Extension(pending): Extension<PendingOAuthStore>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Result<impl IntoResponse, DashboardError> {
|
||||
let kc = state.keycloak;
|
||||
let csrf_state = generate_state();
|
||||
let code_verifier = generate_code_verifier();
|
||||
let code_challenge = derive_code_challenge(&code_verifier);
|
||||
|
||||
let redirect_url = params.get("redirect_url").cloned();
|
||||
pending.insert(
|
||||
csrf_state.clone(),
|
||||
PendingOAuthEntry {
|
||||
redirect_url,
|
||||
code_verifier,
|
||||
},
|
||||
);
|
||||
|
||||
let mut url = Url::parse(&kc.auth_endpoint())
|
||||
.map_err(|e| DashboardError::Other(format!("invalid auth endpoint URL: {e}")))?;
|
||||
|
||||
url.query_pairs_mut()
|
||||
.append_pair("client_id", &kc.client_id)
|
||||
.append_pair("redirect_uri", &kc.redirect_uri)
|
||||
.append_pair("response_type", "code")
|
||||
.append_pair("scope", "openid profile email")
|
||||
.append_pair("state", &csrf_state)
|
||||
.append_pair("code_challenge", &code_challenge)
|
||||
.append_pair("code_challenge_method", "S256");
|
||||
|
||||
Ok(Redirect::temporary(url.as_str()))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
refresh_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct UserinfoResponse {
|
||||
sub: String,
|
||||
email: Option<String>,
|
||||
preferred_username: Option<String>,
|
||||
name: Option<String>,
|
||||
picture: Option<String>,
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
pub async fn auth_callback(
|
||||
session: Session,
|
||||
Extension(state): Extension<ServerState>,
|
||||
Extension(pending): Extension<PendingOAuthStore>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Result<impl IntoResponse, DashboardError> {
|
||||
let kc = state.keycloak;
|
||||
|
||||
let returned_state = params
|
||||
.get("state")
|
||||
.ok_or_else(|| DashboardError::Other("missing state parameter".into()))?;
|
||||
|
||||
let entry = pending
|
||||
.take(returned_state)
|
||||
.ok_or_else(|| DashboardError::Other("unknown or expired oauth state".into()))?;
|
||||
|
||||
let code = params
|
||||
.get("code")
|
||||
.ok_or_else(|| DashboardError::Other("missing code parameter".into()))?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let token_resp = client
|
||||
.post(kc.token_endpoint())
|
||||
.form(&[
|
||||
("grant_type", "authorization_code"),
|
||||
("client_id", kc.client_id.as_str()),
|
||||
("redirect_uri", kc.redirect_uri.as_str()),
|
||||
("code", code),
|
||||
("code_verifier", &entry.code_verifier),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| DashboardError::Other(format!("token request failed: {e}")))?;
|
||||
|
||||
if !token_resp.status().is_success() {
|
||||
let body = token_resp.text().await.unwrap_or_default();
|
||||
return Err(DashboardError::Other(format!(
|
||||
"token exchange failed: {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
let tokens: TokenResponse = token_resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| DashboardError::Other(format!("token parse failed: {e}")))?;
|
||||
|
||||
let userinfo: UserinfoResponse = client
|
||||
.get(kc.userinfo_endpoint())
|
||||
.bearer_auth(&tokens.access_token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| DashboardError::Other(format!("userinfo request failed: {e}")))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| DashboardError::Other(format!("userinfo parse failed: {e}")))?;
|
||||
|
||||
let display_name = userinfo
|
||||
.name
|
||||
.or(userinfo.preferred_username)
|
||||
.unwrap_or_default();
|
||||
|
||||
let user_state = UserStateInner {
|
||||
sub: userinfo.sub,
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token.unwrap_or_default(),
|
||||
user: User {
|
||||
email: userinfo.email.unwrap_or_default(),
|
||||
name: display_name,
|
||||
avatar_url: userinfo.picture.unwrap_or_default(),
|
||||
},
|
||||
};
|
||||
|
||||
session
|
||||
.insert(LOGGED_IN_USER_SESS_KEY, user_state)
|
||||
.await
|
||||
.map_err(|e| DashboardError::Other(format!("session insert failed: {e}")))?;
|
||||
|
||||
let target = entry
|
||||
.redirect_url
|
||||
.filter(|u| !u.is_empty())
|
||||
.unwrap_or_else(|| "/".into());
|
||||
|
||||
Ok(Redirect::temporary(&target))
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
pub async fn logout(
|
||||
session: Session,
|
||||
Extension(state): Extension<ServerState>,
|
||||
) -> Result<impl IntoResponse, DashboardError> {
|
||||
let kc = state.keycloak;
|
||||
|
||||
session
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| DashboardError::Other(format!("session flush failed: {e}")))?;
|
||||
|
||||
let mut url = Url::parse(&kc.logout_endpoint())
|
||||
.map_err(|e| DashboardError::Other(format!("invalid logout endpoint URL: {e}")))?;
|
||||
|
||||
url.query_pairs_mut()
|
||||
.append_pair("client_id", &kc.client_id)
|
||||
.append_pair("post_logout_redirect_uri", &kc.app_url);
|
||||
|
||||
Ok(Redirect::temporary(url.as_str()))
|
||||
}
|
||||
32
compliance-dashboard/src/infrastructure/auth_check.rs
Normal file
32
compliance-dashboard/src/infrastructure/auth_check.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use compliance_core::models::auth::AuthInfo;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Check the current user's authentication state.
|
||||
///
|
||||
/// Reads the tower-sessions session on the server and returns an
|
||||
/// [`AuthInfo`] describing the logged-in user. When no valid session
|
||||
/// exists, `authenticated` is `false` and all other fields are empty.
|
||||
#[server(endpoint = "check-auth")]
|
||||
pub async fn check_auth() -> Result<AuthInfo, ServerFnError> {
|
||||
use super::auth::LOGGED_IN_USER_SESS_KEY;
|
||||
use super::user_state::UserStateInner;
|
||||
use dioxus_fullstack::FullstackContext;
|
||||
|
||||
let session: tower_sessions::Session = FullstackContext::extract().await?;
|
||||
|
||||
let user_state: Option<UserStateInner> = session
|
||||
.get(LOGGED_IN_USER_SESS_KEY)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("session read failed: {e}")))?;
|
||||
|
||||
match user_state {
|
||||
Some(u) => Ok(AuthInfo {
|
||||
authenticated: true,
|
||||
sub: u.sub,
|
||||
email: u.user.email,
|
||||
name: u.user.name,
|
||||
avatar_url: u.user.avatar_url,
|
||||
}),
|
||||
None => Ok(AuthInfo::default()),
|
||||
}
|
||||
}
|
||||
33
compliance-dashboard/src/infrastructure/auth_middleware.rs
Normal file
33
compliance-dashboard/src/infrastructure/auth_middleware.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use axum::{
|
||||
extract::Request,
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use reqwest::StatusCode;
|
||||
use tower_sessions::Session;
|
||||
|
||||
use super::auth::LOGGED_IN_USER_SESS_KEY;
|
||||
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 {
|
||||
let path = request.uri().path();
|
||||
|
||||
if path.starts_with("/api/") && !PUBLIC_API_ENDPOINTS.contains(&path) {
|
||||
let is_authed = session
|
||||
.get::<UserStateInner>(LOGGED_IN_USER_SESS_KEY)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
|
||||
if !is_authed {
|
||||
return (StatusCode::UNAUTHORIZED, "Authentication required").into_response();
|
||||
}
|
||||
}
|
||||
|
||||
next.run(request).await
|
||||
}
|
||||
@@ -24,3 +24,14 @@ impl From<DashboardError> for ServerFnError {
|
||||
ServerFnError::new(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
impl axum::response::IntoResponse for DashboardError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
(
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
self.to_string(),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
56
compliance-dashboard/src/infrastructure/keycloak_config.rs
Normal file
56
compliance-dashboard/src/infrastructure/keycloak_config.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use super::error::DashboardError;
|
||||
|
||||
/// Keycloak OpenID Connect settings.
|
||||
#[derive(Debug)]
|
||||
pub struct KeycloakConfig {
|
||||
pub url: String,
|
||||
pub realm: String,
|
||||
pub client_id: String,
|
||||
pub redirect_uri: String,
|
||||
pub app_url: String,
|
||||
}
|
||||
|
||||
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 auth_endpoint(&self) -> String {
|
||||
format!(
|
||||
"{}/realms/{}/protocol/openid-connect/auth",
|
||||
self.url, self.realm
|
||||
)
|
||||
}
|
||||
|
||||
pub fn token_endpoint(&self) -> String {
|
||||
format!(
|
||||
"{}/realms/{}/protocol/openid-connect/token",
|
||||
self.url, self.realm
|
||||
)
|
||||
}
|
||||
|
||||
pub fn userinfo_endpoint(&self) -> String {
|
||||
format!(
|
||||
"{}/realms/{}/protocol/openid-connect/userinfo",
|
||||
self.url, self.realm
|
||||
)
|
||||
}
|
||||
|
||||
pub fn logout_endpoint(&self) -> String {
|
||||
format!(
|
||||
"{}/realms/{}/protocol/openid-connect/logout",
|
||||
self.url, self.realm
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn required_env(name: &str) -> Result<String, DashboardError> {
|
||||
std::env::var(name)
|
||||
.map_err(|_| DashboardError::Config(format!("{name} is required but not set")))
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// Server function modules (compiled for both web and server;
|
||||
// the #[server] macro generates client stubs for the web target)
|
||||
pub mod auth_check;
|
||||
pub mod chat;
|
||||
pub mod dast;
|
||||
pub mod findings;
|
||||
@@ -12,15 +13,27 @@ pub mod stats;
|
||||
|
||||
// Server-only modules
|
||||
#[cfg(feature = "server")]
|
||||
mod auth;
|
||||
#[cfg(feature = "server")]
|
||||
mod auth_middleware;
|
||||
#[cfg(feature = "server")]
|
||||
pub mod config;
|
||||
#[cfg(feature = "server")]
|
||||
pub mod database;
|
||||
#[cfg(feature = "server")]
|
||||
pub mod error;
|
||||
#[cfg(feature = "server")]
|
||||
pub mod server;
|
||||
pub mod keycloak_config;
|
||||
#[cfg(feature = "server")]
|
||||
mod server;
|
||||
#[cfg(feature = "server")]
|
||||
pub mod server_state;
|
||||
#[cfg(feature = "server")]
|
||||
mod user_state;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub use auth::{auth_callback, auth_login, logout, PendingOAuthStore};
|
||||
#[cfg(feature = "server")]
|
||||
pub use auth_middleware::require_auth;
|
||||
#[cfg(feature = "server")]
|
||||
pub use server::server_start;
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
use axum::routing::get;
|
||||
use axum::{middleware, Extension};
|
||||
use dioxus::prelude::*;
|
||||
use time::Duration;
|
||||
use tower_sessions::{cookie::Key, MemoryStore, SessionManagerLayer};
|
||||
|
||||
use super::config;
|
||||
use super::database::Database;
|
||||
use super::error::DashboardError;
|
||||
use super::keycloak_config::KeycloakConfig;
|
||||
use super::server_state::{ServerState, ServerStateInner};
|
||||
use super::{auth_callback, auth_login, logout, require_auth, PendingOAuthStore};
|
||||
|
||||
pub fn server_start(app: fn() -> Element) -> Result<(), DashboardError> {
|
||||
tokio::runtime::Runtime::new()
|
||||
@@ -12,15 +18,29 @@ 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 db = Database::connect(&config.mongodb_uri, &config.mongodb_database).await?;
|
||||
|
||||
tracing::info!("Keycloak configured for realm '{}'", keycloak.realm);
|
||||
|
||||
let server_state: ServerState = ServerStateInner {
|
||||
agent_api_url: config.agent_api_url.clone(),
|
||||
db,
|
||||
config,
|
||||
keycloak,
|
||||
}
|
||||
.into();
|
||||
|
||||
// Session layer
|
||||
let key = Key::generate();
|
||||
let store = MemoryStore::default();
|
||||
let session = SessionManagerLayer::new(store)
|
||||
.with_secure(false)
|
||||
.with_same_site(tower_sessions::cookie::SameSite::Lax)
|
||||
.with_expiry(tower_sessions::Expiry::OnInactivity(Duration::hours(24)))
|
||||
.with_signed(key);
|
||||
|
||||
let addr = dioxus_cli_config::fullstack_address_or_localhost();
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
.await
|
||||
@@ -29,8 +49,14 @@ pub fn server_start(app: fn() -> Element) -> Result<(), DashboardError> {
|
||||
tracing::info!("Dashboard server listening on {addr}");
|
||||
|
||||
let router = axum::Router::new()
|
||||
.route("/auth", get(auth_login))
|
||||
.route("/auth/callback", get(auth_callback))
|
||||
.route("/logout", get(logout))
|
||||
.serve_dioxus_application(ServeConfig::new(), app)
|
||||
.layer(axum::Extension(server_state));
|
||||
.layer(Extension(PendingOAuthStore::default()))
|
||||
.layer(Extension(server_state))
|
||||
.layer(middleware::from_fn(require_auth))
|
||||
.layer(session);
|
||||
|
||||
axum::serve(listener, router.into_make_service())
|
||||
.await
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::sync::Arc;
|
||||
use compliance_core::DashboardConfig;
|
||||
|
||||
use super::database::Database;
|
||||
use super::keycloak_config::KeycloakConfig;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ServerState(Arc<ServerStateInner>);
|
||||
@@ -19,6 +20,7 @@ pub struct ServerStateInner {
|
||||
pub db: Database,
|
||||
pub config: DashboardConfig,
|
||||
pub agent_api_url: String,
|
||||
pub keycloak: &'static KeycloakConfig,
|
||||
}
|
||||
|
||||
impl From<ServerStateInner> for ServerState {
|
||||
|
||||
18
compliance-dashboard/src/infrastructure/user_state.rs
Normal file
18
compliance-dashboard/src/infrastructure/user_state.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Per-session user data stored in the tower-sessions session store.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct UserStateInner {
|
||||
pub sub: String,
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
pub user: User,
|
||||
}
|
||||
|
||||
/// Basic user profile stored alongside the session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct User {
|
||||
pub email: String,
|
||||
pub name: String,
|
||||
pub avatar_url: String,
|
||||
}
|
||||
Reference in New Issue
Block a user