ci: added basic workflows #2

Merged
sharang merged 14 commits from feat/CAI-5 into main 2026-02-18 09:46:29 +00:00
4 changed files with 0 additions and 427 deletions
Showing only changes of commit 3083030b84 - Show all commits

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,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,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

@@ -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,
}