feat(db): Added database setup and basic types (#5)
All checks were successful
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 2m21s
CI / Security Audit (push) Successful in 1m44s
CI / Tests (push) Successful in 2m55s
CI / Deploy (push) Successful in 2s

Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com>
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
2026-02-20 14:58:14 +00:00
parent 5ce600e32b
commit e68f840f2b
23 changed files with 1375 additions and 480 deletions

View File

@@ -12,7 +12,11 @@ use rand::RngExt;
use tower_sessions::Session;
use url::Url;
use crate::infrastructure::{state::User, Error, UserStateInner};
use crate::infrastructure::{
server_state::ServerState,
state::{User, UserStateInner},
Error,
};
pub const LOGGED_IN_USER_SESS_KEY: &str = "logged-in-user";
@@ -55,70 +59,6 @@ impl PendingOAuthStore {
}
}
/// Configuration loaded from environment variables for Keycloak OAuth.
struct OAuthConfig {
keycloak_url: String,
realm: String,
client_id: String,
redirect_uri: String,
app_url: String,
}
impl OAuthConfig {
/// Load OAuth configuration from environment variables.
///
/// # 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
)
}
}
/// Generate a cryptographically random state string for CSRF protection.
fn generate_state() -> String {
let bytes: [u8; 32] = rand::rng().random();
@@ -165,35 +105,36 @@ fn derive_code_challenge(verifier: &str) -> String {
///
/// # Errors
///
/// Returns `Error` if env vars are missing.
/// Returns `Error` if the Keycloak config is missing or the URL is malformed.
#[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, Error> {
let config = OAuthConfig::from_env()?;
let state = generate_state();
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(
state.clone(),
csrf_state.clone(),
PendingOAuthEntry {
redirect_url,
code_verifier,
},
);
let mut url = Url::parse(&config.auth_endpoint())
let mut url = Url::parse(&kc.auth_endpoint())
.map_err(|e| Error::StateError(format!("invalid auth endpoint URL: {e}")))?;
url.query_pairs_mut()
.append_pair("client_id", &config.client_id)
.append_pair("redirect_uri", &config.redirect_uri)
.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", &state)
.append_pair("state", &csrf_state)
.append_pair("code_challenge", &code_challenge)
.append_pair("code_challenge_method", "S256");
@@ -213,6 +154,10 @@ struct UserinfoResponse {
/// The subject identifier (unique user ID in Keycloak).
sub: String,
email: Option<String>,
/// Keycloak `preferred_username` claim.
preferred_username: Option<String>,
/// Full name from the Keycloak profile.
name: Option<String>,
/// Keycloak may include a picture/avatar URL via protocol mappers.
picture: Option<String>,
}
@@ -234,10 +179,11 @@ struct UserinfoResponse {
#[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, Error> {
let config = OAuthConfig::from_env()?;
let kc = state.keycloak;
// --- CSRF validation via the in-memory pending store ---
let returned_state = params
@@ -255,11 +201,11 @@ pub async fn auth_callback(
let client = reqwest::Client::new();
let token_resp = client
.post(config.token_endpoint())
.post(kc.token_endpoint())
.form(&[
("grant_type", "authorization_code"),
("client_id", &config.client_id),
("redirect_uri", &config.redirect_uri),
("client_id", kc.client_id.as_str()),
("redirect_uri", kc.redirect_uri.as_str()),
("code", code),
("code_verifier", &entry.code_verifier),
])
@@ -279,7 +225,7 @@ pub async fn auth_callback(
// --- Fetch userinfo ---
let userinfo: UserinfoResponse = client
.get(config.userinfo_endpoint())
.get(kc.userinfo_endpoint())
.bearer_auth(&tokens.access_token)
.send()
.await
@@ -288,6 +234,12 @@ pub async fn auth_callback(
.await
.map_err(|e| Error::StateError(format!("userinfo parse failed: {e}")))?;
// Prefer `name`, fall back to `preferred_username`, then empty.
let display_name = userinfo
.name
.or(userinfo.preferred_username)
.unwrap_or_default();
// --- Build user state and persist in session ---
let user_state = UserStateInner {
sub: userinfo.sub,
@@ -295,6 +247,7 @@ pub async fn auth_callback(
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(),
},
};
@@ -316,10 +269,13 @@ pub async fn auth_callback(
///
/// # Errors
///
/// Returns `Error` if env vars are missing or the session cannot be flushed.
/// Returns `Error` if the session cannot be flushed or the URL is malformed.
#[axum::debug_handler]
pub async fn logout(session: Session) -> Result<impl IntoResponse, Error> {
let config = OAuthConfig::from_env()?;
pub async fn logout(
session: Session,
Extension(state): Extension<ServerState>,
) -> Result<impl IntoResponse, Error> {
let kc = state.keycloak;
// Flush all session data.
session
@@ -327,12 +283,12 @@ pub async fn logout(session: Session) -> Result<impl IntoResponse, Error> {
.await
.map_err(|e| Error::StateError(format!("session flush failed: {e}")))?;
let mut url = Url::parse(&config.logout_endpoint())
let mut url = Url::parse(&kc.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);
.append_pair("client_id", &kc.client_id)
.append_pair("post_logout_redirect_uri", &kc.app_url);
Ok(Redirect::temporary(url.as_str()))
}