fix: login working now

This commit is contained in:
Sharang Parnerkar
2026-02-18 08:47:08 +01:00
parent 37478ba8f9
commit 295f02abe2
25 changed files with 1033 additions and 951 deletions

View File

@@ -1,122 +1,46 @@
use crate::{components::*, pages::*};
use dioxus::prelude::*;
/// Application routes.
///
/// `OverviewPage` is wrapped in the `AppShell` layout so the sidebar
/// renders around every authenticated page. The `/login` route remains
/// outside the shell (unauthenticated).
#[derive(Debug, Clone, Routable, PartialEq)]
#[rustfmt::skip]
pub enum Route {
#[layout(Navbar)]
#[route("/")]
OverviewPage {},
#[layout(AppShell)]
#[route("/")]
OverviewPage {},
#[end_layout]
#[route("/login?:redirect_url")]
Login { redirect_url: String },
#[route("/blog/:id")]
Blog { id: i32 },
}
const FAVICON: Asset = asset!("/assets/favicon.ico");
const MAIN_CSS: Asset = asset!("/assets/main.css");
const HEADER_SVG: Asset = asset!("/assets/header.svg");
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
/// Google Fonts URL for Inter (body) and Space Grotesk (headings).
const GOOGLE_FONTS: &str = "https://fonts.googleapis.com/css2?\
family=Inter:wght@400;500;600&\
family=Space+Grotesk:wght@500;600;700&\
display=swap";
/// Root application component. Loads global assets and mounts the router.
#[component]
pub fn App() -> Element {
rsx! {
document::Link { rel: "icon", href: FAVICON }
document::Link { rel: "preconnect", href: "https://fonts.googleapis.com" }
document::Link {
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossorigin: "anonymous",
}
document::Link { rel: "stylesheet", href: GOOGLE_FONTS }
document::Link { rel: "stylesheet", href: MAIN_CSS }
document::Link { rel: "stylesheet", href: TAILWIND_CSS }
Router::<Route> {}
}
}
#[component]
pub fn Hero() -> Element {
rsx! {
div { id: "hero",
img { src: HEADER_SVG, id: "header" }
div { id: "links",
a { href: "https://dioxuslabs.com/learn/0.7/", "📚 Learn Dioxus" }
a { href: "https://dioxuslabs.com/awesome", "🚀 Awesome Dioxus" }
a { href: "https://github.com/dioxus-community/", "📡 Community Libraries" }
a { href: "https://github.com/DioxusLabs/sdk", "⚙️ Dioxus Development Kit" }
a { href: "https://marketplace.visualstudio.com/items?itemName=DioxusLabs.dioxus",
"💫 VSCode Extension"
}
a { href: "https://discord.gg/XgGxMSkvUM", "👋 Community Discord" }
}
}
}
}
/// Home page
#[component]
fn Home() -> Element {
rsx! {
Hero {}
Echo {}
}
}
/// Blog page
#[component]
pub fn Blog(id: i32) -> Element {
rsx! {
div { id: "blog",
// Content
h1 { "This is blog #{id}!" }
p {
"In blog #{id}, we show how the Dioxus router works and how URL parameters can be passed as props to our route components."
}
// Navigation links
Link { to: Route::Blog { id: id - 1 }, "Previous" }
span { " <---> " }
Link { to: Route::Blog { id: id + 1 }, "Next" }
}
}
}
/// Shared navbar component.
#[component]
fn Navbar() -> Element {
rsx! {
div { id: "navbar",
Link { to: Route::OverviewPage {}, "Home" }
Link { to: Route::Blog { id: 1 }, "Blog" }
}
Outlet::<Route> {}
}
}
/// Echo component that demonstrates fullstack server functions.
#[component]
fn Echo() -> Element {
let mut response = use_signal(|| String::new());
rsx! {
div { id: "echo",
h4 { "ServerFn Echo" }
input {
placeholder: "Type here to echo...",
oninput: move |event| async move {
let data = echo_server(event.value()).await.unwrap();
response.set(data);
},
}
if !response().is_empty() {
p {
"Server echoed: "
i { "{response}" }
}
}
}
}
}
/// Echo the user input on the server.
#[post("/api/echo")]
async fn echo_server(input: String) -> Result<String, ServerFnError> {
Ok(input)
}

View File

@@ -0,0 +1,23 @@
use dioxus::prelude::*;
use crate::components::sidebar::Sidebar;
use crate::Route;
/// Application shell layout that wraps all authenticated pages.
///
/// Renders a fixed sidebar on the left and the active child route
/// in the scrollable main content area via `Outlet`.
#[component]
pub fn AppShell() -> Element {
rsx! {
div { class: "app-shell",
Sidebar {
email: "user@example.com".to_string(),
avatar_url: String::new(),
}
main { class: "main-content",
Outlet::<Route> {}
}
}
}
}

25
src/components/card.rs Normal file
View File

@@ -0,0 +1,25 @@
use dioxus::prelude::*;
/// Reusable dashboard card with icon, title, description and click-through link.
///
/// # Arguments
///
/// * `title` - Card heading text.
/// * `description` - Short description shown beneath the title.
/// * `href` - URL the card links to when clicked.
/// * `icon` - Element rendered as the card icon (typically a `dioxus_free_icons::Icon`).
#[component]
pub fn DashboardCard(
title: String,
description: String,
href: String,
icon: Element,
) -> Element {
rsx! {
a { class: "dashboard-card", href: "{href}",
div { class: "card-icon", {icon} }
h3 { class: "card-title", "{title}" }
p { class: "card-description", "{description}" }
}
}
}

View File

@@ -1,2 +1,8 @@
mod app_shell;
mod card;
mod login;
pub mod sidebar;
pub use app_shell::*;
pub use card::*;
pub use login::*;

154
src/components/sidebar.rs Normal file
View File

@@ -0,0 +1,154 @@
use dioxus::prelude::*;
use dioxus_free_icons::icons::bs_icons::{
BsBoxArrowRight, BsFileEarmarkText, BsGear, BsGithub, BsGrid,
BsHouseDoor, BsRobot,
};
use dioxus_free_icons::icons::fa_solid_icons::FaCubes;
use dioxus_free_icons::Icon;
use crate::Route;
/// Navigation entry for the sidebar.
struct NavItem {
label: &'static str,
route: Route,
/// Bootstrap icon element rendered beside the label.
icon: Element,
}
/// Fixed left sidebar containing header, navigation, logout, and footer.
///
/// # Arguments
///
/// * `email` - Email address displayed beneath the avatar placeholder.
/// * `avatar_url` - URL for the avatar image (unused placeholder for now).
#[component]
pub fn Sidebar(email: String, avatar_url: String) -> Element {
let nav_items: Vec<NavItem> = vec![
NavItem {
label: "Overview",
route: Route::OverviewPage {},
icon: rsx! { Icon { icon: BsHouseDoor, width: 18, height: 18 } },
},
NavItem {
label: "Documentation",
route: Route::OverviewPage {},
icon: rsx! { Icon { icon: BsFileEarmarkText, width: 18, height: 18 } },
},
NavItem {
label: "Agents",
route: Route::OverviewPage {},
icon: rsx! { Icon { icon: BsRobot, width: 18, height: 18 } },
},
NavItem {
label: "Models",
route: Route::OverviewPage {},
icon: rsx! { Icon { icon: FaCubes, width: 18, height: 18 } },
},
NavItem {
label: "Settings",
route: Route::OverviewPage {},
icon: rsx! { Icon { icon: BsGear, width: 18, height: 18 } },
},
];
// Determine current path to highlight the active nav link.
let current_route = use_route::<Route>();
rsx! {
aside { class: "sidebar",
// -- Header: avatar circle + email --
SidebarHeader { email: email.clone(), avatar_url }
// -- Navigation links --
nav { class: "sidebar-nav",
for item in nav_items {
{
// Simple active check: highlight Overview only when on `/`.
let is_active = item.route == current_route;
let cls = if is_active {
"sidebar-link active"
} else {
"sidebar-link"
};
rsx! {
Link {
to: item.route,
class: cls,
{item.icon}
span { "{item.label}" }
}
}
}
}
}
// -- Logout button --
div { class: "sidebar-logout",
Link {
to: NavigationTarget::<Route>::External("/auth/logout".into()),
class: "sidebar-link logout-btn",
Icon { icon: BsBoxArrowRight, width: 18, height: 18 }
span { "Logout" }
}
}
// -- Footer: version + social links --
SidebarFooter {}
}
}
}
/// Avatar circle and email display at the top of the sidebar.
///
/// # Arguments
///
/// * `email` - User email to display.
/// * `avatar_url` - Placeholder for future avatar image URL.
#[component]
fn SidebarHeader(email: String, avatar_url: String) -> Element {
// Extract initials from email (first two chars before @).
let initials: String = email
.split('@')
.next()
.unwrap_or("U")
.chars()
.take(2)
.collect::<String>()
.to_uppercase();
rsx! {
div { class: "sidebar-header",
div { class: "avatar-circle",
span { class: "avatar-initials", "{initials}" }
}
p { class: "sidebar-email", "{email}" }
}
}
}
/// Footer section with version string and placeholder social links.
#[component]
fn SidebarFooter() -> Element {
let version = env!("CARGO_PKG_VERSION");
rsx! {
footer { class: "sidebar-footer",
div { class: "sidebar-social",
a {
href: "#",
class: "social-link",
title: "GitHub",
Icon { icon: BsGithub, width: 16, height: 16 }
}
a {
href: "#",
class: "social-link",
title: "Impressum",
Icon { icon: BsGrid, width: 16, height: 16 }
}
}
p { class: "sidebar-version", "v{version}" }
}
}
}

View File

@@ -1,109 +1,307 @@
use super::error::{Error, Result};
use axum::Extension;
use axum::{
extract::FromRequestParts,
http::request::Parts,
response::{IntoResponse, Redirect, Response},
use std::{
collections::HashMap,
sync::{Arc, RwLock},
};
use url::form_urlencoded;
pub struct KeycloakVariables {
pub base_url: String,
pub realm: String,
pub client_id: String,
pub client_secret: String,
pub enable_test_user: bool,
use axum::{
extract::Query,
response::{IntoResponse, Redirect},
Extension,
};
use rand::RngExt;
use tower_sessions::Session;
use url::Url;
use crate::infrastructure::{state::User, Error, UserStateInner};
pub const LOGGED_IN_USER_SESS_KEY: &str = "logged-in-user";
/// In-memory store for pending OAuth states and their associated redirect
/// URLs. Keyed by the random state string. This avoids dependence on the
/// session cookie surviving the Keycloak redirect round-trip (the `dx serve`
/// proxy can drop `Set-Cookie` headers on 307 responses).
#[derive(Debug, Clone, Default)]
pub struct PendingOAuthStore(Arc<RwLock<HashMap<String, Option<String>>>>);
impl PendingOAuthStore {
/// Insert a pending state with an optional post-login redirect URL.
fn insert(&self, state: String, redirect_url: Option<String>) {
// RwLock::write only panics if the lock is poisoned, which
// indicates a prior panic -- propagating is acceptable here.
#[allow(clippy::expect_used)]
self.0
.write()
.expect("pending oauth store lock poisoned")
.insert(state, redirect_url);
}
/// Remove and return the redirect URL if the state was pending.
/// Returns `None` if the state was never stored (CSRF failure).
fn take(&self, state: &str) -> Option<Option<String>> {
#[allow(clippy::expect_used)]
self.0
.write()
.expect("pending oauth store lock poisoned")
.remove(state)
}
}
/// Session data available to the backend when the user is logged in
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct LoggedInData {
pub id: String,
// ID Token value associated with the authenticated session.
pub token_id: String,
pub username: String,
pub avatar_url: Option<String>,
/// Configuration loaded from environment variables for Keycloak OAuth.
struct OAuthConfig {
keycloak_url: String,
realm: String,
client_id: String,
redirect_uri: String,
app_url: String,
}
/// Used for extracting in the server functions.
/// If the `data` is `Some`, the user is logged in.
pub struct UserSession {
data: Option<LoggedInData>,
}
impl UserSession {
/// Get the [`LoggedInData`].
impl OAuthConfig {
/// Load OAuth configuration from environment variables.
///
/// Raises a [`Error::UserNotLoggedIn`] error if the user is not logged in.
pub fn data(self) -> Result<LoggedInData> {
self.data.ok_or(Error::UserNotLoggedIn)
/// # Errors
///
/// Returns `Error::StateError` if any required env var is missing.
fn from_env() -> Result<Self, Error> {
dotenvy::dotenv().ok();
Ok(Self {
keycloak_url: std::env::var("KEYCLOAK_URL")
.map_err(|_| Error::StateError("KEYCLOAK_URL not set".into()))?,
realm: std::env::var("KEYCLOAK_REALM")
.map_err(|_| Error::StateError("KEYCLOAK_REALM not set".into()))?,
client_id: std::env::var("KEYCLOAK_CLIENT_ID")
.map_err(|_| Error::StateError("KEYCLOAK_CLIENT_ID not set".into()))?,
redirect_uri: std::env::var("REDIRECT_URI")
.map_err(|_| Error::StateError("REDIRECT_URI not set".into()))?,
app_url: std::env::var("APP_URL")
.map_err(|_| Error::StateError("APP_URL not set".into()))?,
})
}
/// Build the Keycloak OpenID Connect authorization endpoint URL.
fn auth_endpoint(&self) -> String {
format!(
"{}/realms/{}/protocol/openid-connect/auth",
self.keycloak_url, self.realm
)
}
/// Build the Keycloak OpenID Connect token endpoint URL.
fn token_endpoint(&self) -> String {
format!(
"{}/realms/{}/protocol/openid-connect/token",
self.keycloak_url, self.realm
)
}
/// Build the Keycloak OpenID Connect userinfo endpoint URL.
fn userinfo_endpoint(&self) -> String {
format!(
"{}/realms/{}/protocol/openid-connect/userinfo",
self.keycloak_url, self.realm
)
}
/// Build the Keycloak OpenID Connect end-session (logout) endpoint URL.
fn logout_endpoint(&self) -> String {
format!(
"{}/realms/{}/protocol/openid-connect/logout",
self.keycloak_url, self.realm
)
}
}
const LOGGED_IN_USER_SESSION_KEY: &str = "logged_in_data";
impl<S: std::marker::Sync + std::marker::Send> FromRequestParts<S> for UserSession {
type Rejection = Error;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self> {
let session = parts
.extensions
.get::<tower_sessions::Session>()
.cloned()
.ok_or(Error::AuthSessionLayerNotFound(
"Auth Session Layer not found".to_string(),
))?;
let data: Option<LoggedInData> = session
.get::<LoggedInData>(LOGGED_IN_USER_SESSION_KEY)
.await?;
Ok(Self { data })
}
/// Generate a cryptographically random state string for CSRF protection.
fn generate_state() -> String {
let bytes: [u8; 32] = rand::rng().random();
// Encode as hex to produce a URL-safe string without padding.
bytes.iter().fold(String::with_capacity(64), |mut acc, b| {
use std::fmt::Write;
// write! on a String is infallible, safe to ignore the result.
let _ = write!(acc, "{b:02x}");
acc
})
}
/// Helper function to log the user in by setting the session data
pub async fn login(session: &tower_sessions::Session, data: &LoggedInData) -> Result<()> {
session.insert(LOGGED_IN_USER_SESSION_KEY, data).await?;
Ok(())
}
/// Handler to run when the user wants to logout
/// Redirect the user to Keycloak's authorization page.
///
/// Generates a random CSRF state, stores it (along with the optional
/// redirect URL) in the server-side `PendingOAuthStore`, and redirects
/// the browser to Keycloak.
///
/// # Query Parameters
///
/// * `redirect_url` - Optional URL to redirect to after successful login.
///
/// # Errors
///
/// Returns `Error` if env vars are missing.
#[axum::debug_handler]
pub async fn logout(
state: Extension<super::server_state::ServerState>,
session: tower_sessions::Session,
) -> Result<Response> {
let dashboard_base_url = "http://localhost:8000";
let redirect_uri = format!("{dashboard_base_url}/");
let encoded_redirect_uri: String =
form_urlencoded::byte_serialize(redirect_uri.as_bytes()).collect();
pub async fn auth_login(
Extension(pending): Extension<PendingOAuthStore>,
Query(params): Query<HashMap<String, String>>,
) -> Result<impl IntoResponse, Error> {
let config = OAuthConfig::from_env()?;
let state = generate_state();
// clear the session value for this session
if let Some(login_data) = session
.remove::<LoggedInData>(LOGGED_IN_USER_SESSION_KEY)
.await?
{
let kc_base_url = &state.keycloak_variables.base_url;
let kc_realm = &state.keycloak_variables.realm;
let kc_client_id = &state.keycloak_variables.client_id;
let redirect_url = params.get("redirect_url").cloned();
pending.insert(state.clone(), redirect_url);
// Needed for running locally.
// This will not panic on production and it will return the original so we can keep it
let routed_kc_base_url = kc_base_url.replace("keycloak", "localhost");
let mut url = Url::parse(&config.auth_endpoint())
.map_err(|e| Error::StateError(format!("invalid auth endpoint URL: {e}")))?;
let token_id = login_data.token_id;
url.query_pairs_mut()
.append_pair("client_id", &config.client_id)
.append_pair("redirect_uri", &config.redirect_uri)
.append_pair("response_type", "code")
.append_pair("scope", "openid profile email")
.append_pair("state", &state);
// redirect to Keycloak logout endpoint
let logout_url = format!(
"{routed_kc_base_url}/realms/{kc_realm}/protocol/openid-connect/logout\
?post_logout_redirect_uri={encoded_redirect_uri}\
&client_id={kc_client_id}\
&id_token_hint={token_id}"
);
Ok(Redirect::to(&logout_url).into_response())
} else {
// No id_token in session; just redirect to homepage
Ok(Redirect::to(&redirect_uri).into_response())
}
Ok(Redirect::temporary(url.as_str()))
}
/// Token endpoint response from Keycloak.
#[derive(serde::Deserialize)]
struct TokenResponse {
access_token: String,
refresh_token: Option<String>,
}
/// Userinfo endpoint response from Keycloak.
#[derive(serde::Deserialize)]
struct UserinfoResponse {
/// The subject identifier (unique user ID in Keycloak).
sub: String,
email: Option<String>,
/// Keycloak may include a picture/avatar URL via protocol mappers.
picture: Option<String>,
}
/// Handle the OAuth callback from Keycloak after the user authenticates.
///
/// Validates the CSRF state against the `PendingOAuthStore`, exchanges
/// the authorization code for tokens, fetches user info, stores the
/// logged-in user in the tower-sessions session, and redirects to the app.
///
/// # Query Parameters
///
/// * `code` - The authorization code from Keycloak.
/// * `state` - The CSRF state to verify against the pending store.
///
/// # Errors
///
/// Returns `Error` on CSRF mismatch, token exchange failure, or session issues.
#[axum::debug_handler]
pub async fn auth_callback(
session: Session,
Extension(pending): Extension<PendingOAuthStore>,
Query(params): Query<HashMap<String, String>>,
) -> Result<impl IntoResponse, Error> {
let config = OAuthConfig::from_env()?;
// --- CSRF validation via the in-memory pending store ---
let returned_state = params
.get("state")
.ok_or_else(|| Error::StateError("missing state parameter".into()))?;
let redirect_url = pending
.take(returned_state)
.ok_or_else(|| Error::StateError("unknown or expired oauth state".into()))?;
// --- Exchange code for tokens ---
let code = params
.get("code")
.ok_or_else(|| Error::StateError("missing code parameter".into()))?;
let client = reqwest::Client::new();
let token_resp = client
.post(&config.token_endpoint())
.form(&[
("grant_type", "authorization_code"),
("client_id", &config.client_id),
("redirect_uri", &config.redirect_uri),
("code", code),
])
.send()
.await
.map_err(|e| Error::StateError(format!("token request failed: {e}")))?;
if !token_resp.status().is_success() {
let body = token_resp.text().await.unwrap_or_default();
return Err(Error::StateError(format!("token exchange failed: {body}")));
}
let tokens: TokenResponse = token_resp
.json()
.await
.map_err(|e| Error::StateError(format!("token parse failed: {e}")))?;
// --- Fetch userinfo ---
let userinfo: UserinfoResponse = client
.get(&config.userinfo_endpoint())
.bearer_auth(&tokens.access_token)
.send()
.await
.map_err(|e| Error::StateError(format!("userinfo request failed: {e}")))?
.json()
.await
.map_err(|e| Error::StateError(format!("userinfo parse failed: {e}")))?;
// --- Build user state and persist in session ---
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(),
avatar_url: userinfo.picture.unwrap_or_default(),
},
};
set_login_session(session, user_state).await?;
let target = redirect_url
.filter(|u| !u.is_empty())
.unwrap_or_else(|| "/".into());
Ok(Redirect::temporary(&target))
}
/// Clear the user session and redirect to Keycloak's logout endpoint.
///
/// After Keycloak finishes its own logout flow it will redirect
/// back to the application root.
///
/// # Errors
///
/// Returns `Error` if env vars are missing or the session cannot be flushed.
#[axum::debug_handler]
pub async fn logout(session: Session) -> Result<impl IntoResponse, Error> {
let config = OAuthConfig::from_env()?;
// Flush all session data.
session
.flush()
.await
.map_err(|e| Error::StateError(format!("session flush failed: {e}")))?;
let mut url = Url::parse(&config.logout_endpoint())
.map_err(|e| Error::StateError(format!("invalid logout endpoint URL: {e}")))?;
url.query_pairs_mut()
.append_pair("client_id", &config.client_id)
.append_pair("post_logout_redirect_uri", &config.app_url);
Ok(Redirect::temporary(url.as_str()))
}
/// Persist user data into the session.
///
/// # Errors
///
/// Returns `Error` if the session store write fails.
pub async fn set_login_session(session: Session, data: UserStateInner) -> Result<(), Error> {
session
.insert(LOGGED_IN_USER_SESS_KEY, data)
.await
.map_err(|e| Error::StateError(format!("session insert failed: {e}")))
}

View File

@@ -1,49 +0,0 @@
use super::error::Result;
use super::user::{KeyCloakSub, UserEntity};
use mongodb::{bson::doc, Client, Collection};
pub struct Database {
client: Client,
}
impl Database {
pub async fn new(client: Client) -> Self {
Self { client }
}
}
/// Impl of project related DB actions
impl Database {}
/// Impl of user-related actions
impl Database {
async fn users_collection(&self) -> Collection<UserEntity> {
self.client
.database("dashboard")
.collection::<UserEntity>("users")
}
pub async fn get_user_by_kc_sub(&self, kc_sub: KeyCloakSub) -> Result<Option<UserEntity>> {
let c = self.users_collection().await;
let result = c
.find_one(doc! {
"kc_sub" : kc_sub.0
})
.await?;
Ok(result)
}
pub async fn get_user_by_id(&self, user_id: &str) -> Result<Option<UserEntity>> {
let c = self.users_collection().await;
let user_id: mongodb::bson::oid::ObjectId = user_id.parse()?;
let filter = doc! { "_id" : user_id };
let result = c.find_one(filter).await?;
Ok(result)
}
pub async fn insert_user(&self, user: &UserEntity) -> Result<()> {
let c = self.users_collection().await;
let _ = c.insert_one(user).await?;
Ok(())
}
}

View File

@@ -1,78 +1,22 @@
use axum::response::{IntoResponse, Redirect, Response};
use axum::response::IntoResponse;
use reqwest::StatusCode;
use crate::Route;
pub type Result<T> = core::result::Result<T, Error>;
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("{0}")]
NotFound(String),
#[error("{0}")]
BadRequest(String),
#[error("ReqwestError: {0}")]
ReqwestError(#[from] reqwest::Error),
#[error("ServerStateError: {0}")]
ServerStateError(String),
#[error("SessionError: {0}")]
SessionError(#[from] tower_sessions::session::Error),
#[error("AuthSessionLayerNotFound: {0}")]
AuthSessionLayerNotFound(String),
#[error("UserNotLoggedIn")]
UserNotLoggedIn,
#[error("MongoDbError: {0}")]
MongoDbError(#[from] mongodb::error::Error),
#[error("MongoBsonError: {0}")]
MongoBsonError(#[from] mongodb::bson::ser::Error),
#[error("MongoObjectIdParseError: {0}")]
MongoObjectIdParseError(#[from] mongodb::bson::oid::Error),
StateError(String),
#[error("IoError: {0}")]
IoError(#[from] std::io::Error),
#[error("GeneralError: {0}")]
GeneralError(String),
#[error("SerdeError: {0}")]
SerdeError(#[from] serde_json::Error),
#[error("Forbidden: {0}")]
Forbidden(String),
}
impl IntoResponse for Error {
#[tracing::instrument]
fn into_response(self) -> Response {
let message = self.to_string();
tracing::error!("Converting Error to Reponse: {message}");
fn into_response(self) -> axum::response::Response {
let msg = self.to_string();
tracing::error!("Converting Error to Response: {msg}");
match self {
Error::NotFound(_) => (StatusCode::NOT_FOUND, message).into_response(),
Error::BadRequest(_) => (StatusCode::BAD_REQUEST, message).into_response(),
// ideally we would like to redirect with the original URL as the target, but we do not have access to it here
Error::UserNotLoggedIn => Redirect::to(
&Route::Login {
redirect_url: Route::OverviewPage {}.to_string(),
}
.to_string(),
)
.into_response(),
Error::Forbidden(_) => (StatusCode::FORBIDDEN, message).into_response(),
// INTERNAL_SERVER_ERROR variants
_ => {
tracing::error!("Internal Server Error: {:?}", message);
(StatusCode::INTERNAL_SERVER_ERROR, message).into_response()
}
Self::StateError(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
_ => (StatusCode::INTERNAL_SERVER_ERROR, "Unknown error").into_response(),
}
}
}

View File

@@ -1,302 +0,0 @@
use super::error::Result;
use super::user::{KeyCloakSub, UserEntity};
use crate::Route;
use axum::{
extract::Query,
response::{IntoResponse, Redirect, Response},
Extension,
};
use reqwest::StatusCode;
use tracing::{info, warn};
use url::form_urlencoded;
#[derive(serde::Deserialize)]
pub struct CallbackCode {
code: Option<String>,
error: Option<String>,
}
const LOGIN_REDIRECT_URL_SESSION_KEY: &str = "login.redirect.url";
const TEST_USER_SUB: KeyCloakSub = KeyCloakSub(String::new());
#[derive(serde::Deserialize)]
pub struct LoginRedirectQuery {
redirect_url: Option<String>,
}
/// Handler that redirects the user to the login page of Keycloack.
#[axum::debug_handler]
pub async fn redirect_to_keycloack_login(
state: Extension<super::server_state::ServerState>,
user_session: super::auth::UserSession,
session: tower_sessions::Session,
query: Query<LoginRedirectQuery>,
) -> Result<Response> {
// check if already logged in before redirecting again
if user_session.data().is_ok() {
return Ok(Redirect::to(&Route::OverviewPage {}.to_string()).into_response());
}
if let Some(url) = &query.redirect_url {
if !url.is_empty() {
session.insert(LOGIN_REDIRECT_URL_SESSION_KEY, &url).await?;
}
}
// if this is a test user then skip login
if state.keycloak_variables.enable_test_user {
return login_test_user(state, session).await;
}
let kc_base_url = &state.keycloak_variables.base_url;
let kc_realm = &state.keycloak_variables.realm;
let kc_client_id = &state.keycloak_variables.client_id;
let redirect_uri = format!("http://localhost:8000/auth/callback");
let encoded_redirect_uri: String =
form_urlencoded::byte_serialize(redirect_uri.as_bytes()).collect();
// Needed for running locally.
// This will not panic on production and it will return the original so we can keep it
let routed_kc_base_url = kc_base_url.replace("keycloak", "localhost");
Ok(Redirect::to(
format!("{routed_kc_base_url}/realms/{kc_realm}/protocol/openid-connect/auth?client_id={kc_client_id}&response_type=code&scope=openid%20profile%20email&redirect_uri={encoded_redirect_uri}").as_str())
.into_response())
}
/// Helper function that automatically logs the user in as a test user.
async fn login_test_user(
state: Extension<super::server_state::ServerState>,
session: tower_sessions::Session,
) -> Result<Response> {
let user = state.db.get_user_by_kc_sub(TEST_USER_SUB).await?;
// if we do not have a test user already, create one
let user = if let Some(user) = user {
info!("Existing test user logged in");
user
} else {
info!("Test User not found, inserting ...");
let user = UserEntity {
_id: mongodb::bson::oid::ObjectId::new(),
created_at: mongodb::bson::DateTime::now(),
kc_sub: TEST_USER_SUB,
email: "exampleuser@domain.com".to_string(),
};
state.db.insert_user(&user).await?;
user
};
info!("Test User successfuly logged in: {:?}", user);
let data = super::auth::LoggedInData {
id: user._id.to_string(),
token_id: String::new(),
username: "tester".to_string(),
avatar_url: None,
};
super::auth::login(&session, &data).await?;
// redirect to the URL stored in the session if available
let redirect_url = session
.remove::<String>(LOGIN_REDIRECT_URL_SESSION_KEY)
.await?
.unwrap_or_else(|| Route::OverviewPage {}.to_string());
Ok(Redirect::to(&redirect_url).into_response())
}
/// Handler function executed once KC redirects back to us. Creates database entries if
/// needed and initializes the user session to mark the user as "logged in".
#[axum::debug_handler]
pub async fn handle_login_callback(
state: Extension<super::server_state::ServerState>,
session: tower_sessions::Session,
Query(params): Query<CallbackCode>,
) -> Result<Response> {
// now make sure the user actually authorized the app and that there was no error
let Some(code) = params.code else {
warn!("Code was not provided, error: {:?}", params.error);
return Ok(Redirect::to(&Route::OverviewPage {}.to_string()).into_response());
};
// if on dev environment we get the internal kc url
let kc_base_url = std::env::var("KEYCLOAK_ADMIN_URL")
.unwrap_or_else(|_| state.keycloak_variables.base_url.clone());
let kc_realm = &state.keycloak_variables.realm;
let kc_client_id = &state.keycloak_variables.client_id;
let kc_client_secret = &state.keycloak_variables.client_secret;
let redirect_uri = format!("http://localhost:8000/auth/callback");
// exchange the code for an access token
let token = exchange_code(
&code,
&kc_base_url,
kc_realm,
kc_client_id,
kc_client_secret,
redirect_uri.as_str(),
)
.await?;
// use the access token to get the user information
let user_info = get_user_info(&token, &kc_base_url, kc_realm).await?;
// Check if the user is a member of the organization (only on dev and demo environments)
let base_url = state.keycloak_variables.base_url.clone();
let is_for_devs = base_url.contains("dev") || base_url.contains("demo");
if is_for_devs {
let Some(github_login) = user_info.github_login.as_ref() else {
return Err(crate::infrastructure::error::Error::Forbidden(
"GitHub login not available.".to_string(),
));
};
if !is_org_member(github_login).await? {
return Err(crate::infrastructure::error::Error::Forbidden(
"You are not a member of the organization.".to_string(),
));
}
}
// now check if we have a user already
let kc_sub = KeyCloakSub(user_info.sub);
let user = state.db.get_user_by_kc_sub(kc_sub.clone()).await?;
// if we do not have a user already, create one
let user = if let Some(user) = user {
info!("Existing user logged in");
user
} else {
info!("User not found, creating ...");
let user = UserEntity {
_id: mongodb::bson::oid::ObjectId::new(),
created_at: mongodb::bson::DateTime::now(),
kc_sub,
email: user_info.email.clone(),
};
state.db.insert_user(&user).await?;
user
};
info!("User successfuly logged in");
// we now have access token and information about the user that just logged in, as well as an
// existing or newly created user database entity.
// Store information in session storage that we want (eg name and avatar url + databae id) to make the user "logged in"!
// Redirect the user somewhere
let data = super::auth::LoggedInData {
id: user._id.to_string(),
token_id: token.id_token,
username: user_info.preferred_username,
avatar_url: user_info.picture,
};
super::auth::login(&session, &data).await?;
// redirect to the URL stored in the session if available
let redirect_url = session
.remove::<String>(LOGIN_REDIRECT_URL_SESSION_KEY)
.await?
.unwrap_or_else(|| Route::OverviewPage {}.to_string());
Ok(Redirect::to(&redirect_url).into_response())
}
#[derive(serde::Deserialize)]
#[allow(dead_code)] // not all fields are currently used
struct AccessToken {
access_token: String,
expires_in: u64,
refresh_token: String,
refresh_expires_in: u64,
id_token: String,
}
/// Exchange KC code for an access token
async fn exchange_code(
code: &str,
kc_base_url: &str,
kc_realm: &str,
kc_client_id: &str,
kc_client_secret: &str,
redirect_uri: &str,
) -> Result<AccessToken> {
let res = reqwest::Client::new()
.post(format!(
"{kc_base_url}/realms/{kc_realm}/protocol/openid-connect/token",
))
.form(&[
("grant_type", "authorization_code"),
("client_id", kc_client_id),
("client_secret", kc_client_secret),
("code", code),
("redirect_uri", redirect_uri),
])
.send()
.await?;
let res: AccessToken = res.json().await?;
Ok(res)
}
/// Query the openid-connect endpoint to get the user info by using the access token.
async fn get_user_info(token: &AccessToken, kc_base_url: &str, kc_realm: &str) -> Result<UserInfo> {
let client = reqwest::Client::new();
let url = format!("{kc_base_url}/realms/{kc_realm}/protocol/openid-connect/userinfo");
let mut request = client.get(&url).bearer_auth(token.access_token.clone());
// If KEYCLOAK_ADMIN_URL is NOT set (i.e. we're on the local Keycloak),
// add the HOST header for local testing.
if std::env::var("KEYCLOAK_ADMIN_URL").is_err() {
request = request.header("HOST", "localhost:8888");
}
let res = request.send().await?;
let res: UserInfo = res.json().await?;
Ok(res)
}
/// Contains selected fields from the user information call to KC
/// https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
#[derive(serde::Deserialize)]
#[allow(dead_code)] // not all fields are currently used
struct UserInfo {
sub: String, // subject element of the ID Token
name: String,
given_name: String,
family_name: String,
preferred_username: String,
email: String,
picture: Option<String>,
github_login: Option<String>,
}
/// Check if a user is a member of the organization
const GITHUB_ORG: &str = "etospheres-labs";
async fn is_org_member(username: &str) -> Result<bool> {
let url = format!("https://api.github.com/orgs/{GITHUB_ORG}/members/{username}");
let client = reqwest::Client::new();
let response = client
.get(&url)
.header("Accept", "application/vnd.github+json") // GitHub requires a User-Agent header.
.header("User-Agent", "etopay-app")
.send()
.await?;
match response.status() {
StatusCode::NO_CONTENT => Ok(true),
status => {
tracing::warn!(
"{}: User '{}' is not a member of the organization",
status.as_str(),
username
);
Ok(false)
}
}
}

View File

@@ -1,10 +1,10 @@
#![cfg(feature = "server")]
mod auth;
mod error;
mod server;
mod state;
mod login;
pub mod auth;
pub mod db;
pub mod error;
pub mod server;
pub mod server_state;
pub mod user;
pub use auth::*;
pub use error::*;
pub use server::*;
pub use state::*;

View File

@@ -1,105 +1,56 @@
use super::error::Error;
use super::server_state::ServerState;
use crate::infrastructure::{auth::KeycloakVariables, server_state::ServerStateInner};
use crate::infrastructure::{
auth_callback, auth_login, logout, PendingOAuthStore, UserState, UserStateInner,
};
use axum::{routing::*, Extension};
use dioxus::dioxus_core::Element;
use dioxus::prelude::*;
use dioxus_logger::tracing::info;
use reqwest::{
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
Method,
};
use axum::routing::get;
use axum::Extension;
use time::Duration;
use tower_http::cors::{Any, CorsLayer};
use tower_sessions::{
cookie::{Key, SameSite},
Expiry, MemoryStore, SessionManagerLayer,
};
pub fn server_start(app_fn: fn() -> Element) -> Result<(), Error> {
dotenvy::dotenv().ok();
use tower_sessions::{cookie::Key, MemoryStore, SessionManagerLayer};
/// Start the Axum server with Dioxus fullstack, session management,
/// and Keycloak OAuth routes.
///
/// # Errors
///
/// Returns `Error` if the tokio runtime or TCP listener fails to start.
pub fn server_start(app: fn() -> Element) -> Result<(), super::Error> {
tokio::runtime::Runtime::new()?.block_on(async move {
info!("Connecting to the database ...");
let mongodb_uri = get_env_variable("MONGODB_URI");
let client = mongodb::Client::with_uri_str(mongodb_uri).await?;
let db = super::db::Database::new(client).await;
info!("Connected");
let keycloak_variables: KeycloakVariables = KeycloakVariables {
base_url: get_env_variable("BASE_URL_AUTH"),
realm: get_env_variable("KC_REALM"),
client_id: get_env_variable("KC_CLIENT_ID"),
client_secret: get_env_variable("KC_CLIENT_SECRET"),
enable_test_user: std::env::var("ENABLE_TEST_USER").is_ok_and(|v| v == "yes"),
};
let state: ServerState = ServerStateInner {
db,
keycloak_variables: Box::leak(Box::new(keycloak_variables)),
let state: UserState = UserStateInner {
access_token: "abcd".into(),
sub: "abcd".into(),
refresh_token: "abcd".into(),
..Default::default()
}
.into();
// This uses `tower-sessions` to establish a layer that will provide the session
// as a request extension.
let key = Key::generate(); // This is only used for demonstration purposes; provide a proper
// cryptographic key in a real application.
let session_store = MemoryStore::default();
let session_layer = SessionManagerLayer::new(session_store)
// only allow session cookie in HTTPS connections (also works on localhost)
.with_secure(true)
.with_expiry(Expiry::OnInactivity(Duration::days(1)))
// Allow the session cookie to be sent when request originates from outside our
// domain. Required for the browser to pass the cookie when returning from github auth page.
.with_same_site(SameSite::Lax)
let key = Key::generate();
let store = MemoryStore::default();
let session = SessionManagerLayer::new(store)
.with_secure(false)
// Lax is required so the browser sends the session cookie
// on the redirect back from Keycloak (cross-origin GET).
// Strict would silently drop the cookie on that navigation.
.with_same_site(tower_sessions::cookie::SameSite::Lax)
.with_expiry(tower_sessions::Expiry::OnInactivity(Duration::hours(24)))
.with_signed(key);
let cors = CorsLayer::new()
// allow `GET` and `POST` when accessing the resource
.allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE])
// .allow_credentials(true)
.allow_headers([AUTHORIZATION, ACCEPT, CONTENT_TYPE])
// allow requests from any origin
.allow_origin(Any);
// Build our application web api router.
let web_api_router = Router::new()
// .route("/webhook/gitlab", post(super::gitlab::webhook_handler))
.route("/auth", get(super::login::redirect_to_keycloack_login))
.route("/auth/logout", get(super::auth::logout))
.route("/auth/callback", get(super::login::handle_login_callback))
// Server side render the application, serve static assets, and register the server functions.
.serve_dioxus_application(ServeConfig::default(), app_fn)
.layer(Extension(state))
.layer(session_layer)
.layer(cors)
.layer(tower_http::trace::TraceLayer::new_for_http());
// Start it.
let addr = dioxus_cli_config::fullstack_address_or_localhost();
info!("Server address: {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await?;
let listener = tokio::net::TcpListener::bind(addr).await?;
// Layers are applied AFTER serve_dioxus_application so they
// wrap both the custom Axum routes AND the Dioxus server
// function routes (e.g. check_auth needs Session access).
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(Extension(PendingOAuthStore::default()))
.layer(Extension(state))
.layer(session);
info!("Serving at {addr}");
axum::serve(listener, router.into_make_service()).await?;
axum::serve(listener, web_api_router.into_make_service()).await?;
Ok(())
})
}
/// Tries to load the value from an environment as String.
///
/// # Arguments
///
/// * `key` - the environment variable key to try to load
///
/// # Panics
///
/// Panics if the environment variable does not exist.
fn get_env_variable(key: &str) -> String {
std::env::var(key).unwrap_or_else(|_| {
tracing::error!("{key} environment variable not set. {key} must be set!");
panic!("Environment variable {key} not present")
})
}

View File

@@ -1,55 +0,0 @@
//! Implements a [`ServerState`] that is available in the dioxus server functions
//! as well as in axum handlers.
//! Taken from https://github.com/dxps/dioxus_playground/tree/44a4ddb223e6afe50ef195e61aa2b7182762c7da/dioxus-05-fullstack-routing-axum-pgdb
use super::auth::KeycloakVariables;
use super::error::{Error, Result};
use axum::http;
use std::ops::Deref;
use std::sync::Arc;
/// This is stored as an "extension" object in the axum webserver
/// We can get it in the dioxus server functions using
/// ```rust
/// let state: crate::infrastructure::server_state::ServerState = extract().await?;
/// ```
#[derive(Clone)]
pub struct ServerState(Arc<ServerStateInner>);
impl Deref for ServerState {
type Target = ServerStateInner;
fn deref(&self) -> &Self::Target {
&self.0
}
}
pub struct ServerStateInner {
pub db: crate::infrastructure::db::Database,
pub keycloak_variables: &'static KeycloakVariables,
}
impl From<ServerStateInner> for ServerState {
fn from(value: ServerStateInner) -> Self {
Self(Arc::new(value))
}
}
impl<S> axum::extract::FromRequestParts<S> for ServerState
where
S: std::marker::Sync + std::marker::Send,
{
type Rejection = Error;
async fn from_request_parts(parts: &mut http::request::Parts, _: &S) -> Result<Self> {
parts
.extensions
.get::<ServerState>()
.cloned()
.ok_or(Error::ServerStateError(
"ServerState extension should exist".to_string(),
))
}
}

View File

@@ -0,0 +1,61 @@
use std::{
ops::{Deref, DerefMut},
sync::Arc,
};
use axum::extract::FromRequestParts;
use serde::{Deserialize, Serialize};
use tracing::debug;
#[derive(Debug, Clone)]
pub struct UserState(Arc<UserStateInner>);
impl Deref for UserState {
type Target = UserStateInner;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<UserStateInner> for UserState {
fn from(value: UserStateInner) -> Self {
Self(Arc::new(value))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UserStateInner {
/// Subject in Oauth
pub sub: String,
/// Access Token
pub access_token: String,
/// Refresh Token
pub refresh_token: String,
/// User
pub user: User,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct User {
/// Email
pub email: String,
/// Avatar Url
pub avatar_url: String,
}
impl<S> FromRequestParts<S> for UserState
where
S: std::marker::Sync + std::marker::Send,
{
type Rejection = super::Error;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_: &S,
) -> Result<Self, super::Error> {
parts
.extensions
.get::<UserState>()
.cloned()
.ok_or(super::Error::StateError("Unable to get extension".into()))
}
}

View File

@@ -1,21 +0,0 @@
use serde::{Deserialize, Serialize};
/// Wraps a `String` to store the sub from KC
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyCloakSub(pub String);
/// database entity to store our users
#[derive(Debug, Serialize, Deserialize)]
pub struct UserEntity {
/// Our unique id of the user, for now this is just the mongodb assigned id
pub _id: mongodb::bson::oid::ObjectId,
/// Time the user was created
pub created_at: mongodb::bson::DateTime,
/// KC subject element of the ID Token
pub kc_sub: KeyCloakSub,
/// User email as provided during signup with the identity provider
pub email: String,
}

View File

@@ -1,8 +1,11 @@
mod app;
mod components;
pub mod infrastructure;
mod models;
mod pages;
pub use app::*;
pub use components::*;
pub use models::*;
pub use pages::*;

3
src/models/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
mod user;
pub use user::*;

21
src/models/user.rs Normal file
View File

@@ -0,0 +1,21 @@
use serde::Deserialize;
use serde::Serialize;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UserData {
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoggedInState {
pub access_token: String,
pub email: String,
}
impl LoggedInState {
pub fn new(access_token: String, email: String) -> Self {
Self {
access_token,
email,
}
}
}

View File

@@ -1,8 +1,118 @@
use dioxus::prelude::*;
use dioxus_free_icons::icons::bs_icons::BsBook;
use dioxus_free_icons::icons::fa_solid_icons::{FaChartLine, FaCubes, FaGears};
use dioxus_free_icons::Icon;
use crate::components::DashboardCard;
use crate::Route;
/// Overview dashboard page rendered inside the `AppShell` layout.
///
/// Displays a welcome heading and a grid of quick-access cards
/// for the main GenAI platform tools.
#[component]
pub fn OverviewPage() -> Element {
rsx! {
h1 { "Hello" }
// Check authentication status on mount via a server function.
let auth_check = use_resource(check_auth);
let navigator = use_navigator();
// Once the server responds, redirect unauthenticated users to /auth.
use_effect(move || {
if let Some(Ok(false)) = auth_check() {
navigator.push(NavigationTarget::<Route>::External(
"/auth?redirect_url=/".into(),
));
}
});
match auth_check() {
// Still waiting for the server to respond.
None => rsx! {},
// Not authenticated -- render nothing while the redirect fires.
Some(Ok(false)) => rsx! {},
// Authenticated -- render the overview dashboard.
Some(Ok(true)) => rsx! {
section { class: "overview-page",
h1 { class: "overview-heading", "GenAI Dashboard" }
div { class: "dashboard-grid",
DashboardCard {
title: "Documentation".to_string(),
description: "Guides & API Reference".to_string(),
href: "#".to_string(),
icon: rsx! {
Icon {
icon: BsBook,
width: 28,
height: 28,
}
},
}
DashboardCard {
title: "Langfuse".to_string(),
description: "Observability & Analytics".to_string(),
href: "#".to_string(),
icon: rsx! {
Icon {
icon: FaChartLine,
width: 28,
height: 28,
}
},
}
DashboardCard {
title: "Langchain".to_string(),
description: "Agent Framework".to_string(),
href: "#".to_string(),
icon: rsx! {
Icon {
icon: FaGears,
width: 28,
height: 28,
}
},
}
DashboardCard {
title: "Hugging Face".to_string(),
description: "Browse Models".to_string(),
href: "#".to_string(),
icon: rsx! {
Icon {
icon: FaCubes,
width: 28,
height: 28,
}
},
}
}
}
},
// Server error -- surface it so it is not silently swallowed.
Some(Err(err)) => rsx! {
p { "Error: {err}" }
},
}
}
/// Check whether the current request has an active logged-in session.
///
/// # Returns
///
/// `true` if the session contains a logged-in user, `false` otherwise.
///
/// # Errors
///
/// Returns `ServerFnError` if the session cannot be extracted from the request.
#[server]
async fn check_auth() -> Result<bool, ServerFnError> {
use crate::infrastructure::{UserStateInner, LOGGED_IN_USER_SESS_KEY};
use tower_sessions::Session;
// Extract the tower_sessions::Session from the Axum request.
let session: Session = FullstackContext::extract().await?;
let user: Option<UserStateInner> = session
.get(LOGGED_IN_USER_SESS_KEY)
.await
.map_err(|e| ServerFnError::new(format!("session read failed: {e}")))?;
Ok(user.is_some())
}