feat: enhance application state management with cookie support

- Added cookie key to AppState for managing session cookies.
- Updated AppState initialization to derive cookie key from configuration.
- Removed session-based authentication option from cargo-generate prompts.
- Refactored JWT authentication logic to improve clarity and error handling.
- Updated password validation to align with NIST recommendations (minimum length increased).
- Removed unused session store implementation and related code.
- Improved error handling in user repository for unique constraint violations.
- Refactored OIDC service to include state management for authentication flow.
- Cleaned up dependencies in Cargo.toml and Cargo.toml.template for clarity and efficiency.
This commit is contained in:
2026-03-05 01:28:27 +01:00
parent c368293cd4
commit 9ca4eeddb4
25 changed files with 440 additions and 1340 deletions

View File

@@ -5,28 +5,16 @@ edition = "2024"
[features]
default = ["sqlite"]
sqlite = [
"sqlx/sqlite",
"k-core/sqlite",
"tower-sessions-sqlx-store",
"k-core/sessions-db",
]
postgres = [
"sqlx/postgres",
"k-core/postgres",
"tower-sessions-sqlx-store",
"k-core/sessions-db",
]
sqlite = ["sqlx/sqlite", "k-core/sqlite"]
postgres = ["sqlx/postgres", "k-core/postgres"]
broker-nats = ["dep:futures-util", "k-core/broker-nats"]
auth-axum-login = ["dep:axum-login", "dep:password-auth"]
auth-oidc = ["dep:openidconnect", "dep:url"]
auth-oidc = ["dep:openidconnect", "dep:url", "dep:axum-extra"]
auth-jwt = ["dep:jsonwebtoken"]
[dependencies]
k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [
"logging",
"db-sqlx",
"sessions-db",
] }
domain = { path = "../domain" }
@@ -38,19 +26,17 @@ anyhow = "1.0"
tokio = { version = "1.48.0", features = ["full"] }
tracing = "0.1"
uuid = { version = "1.19.0", features = ["v4", "serde"] }
tower-sessions-sqlx-store = { version = "0.15.0", optional = true }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
futures-core = "0.3"
password-auth = "1.0"
# Optional dependencies
async-nats = { version = "0.45", optional = true }
futures-util = { version = "0.3", optional = true }
futures-core = "0.3"
tower-sessions = "0.14"
# Auth dependencies (optional)
axum-login = { version = "0.18", optional = true }
password-auth = { version = "1.0", optional = true }
openidconnect = { version = "4.0.1", optional = true }
url = { version = "2.5.8", optional = true }
axum-extra = { version = "0.10", features = ["cookie-private"], optional = true }
jsonwebtoken = { version = "10.2.0", features = [
"sha2",
"p256",

View File

@@ -4,29 +4,17 @@ version = "0.1.0"
edition = "2024"
[features]
default = ["{{database}}"{% if auth_session %}, "auth-axum-login"{% endif %}{% if auth_oidc %}, "auth-oidc"{% endif %}{% if auth_jwt %}, "auth-jwt"{% endif %}]
sqlite = [
"sqlx/sqlite",
"k-core/sqlite",
"tower-sessions-sqlx-store",
"k-core/sessions-db",
]
postgres = [
"sqlx/postgres",
"k-core/postgres",
"tower-sessions-sqlx-store",
"k-core/sessions-db",
]
default = ["{{database}}"{% if auth_oidc %}, "auth-oidc"{% endif %}{% if auth_jwt %}, "auth-jwt"{% endif %}]
sqlite = ["sqlx/sqlite", "k-core/sqlite"]
postgres = ["sqlx/postgres", "k-core/postgres"]
broker-nats = ["dep:futures-util", "k-core/broker-nats"]
auth-axum-login = ["dep:axum-login", "dep:password-auth"]
auth-oidc = ["dep:openidconnect", "dep:url"]
auth-oidc = ["dep:openidconnect", "dep:url", "dep:axum-extra"]
auth-jwt = ["dep:jsonwebtoken"]
[dependencies]
k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [
"logging",
"db-sqlx",
"sessions-db",
] }
domain = { path = "../domain" }
@@ -38,18 +26,15 @@ anyhow = "1.0"
tokio = { version = "1.48.0", features = ["full"] }
tracing = "0.1"
uuid = { version = "1.19.0", features = ["v4", "serde"] }
tower-sessions-sqlx-store = { version = "0.15.0", optional = true }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
futures-core = "0.3"
password-auth = "1.0"
# Optional dependencies
async-nats = { version = "0.45", optional = true }
futures-util = { version = "0.3", optional = true }
futures-core = "0.3"
tower-sessions = "0.14"
# Auth dependencies (optional)
axum-login = { version = "0.18", optional = true }
password-auth = { version = "1.0", optional = true }
openidconnect = { version = "4.0.1", optional = true }
url = { version = "2.5.8", optional = true }
axum-extra = { version = "0.10", features = ["cookie-private"], optional = true }
jsonwebtoken = { version = "9.3", optional = true }
# reqwest = { version = "0.13.1", features = ["blocking", "json"], optional = true }

View File

@@ -2,122 +2,14 @@
//!
//! This module contains the concrete implementation of authentication mechanisms.
#[cfg(feature = "auth-axum-login")]
pub mod backend {
use std::sync::Arc;
/// Hash a password using the password-auth crate
pub fn hash_password(password: &str) -> String {
password_auth::generate_hash(password)
}
use axum_login::{AuthnBackend, UserId};
use password_auth::verify_password;
use serde::{Deserialize, Serialize};
use tower_sessions::SessionManagerLayer;
use uuid::Uuid;
use domain::{User, UserRepository};
// We use the same session store as defined in infra
use crate::session_store::InfraSessionStore;
/// Wrapper around domain User to implement AuthUser
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthUser(pub User);
impl axum_login::AuthUser for AuthUser {
type Id = Uuid;
fn id(&self) -> Self::Id {
self.0.id
}
fn session_auth_hash(&self) -> &[u8] {
// Use password hash to invalidate sessions if password changes
self.0
.password_hash
.as_ref()
.map(|s| s.as_bytes())
.unwrap_or(&[])
}
}
#[derive(Clone)]
pub struct AuthBackend {
pub user_repo: Arc<dyn UserRepository>,
}
impl AuthBackend {
pub fn new(user_repo: Arc<dyn UserRepository>) -> Self {
Self { user_repo }
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct Credentials {
pub email: domain::Email,
pub password: domain::Password,
}
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error(transparent)]
Anyhow(#[from] anyhow::Error),
}
impl AuthnBackend for AuthBackend {
type User = AuthUser;
type Credentials = Credentials;
type Error = AuthError;
async fn authenticate(
&self,
creds: Self::Credentials,
) -> Result<Option<Self::User>, Self::Error> {
let user = self
.user_repo
.find_by_email(creds.email.as_ref())
.await
.map_err(|e| AuthError::Anyhow(anyhow::anyhow!(e)))?;
if let Some(user) = user {
if let Some(hash) = &user.password_hash {
// Verify password
if verify_password(creds.password.as_ref(), hash).is_ok() {
return Ok(Some(AuthUser(user)));
}
}
}
Ok(None)
}
async fn get_user(
&self,
user_id: &UserId<Self>,
) -> Result<Option<Self::User>, Self::Error> {
let user = self
.user_repo
.find_by_id(*user_id)
.await
.map_err(|e| AuthError::Anyhow(anyhow::anyhow!(e)))?;
Ok(user.map(AuthUser))
}
}
pub type AuthSession = axum_login::AuthSession<AuthBackend>;
pub type AuthManagerLayer = axum_login::AuthManagerLayer<AuthBackend, InfraSessionStore>;
pub async fn setup_auth_layer(
session_layer: SessionManagerLayer<InfraSessionStore>,
user_repo: Arc<dyn UserRepository>,
) -> Result<AuthManagerLayer, AuthError> {
let backend = AuthBackend::new(user_repo);
let auth_layer = axum_login::AuthManagerLayerBuilder::new(backend, session_layer).build();
Ok(auth_layer)
}
pub fn hash_password(password: &str) -> String {
password_auth::generate_hash(password)
}
/// Verify a password against a stored hash
pub fn verify_password(password: &str, hash: &str) -> bool {
password_auth::verify_password(password, hash).is_ok()
}
#[cfg(feature = "auth-oidc")]

View File

@@ -15,6 +15,7 @@ use openidconnect::{
},
reqwest,
};
use serde::{Deserialize, Serialize};
pub type OidcClient = Client<
EmptyAdditionalClaims,
@@ -36,9 +37,18 @@ pub type OidcClient = Client<
EndpointMaybeSet, // HasUserInfoUrl (Discovered, might be missing)
>;
/// Serializable OIDC state stored in an encrypted cookie during the auth code flow
#[derive(Debug, Serialize, Deserialize)]
pub struct OidcState {
pub csrf_token: CsrfToken,
pub nonce: OidcNonce,
pub pkce_verifier: PkceVerifier,
}
#[derive(Clone)]
pub struct OidcService {
client: OidcClient,
http_client: reqwest::Client,
resource_id: Option<ResourceId>,
}
@@ -61,11 +71,7 @@ impl OidcService {
tracing::debug!("🔵 OIDC Setup: Redirect = '{}'", redirect_url);
tracing::debug!(
"🔵 OIDC Setup: Secret = {:?}",
if client_secret.is_some() {
"SET"
} else {
"NONE"
}
if client_secret.is_some() { "SET" } else { "NONE" }
);
let http_client = reqwest::ClientBuilder::new()
@@ -78,13 +84,13 @@ impl OidcService {
)
.await?;
// Convert to openidconnect types
let oidc_client_id = openidconnect::ClientId::new(client_id.as_ref().to_string());
let oidc_client_secret = client_secret
.as_ref()
.filter(|s| !s.is_empty())
.map(|s| openidconnect::ClientSecret::new(s.as_ref().to_string()));
let oidc_redirect_url = openidconnect::RedirectUrl::new(redirect_url.as_ref().to_string())?;
let oidc_redirect_url =
openidconnect::RedirectUrl::new(redirect_url.as_ref().to_string())?;
let client = CoreClient::from_provider_metadata(
provider_metadata,
@@ -95,14 +101,16 @@ impl OidcService {
Ok(Self {
client,
http_client,
resource_id,
})
}
/// Get the authorization URL and associated state for OIDC login
/// Get the authorization URL and associated state for OIDC login.
///
/// Returns structured data instead of a raw tuple for better type safety
pub fn get_authorization_url(&self) -> AuthorizationUrlData {
/// Returns `(AuthorizationUrlData, OidcState)` where `OidcState` should be
/// serialized and stored in an encrypted cookie for the duration of the flow.
pub fn get_authorization_url(&self) -> (AuthorizationUrlData, OidcState) {
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let (auth_url, csrf_token, nonce) = self
@@ -117,12 +125,20 @@ impl OidcService {
.set_pkce_challenge(pkce_challenge)
.url();
AuthorizationUrlData {
url: auth_url.into(),
let oidc_state = OidcState {
csrf_token: CsrfToken::new(csrf_token.secret().to_string()),
nonce: OidcNonce::new(nonce.secret().to_string()),
pkce_verifier: PkceVerifier::new(pkce_verifier.secret().to_string()),
}
};
let auth_data = AuthorizationUrlData {
url: auth_url.into(),
csrf_token: oidc_state.csrf_token.clone(),
nonce: oidc_state.nonce.clone(),
pkce_verifier: oidc_state.pkce_verifier.clone(),
};
(auth_data, oidc_state)
}
/// Resolve the OIDC callback with type-safe parameters
@@ -132,10 +148,6 @@ impl OidcService {
nonce: OidcNonce,
pkce_verifier: PkceVerifier,
) -> anyhow::Result<OidcUser> {
let http_client = reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.build()?;
let oidc_pkce_verifier =
openidconnect::PkceCodeVerifier::new(pkce_verifier.as_ref().to_string());
let oidc_nonce = openidconnect::Nonce::new(nonce.as_ref().to_string());
@@ -146,7 +158,7 @@ impl OidcService {
code.as_ref().to_string(),
))?
.set_pkce_verifier(oidc_pkce_verifier)
.request_async(&http_client)
.request_async(&self.http_client)
.await?;
let id_token = token_response
@@ -178,19 +190,17 @@ impl OidcService {
let email = if let Some(email) = claims.email() {
Some(email.as_str().to_string())
} else {
// Fallback: Call UserInfo Endpoint using the Access Token
tracing::debug!("🔵 Email missing in ID Token, fetching UserInfo...");
let user_info: UserInfoClaims<EmptyAdditionalClaims, CoreGenderClaim> = self
.client
.user_info(token_response.access_token().clone(), None)?
.request_async(&http_client)
.request_async(&self.http_client)
.await?;
user_info.email().map(|e| e.as_str().to_string())
};
// If email is still missing, we must error out because your app requires valid emails
let email =
email.ok_or_else(|| anyhow!("User has no verified email address in ZITADEL"))?;

View File

@@ -5,8 +5,6 @@ use crate::SqliteUserRepository;
use crate::db::DatabasePool;
use domain::UserRepository;
use k_core::session::store::InfraSessionStore;
#[derive(Debug, thiserror::Error)]
pub enum FactoryError {
#[error("Database error: {0}")]
@@ -33,18 +31,3 @@ pub async fn build_user_repository(pool: &DatabasePool) -> FactoryResult<Arc<dyn
)),
}
}
pub async fn build_session_store(
pool: &DatabasePool,
) -> FactoryResult<crate::session_store::InfraSessionStore> {
Ok(match pool {
#[cfg(feature = "sqlite")]
DatabasePool::Sqlite(p) => {
InfraSessionStore::Sqlite(tower_sessions_sqlx_store::SqliteStore::new(p.clone()))
}
#[cfg(feature = "postgres")]
DatabasePool::Postgres(p) => {
InfraSessionStore::Postgres(tower_sessions_sqlx_store::PostgresStore::new(p.clone()))
}
})
}

View File

@@ -5,9 +5,7 @@
//!
//! ## Adapters
//!
//! - [`SqliteNoteRepository`] - SQLite adapter for notes with FTS5 search
//! - [`SqliteUserRepository`] - SQLite adapter for users (OIDC-ready)
//! - [`SqliteTagRepository`] - SQLite adapter for tags
//!
//! ## Database
//!
@@ -17,7 +15,6 @@
pub mod auth;
pub mod db;
pub mod factory;
pub mod session_store;
mod user_repository;
// Re-export for convenience

View File

@@ -1,2 +0,0 @@
pub use k_core::session::store::InfraSessionStore;
pub use tower_sessions::{Expiry, SessionManagerLayer};

View File

@@ -1,27 +1,13 @@
//! SQLite implementation of UserRepository
//! SQLite and PostgreSQL implementations of UserRepository
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::{FromRow, SqlitePool};
use sqlx::FromRow;
use uuid::Uuid;
use domain::{DomainError, DomainResult, Email, User, UserRepository};
/// SQLite adapter for UserRepository
#[cfg(feature = "sqlite")]
#[derive(Clone)]
pub struct SqliteUserRepository {
pool: SqlitePool,
}
#[cfg(feature = "sqlite")]
impl SqliteUserRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
/// Row type for SQLite query results
/// Row type for database query results (shared between SQLite and PostgreSQL)
#[derive(Debug, FromRow)]
struct UserRow {
id: String,
@@ -46,7 +32,6 @@ impl TryFrom<UserRow> for User {
})
.map_err(|e| DomainError::RepositoryError(format!("Invalid datetime: {}", e)))?;
// Parse email from string - it was validated when originally stored
let email = Email::try_from(row.email)
.map_err(|e| DomainError::RepositoryError(format!("Invalid email in DB: {}", e)))?;
@@ -60,6 +45,20 @@ impl TryFrom<UserRow> for User {
}
}
/// SQLite adapter for UserRepository
#[cfg(feature = "sqlite")]
#[derive(Clone)]
pub struct SqliteUserRepository {
pool: sqlx::SqlitePool,
}
#[cfg(feature = "sqlite")]
impl SqliteUserRepository {
pub fn new(pool: sqlx::SqlitePool) -> Self {
Self { pool }
}
}
#[cfg(feature = "sqlite")]
#[async_trait]
impl UserRepository for SqliteUserRepository {
@@ -116,12 +115,20 @@ impl UserRepository for SqliteUserRepository {
)
.bind(&id)
.bind(&user.subject)
.bind(user.email.as_ref()) // Use .as_ref() to get the inner &str
.bind(user.email.as_ref())
.bind(&user.password_hash)
.bind(&created_at)
.execute(&self.pool)
.await
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
.map_err(|e| {
// Surface UNIQUE constraint violations as domain-level conflicts
let msg = e.to_string();
if msg.contains("UNIQUE constraint failed") || msg.contains("unique constraint") {
DomainError::UserAlreadyExists(user.email.as_ref().to_string())
} else {
DomainError::RepositoryError(msg)
}
})?;
Ok(())
}
@@ -144,7 +151,7 @@ mod tests {
use crate::db::run_migrations;
use k_core::db::{DatabaseConfig, DatabasePool, connect};
async fn setup_test_db() -> SqlitePool {
async fn setup_test_db() -> sqlx::SqlitePool {
let config = DatabaseConfig::default();
let db_pool = connect(&config).await.expect("Failed to create pool");
@@ -168,7 +175,7 @@ mod tests {
assert!(found.is_some());
let found = found.unwrap();
assert_eq!(found.subject, "oidc|123");
assert_eq!(found.email_str(), "test@example.com");
assert_eq!(found.email.as_ref(), "test@example.com");
assert!(found.password_hash.is_none());
}
@@ -184,7 +191,7 @@ mod tests {
let found = repo.find_by_id(user.id).await.unwrap();
assert!(found.is_some());
let found = found.unwrap();
assert_eq!(found.email_str(), "local@example.com");
assert_eq!(found.email.as_ref(), "local@example.com");
assert_eq!(found.password_hash, Some("hashed_pw".to_string()));
}
@@ -292,7 +299,14 @@ impl UserRepository for PostgresUserRepository {
.bind(&created_at)
.execute(&self.pool)
.await
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
.map_err(|e| {
let msg = e.to_string();
if msg.contains("unique constraint") || msg.contains("duplicate key") {
DomainError::UserAlreadyExists(user.email.as_ref().to_string())
} else {
DomainError::RepositoryError(msg)
}
})?;
Ok(())
}