Compare commits

...

7 Commits

92 changed files with 1783 additions and 4390 deletions

1781
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,39 @@
[workspace]
members = ["domain", "infra", "api"]
members = [
"crates/domain",
"crates/application",
"crates/api-types",
"crates/adapters/sqlite",
"crates/adapters/postgres",
"crates/adapters/auth",
"crates/presentation",
"crates/bootstrap",
"crates/worker",
]
resolver = "2"
[workspace.dependencies]
tokio = { version = "1.0", features = ["macros", "rt-multi-thread", "net", "time", "sync"] }
async-trait = "0.1"
futures = "0.3"
anyhow = "1.0"
thiserror = "2.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "1.0", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
dotenvy = "0.15"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
axum = { version = "0.8", features = ["macros"] }
tower-http = { version = "0.6", features = ["cors", "trace"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "uuid", "chrono", "macros"] }
jsonwebtoken = "9.3"
bcrypt = "0.15"
utoipa = { version = "5.3", features = ["axum_extras", "uuid", "chrono"] }
utoipa-scalar = { version = "0.3", features = ["axum"] }
domain = { path = "crates/domain" }
application = { path = "crates/application" }
api-types = { path = "crates/api-types" }
adapters-auth = { path = "crates/adapters/auth" }
presentation = { path = "crates/presentation" }

39
Cargo.toml.liquid Normal file
View File

@@ -0,0 +1,39 @@
[workspace]
members = [
"crates/domain",
"crates/application",
"crates/api-types",
{% if database == "sqlite" %}"crates/adapters/sqlite",{% endif %}
{% if database == "postgres" %}"crates/adapters/postgres",{% endif %}
"crates/adapters/auth",
"crates/presentation",
"crates/bootstrap",
{% if worker %}"crates/worker",{% endif %}
]
resolver = "2"
[workspace.dependencies]
tokio = { version = "1.0", features = ["macros", "rt-multi-thread", "net", "time", "sync"] }
async-trait = "0.1"
futures = "0.3"
anyhow = "1.0"
thiserror = "2.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "1.0", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
dotenvy = "0.15"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
axum = { version = "0.8", features = ["macros"] }
tower-http = { version = "0.6", features = ["cors", "trace"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "uuid", "chrono", "macros"] }
jsonwebtoken = "9.3"
bcrypt = "0.15"
utoipa = { version = "5.3", features = ["axum_extras", "uuid", "chrono"] }
utoipa-scalar = { version = "5.0", features = ["axum"] }
domain = { path = "crates/domain" }
application = { path = "crates/application" }
api-types = { path = "crates/api-types" }
adapters-auth = { path = "crates/adapters/auth" }
presentation = { path = "crates/presentation" }

View File

@@ -1,49 +0,0 @@
[package]
name = "api"
version = "0.1.0"
edition = "2024"
default-run = "api"
[features]
default = ["sqlite", "auth-jwt"]
sqlite = ["infra/sqlite"]
postgres = ["infra/postgres"]
auth-oidc = ["infra/auth-oidc"]
auth-jwt = ["infra/auth-jwt"]
[dependencies]
k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [
"logging",
"db-sqlx",
"sqlite",
"http",
] }
domain = { path = "../domain" }
infra = { path = "../infra", default-features = false, features = ["sqlite"] }
# Web framework
axum = { version = "0.8.8", features = ["macros"] }
axum-extra = { version = "0.10", features = ["cookie-private", "cookie-key-expansion"] }
tower = "0.5.2"
tower-http = { version = "0.6.2", features = ["cors", "trace"] }
# Async runtime
tokio = { version = "1.48.0", features = ["full"] }
# Serialization
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0"
# Error handling
thiserror = "2.0.17"
anyhow = "1.0"
# Utilities
chrono = { version = "0.4.42", features = ["serde"] }
uuid = { version = "1.19.0", features = ["v4", "serde"] }
# Logging
tracing = "0.1"
dotenvy = "0.15.7"
time = "0.3"

View File

@@ -1,49 +0,0 @@
[package]
name = "{{project_name}}"
version = "0.1.0"
edition = "2024"
default-run = "{{project_name}}"
[features]
default = ["{{database}}"{% if auth_oidc %}, "auth-oidc"{% endif %}{% if auth_jwt %}, "auth-jwt"{% endif %}]
sqlite = ["infra/sqlite"]
postgres = ["infra/postgres"]
auth-oidc = ["infra/auth-oidc"]
auth-jwt = ["infra/auth-jwt"]
[dependencies]
k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [
"logging",
"db-sqlx",
"{{database}}",
"http",
] }
domain = { path = "../domain" }
infra = { path = "../infra", default-features = false, features = ["{{database}}"] }
# Web framework
axum = { version = "0.8.8", features = ["macros"] }
axum-extra = { version = "0.10", features = ["cookie-private", "cookie-key-expansion"] }
tower = "0.5.2"
tower-http = { version = "0.6.2", features = ["cors", "trace"] }
# Async runtime
tokio = { version = "1.48.0", features = ["full"] }
# Serialization
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0"
# Error handling
thiserror = "2.0.17"
anyhow = "1.0"
# Utilities
chrono = { version = "0.4.42", features = ["serde"] }
uuid = { version = "1.19.0", features = ["v4", "serde"] }
# Logging
tracing = "0.1"
dotenvy = "0.15.7"
time = "0.3"

View File

@@ -1,119 +0,0 @@
//! Application Configuration
//!
//! Loads configuration from environment variables.
use std::env;
/// Application configuration loaded from environment variables
#[derive(Debug, Clone)]
pub struct Config {
pub database_url: String,
pub cookie_secret: String,
pub cors_allowed_origins: Vec<String>,
pub port: u16,
pub host: String,
pub secure_cookie: bool,
pub db_max_connections: u32,
pub db_min_connections: u32,
// OIDC configuration
pub oidc_issuer: Option<String>,
pub oidc_client_id: Option<String>,
pub oidc_client_secret: Option<String>,
pub oidc_redirect_url: Option<String>,
pub oidc_resource_id: Option<String>,
// JWT configuration
pub jwt_secret: Option<String>,
pub jwt_issuer: Option<String>,
pub jwt_audience: Option<String>,
pub jwt_expiry_hours: u64,
/// Whether the application is running in production mode
pub is_production: bool,
}
impl Config {
pub fn from_env() -> Self {
let _ = dotenvy::dotenv();
let host = env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port = env::var("PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(3000);
let database_url =
env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:data.db?mode=rwc".to_string());
// Cookie secret for PrivateCookieJar (OIDC state encryption).
// Must be at least 64 bytes in production.
let cookie_secret = env::var("COOKIE_SECRET").unwrap_or_else(|_| {
"k-template-cookie-secret-key-must-be-at-least-64-bytes-long!!".to_string()
});
let cors_origins_str = env::var("CORS_ALLOWED_ORIGINS")
.unwrap_or_else(|_| "http://localhost:5173".to_string());
let cors_allowed_origins = cors_origins_str
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let secure_cookie = env::var("SECURE_COOKIE")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(false);
let db_max_connections = env::var("DB_MAX_CONNECTIONS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(5);
let db_min_connections = env::var("DB_MIN_CONNECTIONS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(1);
let oidc_issuer = env::var("OIDC_ISSUER").ok();
let oidc_client_id = env::var("OIDC_CLIENT_ID").ok();
let oidc_client_secret = env::var("OIDC_CLIENT_SECRET").ok();
let oidc_redirect_url = env::var("OIDC_REDIRECT_URL").ok();
let oidc_resource_id = env::var("OIDC_RESOURCE_ID").ok();
let jwt_secret = env::var("JWT_SECRET").ok();
let jwt_issuer = env::var("JWT_ISSUER").ok();
let jwt_audience = env::var("JWT_AUDIENCE").ok();
let jwt_expiry_hours = env::var("JWT_EXPIRY_HOURS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(24);
let is_production = env::var("PRODUCTION")
.or_else(|_| env::var("RUST_ENV"))
.map(|v| v.to_lowercase() == "production" || v == "1" || v == "true")
.unwrap_or(false);
Self {
host,
port,
database_url,
cookie_secret,
cors_allowed_origins,
secure_cookie,
db_max_connections,
db_min_connections,
oidc_issuer,
oidc_client_id,
oidc_client_secret,
oidc_redirect_url,
oidc_resource_id,
jwt_secret,
jwt_issuer,
jwt_audience,
jwt_expiry_hours,
is_production,
}
}
}

View File

@@ -1,49 +0,0 @@
//! Request and Response DTOs
//!
//! Data Transfer Objects for the API.
//! Uses domain newtypes for validation instead of the validator crate.
use chrono::{DateTime, Utc};
use domain::{Email, Password};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Login request with validated email and password newtypes
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
/// Email is validated on deserialization
pub email: Email,
/// Password is validated on deserialization (min 8 chars)
pub password: Password,
}
/// Register request with validated email and password newtypes
#[derive(Debug, Deserialize)]
pub struct RegisterRequest {
/// Email is validated on deserialization
pub email: Email,
/// Password is validated on deserialization (min 8 chars)
pub password: Password,
}
/// User response DTO
#[derive(Debug, Serialize)]
pub struct UserResponse {
pub id: Uuid,
pub email: String,
pub created_at: DateTime<Utc>,
}
/// JWT token response
#[derive(Debug, Serialize)]
pub struct TokenResponse {
pub access_token: String,
pub token_type: String,
pub expires_in: u64,
}
/// System configuration response
#[derive(Debug, Serialize)]
pub struct ConfigResponse {
pub allow_registration: bool,
}

View File

@@ -1,126 +0,0 @@
//! API error handling
//!
//! Maps domain errors to HTTP responses
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Serialize;
use thiserror::Error;
use domain::DomainError;
/// API-level errors
#[derive(Debug, Error)]
pub enum ApiError {
#[error("{0}")]
Domain(#[from] DomainError),
#[error("Validation error: {0}")]
Validation(String),
#[error("Internal server error")]
Internal(String),
#[error("Forbidden: {0}")]
Forbidden(String),
#[error("Unauthorized: {0}")]
Unauthorized(String),
}
/// Error response body
#[derive(Debug, Serialize)]
pub struct ErrorResponse {
pub error: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, error_response) = match &self {
ApiError::Domain(domain_error) => {
let status = match domain_error {
DomainError::UserNotFound(_) => StatusCode::NOT_FOUND,
DomainError::UserAlreadyExists(_) => StatusCode::CONFLICT,
DomainError::ValidationError(_) => StatusCode::BAD_REQUEST,
// Unauthenticated = not logged in → 401
DomainError::Unauthenticated(_) => StatusCode::UNAUTHORIZED,
// Forbidden = not allowed to perform action → 403
DomainError::Forbidden(_) => StatusCode::FORBIDDEN,
DomainError::RepositoryError(_) | DomainError::InfrastructureError(_) => {
StatusCode::INTERNAL_SERVER_ERROR
}
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
(
status,
ErrorResponse {
error: domain_error.to_string(),
details: None,
},
)
}
ApiError::Validation(msg) => (
StatusCode::BAD_REQUEST,
ErrorResponse {
error: "Validation error".to_string(),
details: Some(msg.clone()),
},
),
ApiError::Internal(msg) => {
tracing::error!("Internal error: {}", msg);
(
StatusCode::INTERNAL_SERVER_ERROR,
ErrorResponse {
error: "Internal server error".to_string(),
details: None,
},
)
}
ApiError::Forbidden(msg) => (
StatusCode::FORBIDDEN,
ErrorResponse {
error: "Forbidden".to_string(),
details: Some(msg.clone()),
},
),
ApiError::Unauthorized(msg) => (
StatusCode::UNAUTHORIZED,
ErrorResponse {
error: "Unauthorized".to_string(),
details: Some(msg.clone()),
},
),
};
(status, Json(error_response)).into_response()
}
}
impl ApiError {
pub fn validation(msg: impl Into<String>) -> Self {
Self::Validation(msg.into())
}
pub fn internal(msg: impl Into<String>) -> Self {
Self::Internal(msg.into())
}
}
/// Result type alias for API handlers
pub type ApiResult<T> = Result<T, ApiError>;

View File

@@ -1,89 +0,0 @@
//! Auth extractors for API handlers
//!
//! Provides the `CurrentUser` extractor that validates JWT Bearer tokens.
use axum::{extract::FromRequestParts, http::request::Parts};
use domain::User;
use crate::error::ApiError;
use crate::state::AppState;
/// Extracted current user from the request.
///
/// Validates a JWT Bearer token from the `Authorization` header.
pub struct CurrentUser(pub User);
impl FromRequestParts<AppState> for CurrentUser {
type Rejection = ApiError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
#[cfg(feature = "auth-jwt")]
{
return match try_jwt_auth(parts, state).await {
Ok(user) => Ok(CurrentUser(user)),
Err(e) => Err(e),
};
}
#[cfg(not(feature = "auth-jwt"))]
{
let _ = (parts, state);
Err(ApiError::Unauthorized(
"No authentication backend configured".to_string(),
))
}
}
}
/// Authenticate using JWT Bearer token
#[cfg(feature = "auth-jwt")]
async fn try_jwt_auth(parts: &mut Parts, state: &AppState) -> Result<User, ApiError> {
use axum::http::header::AUTHORIZATION;
let auth_header = parts
.headers
.get(AUTHORIZATION)
.ok_or_else(|| ApiError::Unauthorized("Missing Authorization header".to_string()))?;
let auth_str = auth_header
.to_str()
.map_err(|_| ApiError::Unauthorized("Invalid Authorization header encoding".to_string()))?;
let token = auth_str.strip_prefix("Bearer ").ok_or_else(|| {
ApiError::Unauthorized("Authorization header must use Bearer scheme".to_string())
})?;
let validator = state
.jwt_validator
.as_ref()
.ok_or_else(|| ApiError::Internal("JWT validator not configured".to_string()))?;
let claims = validator.validate_token(token).map_err(|e| {
tracing::debug!("JWT validation failed: {:?}", e);
match e {
infra::auth::jwt::JwtError::Expired => {
ApiError::Unauthorized("Token expired".to_string())
}
infra::auth::jwt::JwtError::InvalidFormat => {
ApiError::Unauthorized("Invalid token format".to_string())
}
_ => ApiError::Unauthorized("Token validation failed".to_string()),
}
})?;
let user_id: uuid::Uuid = claims
.sub
.parse()
.map_err(|_| ApiError::Unauthorized("Invalid user ID in token".to_string()))?;
let user = state
.user_service
.find_by_id(user_id)
.await
.map_err(|e| ApiError::Internal(format!("Failed to fetch user: {}", e)))?;
Ok(user)
}

View File

@@ -1,95 +0,0 @@
//! API Server Entry Point
//!
//! Configures and starts the HTTP server with JWT-based authentication.
use std::net::SocketAddr;
use std::time::Duration as StdDuration;
use axum::Router;
use domain::UserService;
use infra::factory::build_user_repository;
use infra::run_migrations;
use k_core::http::server::{ServerConfig, apply_standard_middleware};
use k_core::logging;
use tokio::net::TcpListener;
use tracing::info;
mod config;
mod dto;
mod error;
mod extractors;
mod routes;
mod state;
use crate::config::Config;
use crate::state::AppState;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
logging::init("api");
let config = Config::from_env();
info!("Starting server on {}:{}", config.host, config.port);
// Setup database
tracing::info!("Connecting to database: {}", config.database_url);
#[cfg(all(feature = "sqlite", not(feature = "postgres")))]
let db_type = k_core::db::DbType::Sqlite;
#[cfg(all(feature = "postgres", not(feature = "sqlite")))]
let db_type = k_core::db::DbType::Postgres;
// Both features enabled: fall back to URL inspection at runtime
#[cfg(all(feature = "sqlite", feature = "postgres"))]
let db_type = if config.database_url.starts_with("postgres") {
k_core::db::DbType::Postgres
} else {
k_core::db::DbType::Sqlite
};
let db_config = k_core::db::DatabaseConfig {
db_type,
url: config.database_url.clone(),
max_connections: config.db_max_connections,
min_connections: config.db_min_connections,
acquire_timeout: StdDuration::from_secs(30),
};
let db_pool = k_core::db::connect(&db_config).await?;
run_migrations(&db_pool).await?;
let user_repo = build_user_repository(&db_pool).await?;
let user_service = UserService::new(user_repo);
let state = AppState::new(user_service, config.clone()).await?;
let server_config = ServerConfig {
cors_origins: config.cors_allowed_origins.clone(),
};
let app = Router::new()
.nest("/api/v1", routes::api_v1_router())
.with_state(state);
let app = apply_standard_middleware(app, &server_config);
let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
let listener = TcpListener::bind(addr).await?;
tracing::info!("🚀 API server running at http://{}", addr);
tracing::info!("🔒 Authentication mode: JWT (Bearer token)");
#[cfg(feature = "auth-jwt")]
tracing::info!(" ✓ JWT auth enabled");
#[cfg(feature = "auth-oidc")]
tracing::info!(" ✓ OIDC integration enabled (stateless cookie state)");
tracing::info!("📝 API endpoints available at /api/v1/...");
axum::serve(listener, app).await?;
Ok(())
}

View File

@@ -1,253 +0,0 @@
//! Authentication routes
//!
//! Provides login, register, logout, token, and OIDC endpoints.
//! All authentication is JWT-based. OIDC state is stored in an encrypted cookie.
use axum::{
Router,
extract::{Json, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
};
use crate::{
dto::{LoginRequest, RegisterRequest, TokenResponse, UserResponse},
error::ApiError,
extractors::CurrentUser,
state::AppState,
};
pub fn router() -> Router<AppState> {
let r = Router::new()
.route("/login", post(login))
.route("/register", post(register))
.route("/logout", post(logout))
.route("/me", get(me));
#[cfg(feature = "auth-jwt")]
let r = r.route("/token", post(get_token));
#[cfg(feature = "auth-oidc")]
let r = r
.route("/login/oidc", get(oidc_login))
.route("/callback", get(oidc_callback));
r
}
/// Login with email + password → JWT token
async fn login(
State(state): State<AppState>,
Json(payload): Json<LoginRequest>,
) -> Result<impl IntoResponse, ApiError> {
let user = state
.user_service
.find_by_email(payload.email.as_ref())
.await?
.ok_or_else(|| ApiError::Unauthorized("Invalid credentials".to_string()))?;
let hash = user
.password_hash
.as_deref()
.ok_or_else(|| ApiError::Unauthorized("Invalid credentials".to_string()))?;
if !infra::auth::verify_password(payload.password.as_ref(), hash) {
return Err(ApiError::Unauthorized("Invalid credentials".to_string()));
}
let token = create_jwt(&user, &state)?;
Ok((
StatusCode::OK,
Json(TokenResponse {
access_token: token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_hours * 3600,
}),
))
}
/// Register a new local user → JWT token
async fn register(
State(state): State<AppState>,
Json(payload): Json<RegisterRequest>,
) -> Result<impl IntoResponse, ApiError> {
let password_hash = infra::auth::hash_password(payload.password.as_ref());
let user = state
.user_service
.create_local(payload.email.as_ref(), &password_hash)
.await?;
let token = create_jwt(&user, &state)?;
Ok((
StatusCode::CREATED,
Json(TokenResponse {
access_token: token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_hours * 3600,
}),
))
}
/// Logout — JWT is stateless; instruct the client to drop the token
async fn logout() -> impl IntoResponse {
StatusCode::OK
}
/// Get current user info from JWT
async fn me(CurrentUser(user): CurrentUser) -> Result<impl IntoResponse, ApiError> {
Ok(Json(UserResponse {
id: user.id,
email: user.email.into_inner(),
created_at: user.created_at,
}))
}
/// Issue a new JWT for the currently authenticated user (OIDC→JWT exchange or token refresh)
#[cfg(feature = "auth-jwt")]
async fn get_token(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
) -> Result<impl IntoResponse, ApiError> {
let token = create_jwt(&user, &state)?;
Ok(Json(TokenResponse {
access_token: token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_hours * 3600,
}))
}
/// Helper: create JWT for a user
#[cfg(feature = "auth-jwt")]
fn create_jwt(user: &domain::User, state: &AppState) -> Result<String, ApiError> {
let validator = state
.jwt_validator
.as_ref()
.ok_or_else(|| ApiError::Internal("JWT not configured".to_string()))?;
validator
.create_token(user)
.map_err(|e| ApiError::Internal(format!("Failed to create token: {}", e)))
}
#[cfg(not(feature = "auth-jwt"))]
fn create_jwt(_user: &domain::User, _state: &AppState) -> Result<String, ApiError> {
Err(ApiError::Internal("JWT feature not enabled".to_string()))
}
// ============================================================================
// OIDC Routes
// ============================================================================
#[cfg(feature = "auth-oidc")]
#[derive(serde::Deserialize)]
struct CallbackParams {
code: String,
state: String,
}
/// Start OIDC login: generate authorization URL and store state in encrypted cookie
#[cfg(feature = "auth-oidc")]
async fn oidc_login(
State(state): State<AppState>,
jar: axum_extra::extract::PrivateCookieJar,
) -> Result<impl IntoResponse, ApiError> {
use axum::http::header;
use axum::response::Response;
use axum_extra::extract::cookie::{Cookie, SameSite};
let service = state
.oidc_service
.as_ref()
.ok_or(ApiError::Internal("OIDC not configured".into()))?;
let (auth_data, oidc_state) = service.get_authorization_url();
let state_json = serde_json::to_string(&oidc_state)
.map_err(|e| ApiError::Internal(format!("Failed to serialize OIDC state: {}", e)))?;
let cookie = Cookie::build(("oidc_state", state_json))
.max_age(time::Duration::minutes(5))
.http_only(true)
.same_site(SameSite::Lax)
.secure(state.config.secure_cookie)
.path("/")
.build();
let updated_jar = jar.add(cookie);
let redirect = axum::response::Redirect::to(auth_data.url.as_str()).into_response();
let (mut parts, body) = redirect.into_parts();
parts.headers.insert(
header::CACHE_CONTROL,
"no-cache, no-store, must-revalidate".parse().unwrap(),
);
parts
.headers
.insert(header::PRAGMA, "no-cache".parse().unwrap());
parts.headers.insert(header::EXPIRES, "0".parse().unwrap());
Ok((updated_jar, Response::from_parts(parts, body)))
}
/// Handle OIDC callback: verify state cookie, complete exchange, issue JWT, clear cookie
#[cfg(feature = "auth-oidc")]
async fn oidc_callback(
State(state): State<AppState>,
jar: axum_extra::extract::PrivateCookieJar,
axum::extract::Query(params): axum::extract::Query<CallbackParams>,
) -> Result<impl IntoResponse, ApiError> {
use infra::auth::oidc::OidcState;
let service = state
.oidc_service
.as_ref()
.ok_or(ApiError::Internal("OIDC not configured".into()))?;
// Read and decrypt OIDC state from cookie
let cookie = jar
.get("oidc_state")
.ok_or(ApiError::Validation("Missing OIDC state cookie".into()))?;
let oidc_state: OidcState = serde_json::from_str(cookie.value())
.map_err(|_| ApiError::Validation("Invalid OIDC state cookie".into()))?;
// Verify CSRF token
if params.state != oidc_state.csrf_token.as_ref() {
return Err(ApiError::Validation("Invalid CSRF token".into()));
}
// Complete OIDC exchange
let oidc_user = service
.resolve_callback(
domain::AuthorizationCode::new(params.code),
oidc_state.nonce,
oidc_state.pkce_verifier,
)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let user = state
.user_service
.find_or_create(&oidc_user.subject, &oidc_user.email)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
// Clear the OIDC state cookie
let cleared_jar = jar.remove(axum_extra::extract::cookie::Cookie::from("oidc_state"));
let token = create_jwt(&user, &state)?;
Ok((
cleared_jar,
Json(TokenResponse {
access_token: token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_hours * 3600,
}),
))
}

View File

@@ -1,13 +0,0 @@
use axum::{Json, Router, routing::get};
use crate::dto::ConfigResponse;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new().route("/", get(get_config))
}
async fn get_config() -> Json<ConfigResponse> {
Json(ConfigResponse {
allow_registration: true, // Default to true for template
})
}

View File

@@ -1,16 +0,0 @@
//! API Routes
//!
//! Defines the API endpoints and maps them to handler functions.
use crate::state::AppState;
use axum::Router;
pub mod auth;
pub mod config;
/// Construct the API v1 router
pub fn api_v1_router() -> Router<AppState> {
Router::new()
.nest("/auth", auth::router())
.nest("/config", config::router())
}

View File

@@ -1,116 +0,0 @@
//! Application State
//!
//! Holds shared state for the application.
use axum::extract::FromRef;
use axum_extra::extract::cookie::Key;
#[cfg(feature = "auth-jwt")]
use infra::auth::jwt::{JwtConfig, JwtValidator};
#[cfg(feature = "auth-oidc")]
use infra::auth::oidc::OidcService;
use std::sync::Arc;
use crate::config::Config;
use domain::UserService;
#[derive(Clone)]
pub struct AppState {
pub user_service: Arc<UserService>,
pub cookie_key: Key,
#[cfg(feature = "auth-oidc")]
pub oidc_service: Option<Arc<OidcService>>,
#[cfg(feature = "auth-jwt")]
pub jwt_validator: Option<Arc<JwtValidator>>,
pub config: Arc<Config>,
}
impl AppState {
pub async fn new(user_service: UserService, config: Config) -> anyhow::Result<Self> {
let cookie_key = Key::derive_from(config.cookie_secret.as_bytes());
#[cfg(feature = "auth-oidc")]
let oidc_service = if let (Some(issuer), Some(id), secret, Some(redirect), resource_id) = (
&config.oidc_issuer,
&config.oidc_client_id,
&config.oidc_client_secret,
&config.oidc_redirect_url,
&config.oidc_resource_id,
) {
tracing::info!("Initializing OIDC service with issuer: {}", issuer);
let issuer_url = domain::IssuerUrl::new(issuer)
.map_err(|e| anyhow::anyhow!("Invalid OIDC issuer URL: {}", e))?;
let client_id = domain::ClientId::new(id)
.map_err(|e| anyhow::anyhow!("Invalid OIDC client ID: {}", e))?;
let client_secret = secret.as_ref().map(|s| domain::ClientSecret::new(s));
let redirect_url = domain::RedirectUrl::new(redirect)
.map_err(|e| anyhow::anyhow!("Invalid OIDC redirect URL: {}", e))?;
let resource = resource_id
.as_ref()
.map(|r| domain::ResourceId::new(r))
.transpose()
.map_err(|e| anyhow::anyhow!("Invalid OIDC resource ID: {}", e))?;
Some(Arc::new(
OidcService::new(issuer_url, client_id, client_secret, redirect_url, resource)
.await?,
))
} else {
None
};
#[cfg(feature = "auth-jwt")]
let jwt_validator = {
let secret = match &config.jwt_secret {
Some(s) if !s.is_empty() => s.clone(),
_ => {
if config.is_production {
anyhow::bail!("JWT_SECRET is required in production");
}
tracing::warn!(
"⚠️ JWT_SECRET not set — using insecure development secret. DO NOT USE IN PRODUCTION!"
);
"k-template-dev-secret-not-for-production-use-only".to_string()
}
};
tracing::info!("Initializing JWT validator");
let jwt_config = JwtConfig::new(
secret,
config.jwt_issuer.clone(),
config.jwt_audience.clone(),
Some(config.jwt_expiry_hours),
config.is_production,
)?;
Some(Arc::new(JwtValidator::new(jwt_config)))
};
Ok(Self {
user_service: Arc::new(user_service),
cookie_key,
#[cfg(feature = "auth-oidc")]
oidc_service,
#[cfg(feature = "auth-jwt")]
jwt_validator,
config: Arc::new(config),
})
}
}
impl FromRef<AppState> for Arc<UserService> {
fn from_ref(input: &AppState) -> Self {
input.user_service.clone()
}
}
impl FromRef<AppState> for Arc<Config> {
fn from_ref(input: &AppState) -> Self {
input.config.clone()
}
}
impl FromRef<AppState> for Key {
fn from_ref(input: &AppState) -> Self {
input.cookie_key.clone()
}
}

View File

@@ -0,0 +1,15 @@
[package]
name = "adapters-auth"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
anyhow = { workspace = true }
jsonwebtoken = { workspace = true }
bcrypt = { workspace = true }
serde = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
tokio = { workspace = true }

View File

@@ -0,0 +1,74 @@
use async_trait::async_trait;
use chrono::Utc;
use domain::{errors::DomainError, ports::TokenIssuer, value_objects::{Role, UserId}};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use std::str::FromStr;
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String,
pub role: String,
pub exp: i64,
}
pub struct JwtTokenIssuer {
encoding_key: EncodingKey,
decoding_key: DecodingKey,
expiry_hours: i64,
}
impl JwtTokenIssuer {
pub fn new(secret: &str) -> Self {
Self {
encoding_key: EncodingKey::from_secret(secret.as_bytes()),
decoding_key: DecodingKey::from_secret(secret.as_bytes()),
expiry_hours: 24,
}
}
}
#[async_trait]
impl TokenIssuer for JwtTokenIssuer {
async fn issue(&self, user_id: &UserId, role: &Role) -> Result<String, DomainError> {
let claims = Claims {
sub: user_id.to_string(),
role: role.to_string(),
exp: (Utc::now() + chrono::Duration::hours(self.expiry_hours)).timestamp(),
};
encode(&Header::default(), &claims, &self.encoding_key)
.map_err(|e| DomainError::Internal(e.to_string()))
}
async fn verify(&self, token: &str) -> Result<(UserId, Role), DomainError> {
let data = decode::<Claims>(token, &self.decoding_key, &Validation::default())
.map_err(|_| DomainError::Unauthorized("Invalid or expired token".to_string()))?;
let uuid = uuid::Uuid::parse_str(&data.claims.sub)
.map_err(|_| DomainError::Unauthorized("Invalid token subject".to_string()))?;
let role = Role::from_str(&data.claims.role)
.map_err(|_| DomainError::Unauthorized("Invalid role in token".to_string()))?;
Ok((UserId::from_uuid(uuid), role))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn issue_and_verify_roundtrip() {
let issuer = JwtTokenIssuer::new("test-secret-key-long-enough-32chars!!");
let user_id = UserId::new();
let token = issuer.issue(&user_id, &Role::User).await.unwrap();
let (verified_id, verified_role) = issuer.verify(&token).await.unwrap();
assert_eq!(verified_id, user_id);
assert_eq!(verified_role, Role::User);
}
#[tokio::test]
async fn rejects_invalid_token() {
let issuer = JwtTokenIssuer::new("test-secret-key-long-enough-32chars!!");
let result = issuer.verify("not.a.valid.jwt").await;
assert!(matches!(result, Err(DomainError::Unauthorized(_))));
}
}

View File

@@ -0,0 +1,7 @@
pub mod jwt;
pub mod oidc;
pub mod password;
pub use jwt::JwtTokenIssuer;
pub use oidc::OidcAdapter;
pub use password::BcryptPasswordHasher;

View File

@@ -0,0 +1,10 @@
// Stub: extend this when auth_oidc = true.
pub struct OidcAdapter;
impl OidcAdapter {
pub fn new() -> Self { Self }
}
impl Default for OidcAdapter {
fn default() -> Self { Self::new() }
}

View File

@@ -0,0 +1,38 @@
use async_trait::async_trait;
use domain::{errors::DomainError, ports::PasswordHasher, value_objects::PasswordHash};
pub struct BcryptPasswordHasher;
#[async_trait]
impl PasswordHasher for BcryptPasswordHasher {
async fn hash(&self, password: &str) -> Result<PasswordHash, DomainError> {
let password = password.to_owned();
let hash = tokio::task::spawn_blocking(move || bcrypt::hash(&password, 12))
.await
.map_err(|e| DomainError::Internal(e.to_string()))?
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(PasswordHash::from_hash(hash))
}
async fn verify(&self, password: &str, hash: &PasswordHash) -> Result<bool, DomainError> {
let password = password.to_owned();
let hash = hash.as_str().to_owned();
tokio::task::spawn_blocking(move || bcrypt::verify(&password, &hash))
.await
.map_err(|e| DomainError::Internal(e.to_string()))?
.map_err(|e| DomainError::Internal(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn hash_and_verify_roundtrip() {
let h = BcryptPasswordHasher;
let hash = h.hash("mysecretpassword").await.unwrap();
assert!(h.verify("mysecretpassword", &hash).await.unwrap());
assert!(!h.verify("wrongpassword", &hash).await.unwrap());
}
}

View File

@@ -0,0 +1,12 @@
[package]
name = "adapters-postgres"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
sqlx = { workspace = true, features = ["postgres"] }
uuid = { workspace = true }
chrono = { workspace = true }
anyhow = { workspace = true }
async-trait = { workspace = true }

View File

@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@@ -0,0 +1,14 @@
pub type PgPool = sqlx::PgPool;
pub async fn connect(url: &str) -> anyhow::Result<PgPool> {
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(10)
.connect(url)
.await?;
Ok(pool)
}
pub async fn run_migrations(pool: &PgPool) -> anyhow::Result<()> {
sqlx::migrate!("./migrations").run(pool).await?;
Ok(())
}

View File

@@ -0,0 +1,5 @@
pub mod db;
pub mod user_repository;
pub use db::{connect, run_migrations, PgPool};
pub use user_repository::PostgresUserRepository;

View File

@@ -0,0 +1,86 @@
use async_trait::async_trait;
use domain::{
entities::User,
errors::DomainError,
ports::UserRepository,
value_objects::{Email, PasswordHash, Role, UserId},
};
use std::str::FromStr;
use crate::db::PgPool;
pub struct PostgresUserRepository {
pool: PgPool,
}
impl PostgresUserRepository {
pub fn new(pool: PgPool) -> Self { Self { pool } }
}
#[async_trait]
impl UserRepository for PostgresUserRepository {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
let row = sqlx::query!(
"SELECT id, email, password_hash, role, created_at FROM users WHERE id = $1",
*id.as_uuid()
)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
row.map(|r| Ok(User {
id: UserId::from_uuid(r.id),
email: Email::new(r.email)?,
password_hash: PasswordHash::from_hash(r.password_hash),
role: Role::from_str(&r.role).map_err(DomainError::Internal)?,
created_at: r.created_at,
}))
.transpose()
}
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
let row = sqlx::query!(
"SELECT id, email, password_hash, role, created_at FROM users WHERE email = $1",
email.as_str()
)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
row.map(|r| Ok(User {
id: UserId::from_uuid(r.id),
email: Email::new(r.email)?,
password_hash: PasswordHash::from_hash(r.password_hash),
role: Role::from_str(&r.role).map_err(DomainError::Internal)?,
created_at: r.created_at,
}))
.transpose()
}
async fn save(&self, user: &User) -> Result<(), DomainError> {
sqlx::query!(
"INSERT INTO users (id, email, password_hash, role, created_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
role = EXCLUDED.role",
*user.id.as_uuid(),
user.email.as_str(),
user.password_hash.as_str(),
user.role.to_string(),
user.created_at
)
.execute(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(())
}
async fn delete(&self, id: &UserId) -> Result<(), DomainError> {
sqlx::query!("DELETE FROM users WHERE id = $1", *id.as_uuid())
.execute(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(())
}
}

View File

@@ -0,0 +1,44 @@
{
"db_name": "SQLite",
"query": "SELECT id, email, password_hash, role, created_at FROM users WHERE email = ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "email",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "password_hash",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "role",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 4,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "32a9f3382874860eb5382c5bb6ef08dbfb4ff01e052d88812ae65bb30600388c"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM users WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "73ffdf5be39aa5c4c160c2f77d6634a6970eeb4e1d3395f045ded747f0ce9d2a"
}

View File

@@ -0,0 +1,44 @@
{
"db_name": "SQLite",
"query": "SELECT id, email, password_hash, role, created_at FROM users WHERE id = ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "email",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "password_hash",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "role",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 4,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "99f8b694a94f0e01b788cc1c3a2e2ee54ba6e843139de462c8327b084abf151f"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO users (id, email, password_hash, role, created_at)\n VALUES (?, ?, ?, ?, ?)\n ON CONFLICT(id) DO UPDATE SET\n email = excluded.email,\n password_hash = excluded.password_hash,\n role = excluded.role",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "d20be9ee2cb28025aaa1fd644cda14d209c76269538686dd6e0818922c386dc1"
}

View File

@@ -0,0 +1,12 @@
[package]
name = "adapters-sqlite"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
sqlx = { workspace = true, features = ["sqlite"] }
uuid = { workspace = true }
chrono = { workspace = true }
anyhow = { workspace = true }
async-trait = { workspace = true }

View File

@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
created_at TEXT NOT NULL
);

View File

@@ -0,0 +1,14 @@
pub type SqlitePool = sqlx::SqlitePool;
pub async fn connect(url: &str) -> anyhow::Result<SqlitePool> {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(5)
.connect(url)
.await?;
Ok(pool)
}
pub async fn run_migrations(pool: &SqlitePool) -> anyhow::Result<()> {
sqlx::migrate!("./migrations").run(pool).await?;
Ok(())
}

View File

@@ -0,0 +1,5 @@
pub mod db;
pub mod user_repository;
pub use db::{connect, run_migrations, SqlitePool};
pub use user_repository::SqliteUserRepository;

View File

@@ -0,0 +1,95 @@
use async_trait::async_trait;
use domain::{
entities::User,
errors::DomainError,
ports::UserRepository,
value_objects::{Email, PasswordHash, Role, UserId},
};
use std::str::FromStr;
use crate::db::SqlitePool;
pub struct SqliteUserRepository {
pool: SqlitePool,
}
impl SqliteUserRepository {
pub fn new(pool: SqlitePool) -> Self { Self { pool } }
}
#[async_trait]
impl UserRepository for SqliteUserRepository {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
let id_str = id.to_string();
let row = sqlx::query!(
"SELECT id, email, password_hash, role, created_at FROM users WHERE id = ?",
id_str
)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
row.map(|r| row_to_user(r.id, r.email, r.password_hash, r.role, r.created_at))
.transpose()
}
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
let email_str = email.as_str().to_owned();
let row = sqlx::query!(
"SELECT id, email, password_hash, role, created_at FROM users WHERE email = ?",
email_str
)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
row.map(|r| row_to_user(r.id, r.email, r.password_hash, r.role, r.created_at))
.transpose()
}
async fn save(&self, user: &User) -> Result<(), DomainError> {
let id = user.id.to_string();
let email = user.email.as_str().to_owned();
let hash = user.password_hash.as_str().to_owned();
let role = user.role.to_string();
let created_at = user.created_at.to_rfc3339();
sqlx::query!(
"INSERT INTO users (id, email, password_hash, role, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
email = excluded.email,
password_hash = excluded.password_hash,
role = excluded.role",
id, email, hash, role, created_at
)
.execute(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(())
}
async fn delete(&self, id: &UserId) -> Result<(), DomainError> {
let id_str = id.to_string();
sqlx::query!("DELETE FROM users WHERE id = ?", id_str)
.execute(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(())
}
}
fn row_to_user(
id: String,
email: String,
password_hash: String,
role: String,
created_at: String,
) -> Result<User, DomainError> {
let uuid = uuid::Uuid::parse_str(&id).map_err(|e| DomainError::Internal(e.to_string()))?;
let email = Email::new(email)?;
let role = Role::from_str(&role).map_err(DomainError::Internal)?;
let created_at = chrono::DateTime::parse_from_rfc3339(&created_at)
.map_err(|e| DomainError::Internal(e.to_string()))?
.with_timezone(&chrono::Utc);
Ok(User { id: UserId::from_uuid(uuid), email, password_hash: PasswordHash::from_hash(password_hash), role, created_at })
}

View File

@@ -0,0 +1,11 @@
[package]
name = "api-types"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
serde = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
utoipa = { workspace = true }

View File

@@ -0,0 +1,2 @@
pub mod requests;
pub mod responses;

View File

@@ -0,0 +1,11 @@
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct RegisterRequest {
pub email: String,
pub password: String,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}

View File

@@ -0,0 +1,27 @@
use chrono::{DateTime, Utc};
use uuid::Uuid;
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct UserResponse {
pub id: Uuid,
pub email: String,
pub role: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct AuthResponse {
pub token: String,
pub user: UserResponse,
}
impl UserResponse {
pub fn from_domain(user: &domain::entities::User) -> Self {
Self {
id: *user.id.as_uuid(),
email: user.email.to_string(),
role: user.role.to_string(),
created_at: user.created_at,
}
}
}

View File

@@ -0,0 +1,12 @@
[package]
name = "application"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
uuid = { workspace = true }
tokio = { workspace = true }

View File

@@ -0,0 +1,2 @@
pub mod testing;
pub mod use_cases;

View File

@@ -0,0 +1,79 @@
use std::collections::HashMap;
use async_trait::async_trait;
use tokio::sync::Mutex;
use domain::{
entities::User,
errors::DomainError,
ports::{PasswordHasher, TokenIssuer, UserRepository},
value_objects::{Email, PasswordHash, Role, UserId},
};
pub struct InMemoryUserRepository {
users: Mutex<HashMap<String, User>>,
}
impl InMemoryUserRepository {
pub fn new() -> Self {
Self { users: Mutex::new(HashMap::new()) }
}
pub async fn all(&self) -> Vec<User> {
self.users.lock().await.values().cloned().collect()
}
}
impl Default for InMemoryUserRepository {
fn default() -> Self { Self::new() }
}
#[async_trait]
impl UserRepository for InMemoryUserRepository {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
Ok(self.users.lock().await.get(&id.to_string()).cloned())
}
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
Ok(self.users.lock().await.values()
.find(|u| u.email.as_str() == email.as_str())
.cloned())
}
async fn save(&self, user: &User) -> Result<(), DomainError> {
self.users.lock().await.insert(user.id.to_string(), user.clone());
Ok(())
}
async fn delete(&self, id: &UserId) -> Result<(), DomainError> {
self.users.lock().await.remove(&id.to_string());
Ok(())
}
}
pub struct StubPasswordHasher;
#[async_trait]
impl PasswordHasher for StubPasswordHasher {
async fn hash(&self, password: &str) -> Result<PasswordHash, DomainError> {
Ok(PasswordHash::from_hash(format!("hashed:{password}")))
}
async fn verify(&self, password: &str, hash: &PasswordHash) -> Result<bool, DomainError> {
Ok(hash.as_str() == format!("hashed:{password}"))
}
}
pub struct StubTokenIssuer;
#[async_trait]
impl TokenIssuer for StubTokenIssuer {
async fn issue(&self, user_id: &UserId, _role: &Role) -> Result<String, DomainError> {
Ok(format!("token:{user_id}"))
}
async fn verify(&self, token: &str) -> Result<(UserId, Role), DomainError> {
let id_str = token.strip_prefix("token:").ok_or_else(|| {
DomainError::Unauthorized("Invalid stub token".to_string())
})?;
let uuid = uuid::Uuid::parse_str(id_str)
.map_err(|_| DomainError::Unauthorized("Bad UUID in stub token".to_string()))?;
Ok((UserId::from_uuid(uuid), Role::User))
}
}

View File

@@ -0,0 +1,40 @@
use std::sync::Arc;
use domain::{entities::User, errors::DomainError, ports::UserRepository, value_objects::UserId};
pub struct GetProfile {
repo: Arc<dyn UserRepository>,
}
impl GetProfile {
pub fn new(repo: Arc<dyn UserRepository>) -> Self { Self { repo } }
pub async fn execute(&self, user_id: &UserId) -> Result<User, DomainError> {
self.repo.find_by_id(user_id).await?
.ok_or_else(|| DomainError::NotFound(format!("User {user_id} not found")))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::{InMemoryUserRepository, StubPasswordHasher};
use crate::use_cases::register::RegisterUser;
#[tokio::test]
async fn get_profile_returns_existing_user() {
let repo = Arc::new(InMemoryUserRepository::new());
let r = RegisterUser::new(repo.clone(), Arc::new(StubPasswordHasher));
let user = r.execute("user@example.com", "password123").await.unwrap();
let uc = GetProfile::new(repo);
let found = uc.execute(&user.id).await.unwrap();
assert_eq!(found.id, user.id);
}
#[tokio::test]
async fn get_profile_returns_not_found() {
let repo = Arc::new(InMemoryUserRepository::new());
let uc = GetProfile::new(repo);
let result = uc.execute(&UserId::new()).await;
assert!(matches!(result, Err(DomainError::NotFound(_))));
}
}

View File

@@ -0,0 +1,74 @@
use std::sync::Arc;
use domain::{
entities::User,
errors::DomainError,
ports::{PasswordHasher, TokenIssuer, UserRepository},
value_objects::Email,
};
pub struct LoginUser {
repo: Arc<dyn UserRepository>,
hasher: Arc<dyn PasswordHasher>,
issuer: Arc<dyn TokenIssuer>,
}
impl LoginUser {
pub fn new(
repo: Arc<dyn UserRepository>,
hasher: Arc<dyn PasswordHasher>,
issuer: Arc<dyn TokenIssuer>,
) -> Self {
Self { repo, hasher, issuer }
}
pub async fn execute(&self, email: &str, password: &str) -> Result<(User, String), DomainError> {
let email = Email::new(email)?;
let user = self.repo.find_by_email(&email).await?
.ok_or_else(|| DomainError::Unauthorized("Invalid credentials".to_string()))?;
let valid = self.hasher.verify(password, &user.password_hash).await?;
if !valid {
return Err(DomainError::Unauthorized("Invalid credentials".to_string()));
}
let token = self.issuer.issue(&user.id, &user.role).await?;
Ok((user, token))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::{InMemoryUserRepository, StubPasswordHasher, StubTokenIssuer};
use crate::use_cases::register::RegisterUser;
async fn seeded_repo() -> Arc<InMemoryUserRepository> {
let repo = Arc::new(InMemoryUserRepository::new());
let r = RegisterUser::new(repo.clone(), Arc::new(StubPasswordHasher));
r.execute("user@example.com", "password123").await.unwrap();
repo
}
#[tokio::test]
async fn login_returns_user_and_token() {
let repo = seeded_repo().await;
let uc = LoginUser::new(repo, Arc::new(StubPasswordHasher), Arc::new(StubTokenIssuer));
let (user, token) = uc.execute("user@example.com", "password123").await.unwrap();
assert_eq!(user.email.as_str(), "user@example.com");
assert!(token.starts_with("token:"));
}
#[tokio::test]
async fn login_rejects_wrong_password() {
let repo = seeded_repo().await;
let uc = LoginUser::new(repo, Arc::new(StubPasswordHasher), Arc::new(StubTokenIssuer));
let result = uc.execute("user@example.com", "wrongpassword").await;
assert!(matches!(result, Err(DomainError::Unauthorized(_))));
}
#[tokio::test]
async fn login_rejects_unknown_email() {
let repo = seeded_repo().await;
let uc = LoginUser::new(repo, Arc::new(StubPasswordHasher), Arc::new(StubTokenIssuer));
let result = uc.execute("nobody@example.com", "password123").await;
assert!(matches!(result, Err(DomainError::Unauthorized(_))));
}
}

View File

@@ -0,0 +1,7 @@
pub mod get_profile;
pub mod login;
pub mod register;
pub use get_profile::GetProfile;
pub use login::LoginUser;
pub use register::RegisterUser;

View File

@@ -0,0 +1,72 @@
use std::sync::Arc;
use domain::{
entities::User,
errors::DomainError,
ports::{PasswordHasher, UserRepository},
value_objects::{Email, UserId},
};
pub struct RegisterUser {
repo: Arc<dyn UserRepository>,
hasher: Arc<dyn PasswordHasher>,
}
impl RegisterUser {
pub fn new(repo: Arc<dyn UserRepository>, hasher: Arc<dyn PasswordHasher>) -> Self {
Self { repo, hasher }
}
pub async fn execute(&self, email: &str, password: &str) -> Result<User, DomainError> {
if password.len() < 8 {
return Err(DomainError::Validation("Password must be at least 8 characters".to_string()));
}
let email = Email::new(email)?;
if self.repo.find_by_email(&email).await?.is_some() {
return Err(DomainError::Conflict(format!("Email {} is already registered", email.as_str())));
}
let hash = self.hasher.hash(password).await?;
let user = User::new(UserId::new(), email, hash);
self.repo.save(&user).await?;
Ok(user)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::{InMemoryUserRepository, StubPasswordHasher};
#[tokio::test]
async fn register_creates_user() {
let repo = Arc::new(InMemoryUserRepository::new());
let uc = RegisterUser::new(repo.clone(), Arc::new(StubPasswordHasher));
let user = uc.execute("test@example.com", "password123").await.unwrap();
assert_eq!(user.email.as_str(), "test@example.com");
assert_eq!(repo.all().await.len(), 1);
}
#[tokio::test]
async fn register_rejects_duplicate_email() {
let repo = Arc::new(InMemoryUserRepository::new());
let uc = RegisterUser::new(repo.clone(), Arc::new(StubPasswordHasher));
uc.execute("test@example.com", "password123").await.unwrap();
let result = uc.execute("test@example.com", "different1").await;
assert!(matches!(result, Err(DomainError::Conflict(_))));
}
#[tokio::test]
async fn register_rejects_short_password() {
let repo = Arc::new(InMemoryUserRepository::new());
let uc = RegisterUser::new(repo, Arc::new(StubPasswordHasher));
let result = uc.execute("test@example.com", "short").await;
assert!(matches!(result, Err(DomainError::Validation(_))));
}
#[tokio::test]
async fn register_rejects_invalid_email() {
let repo = Arc::new(InMemoryUserRepository::new());
let uc = RegisterUser::new(repo, Arc::new(StubPasswordHasher));
let result = uc.execute("notanemail", "password123").await;
assert!(matches!(result, Err(DomainError::Validation(_))));
}
}

View File

@@ -0,0 +1,22 @@
[package]
name = "bootstrap"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "server"
path = "src/main.rs"
[dependencies]
domain = { workspace = true }
application = { workspace = true }
adapters-auth = { workspace = true }
presentation = { workspace = true }
adapters-sqlite = { path = "../adapters/sqlite" }
tokio = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
dotenvy = { workspace = true }
tower-http = { workspace = true }
axum = { workspace = true }

View File

@@ -0,0 +1,28 @@
#[derive(Debug, Clone)]
pub struct Config {
pub host: String,
pub port: u16,
pub database_url: String,
pub jwt_secret: String,
pub cors_allowed_origins: Vec<String>,
}
impl Config {
pub fn from_env() -> Self {
dotenvy::dotenv().ok();
Self {
host: std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()),
port: std::env::var("PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(3000),
database_url: std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"),
jwt_secret: std::env::var("JWT_SECRET").expect("JWT_SECRET must be set"),
cors_allowed_origins: std::env::var("CORS_ALLOWED_ORIGINS")
.unwrap_or_else(|_| "http://localhost:3000".to_string())
.split(',')
.map(|s| s.trim().to_string())
.collect(),
}
}
}

View File

@@ -0,0 +1,43 @@
// If you chose postgres at cargo generate time, replace adapters_sqlite with
// adapters_postgres throughout this file (connect, run_migrations, PostgresUserRepository).
use std::sync::Arc;
use anyhow::Result;
use axum::Router;
use axum::http::HeaderValue;
use tower_http::{cors::{Any, CorsLayer}, trace::TraceLayer};
use adapters_auth::{BcryptPasswordHasher, JwtTokenIssuer};
use adapters_sqlite::{connect, run_migrations, SqliteUserRepository};
use application::use_cases::{GetProfile, LoginUser, RegisterUser};
use presentation::{routes::app_router, state::AppState};
use crate::config::Config;
pub async fn build_app(config: &Config) -> Result<Router> {
let pool = connect(&config.database_url).await?;
run_migrations(&pool).await?;
let user_repo = Arc::new(SqliteUserRepository::new(pool));
let hasher = Arc::new(BcryptPasswordHasher);
let issuer = Arc::new(JwtTokenIssuer::new(&config.jwt_secret));
let register_uc = Arc::new(RegisterUser::new(user_repo.clone(), hasher.clone()));
let login_uc = Arc::new(LoginUser::new(user_repo.clone(), hasher, issuer.clone()));
let get_profile_uc = Arc::new(GetProfile::new(user_repo));
let state = AppState::new(register_uc, login_uc, get_profile_uc, issuer);
let cors = CorsLayer::new()
.allow_origin(
config.cors_allowed_origins.iter()
.filter_map(|o| o.parse::<HeaderValue>().ok())
.collect::<Vec<_>>(),
)
.allow_methods(Any)
.allow_headers(Any);
Ok(app_router()
.with_state(state)
.layer(TraceLayer::new_for_http())
.layer(cors))
}

View File

View File

@@ -0,0 +1,28 @@
use std::net::SocketAddr;
use tracing::info;
mod config;
mod factory;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive("bootstrap=info".parse()?)
.add_directive("tower_http=debug".parse()?),
)
.init();
let config = config::Config::from_env();
let app = factory::build_app(&config).await?;
let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
let listener = tokio::net::TcpListener::bind(addr).await?;
info!("🚀 Server running at http://{addr}");
info!("📖 Scalar docs at http://{addr}/scalar");
axum::serve(listener, app).await?;
Ok(())
}

11
crates/domain/Cargo.toml Normal file
View File

@@ -0,0 +1,11 @@
[package]
name = "domain"
version = "0.1.0"
edition = "2024"
[dependencies]
uuid = { workspace = true }
chrono = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
async-trait = { workspace = true }

View File

@@ -0,0 +1,2 @@
mod user;
pub use user::User;

View File

@@ -0,0 +1,17 @@
use chrono::{DateTime, Utc};
use crate::value_objects::{Email, PasswordHash, Role, UserId};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct User {
pub id: UserId,
pub email: Email,
pub password_hash: PasswordHash,
pub role: Role,
pub created_at: DateTime<Utc>,
}
impl User {
pub fn new(id: UserId, email: Email, password_hash: PasswordHash) -> Self {
Self { id, email, password_hash, role: Role::User, created_at: Utc::now() }
}
}

View File

@@ -0,0 +1,13 @@
#[derive(Debug, thiserror::Error)]
pub enum DomainError {
#[error("Not found: {0}")]
NotFound(String),
#[error("Conflict: {0}")]
Conflict(String),
#[error("Unauthorized: {0}")]
Unauthorized(String),
#[error("Validation error: {0}")]
Validation(String),
#[error("Internal error: {0}")]
Internal(String),
}

View File

@@ -0,0 +1,7 @@
use uuid::Uuid;
#[derive(Debug, Clone)]
pub enum DomainEvent {
UserRegistered { user_id: Uuid },
UserLoggedIn { user_id: Uuid },
}

5
crates/domain/src/lib.rs Normal file
View File

@@ -0,0 +1,5 @@
pub mod entities;
pub mod errors;
pub mod events;
pub mod ports;
pub mod value_objects;

View File

@@ -0,0 +1,14 @@
use async_trait::async_trait;
use crate::{errors::DomainError, value_objects::{PasswordHash, Role, UserId}};
#[async_trait]
pub trait PasswordHasher: Send + Sync {
async fn hash(&self, password: &str) -> Result<PasswordHash, DomainError>;
async fn verify(&self, password: &str, hash: &PasswordHash) -> Result<bool, DomainError>;
}
#[async_trait]
pub trait TokenIssuer: Send + Sync {
async fn issue(&self, user_id: &UserId, role: &Role) -> Result<String, DomainError>;
async fn verify(&self, token: &str) -> Result<(UserId, Role), DomainError>;
}

View File

@@ -0,0 +1,5 @@
mod auth;
mod user_repo;
pub use auth::{PasswordHasher, TokenIssuer};
pub use user_repo::UserRepository;

View File

@@ -0,0 +1,10 @@
use async_trait::async_trait;
use crate::{entities::User, errors::DomainError, value_objects::{Email, UserId}};
#[async_trait]
pub trait UserRepository: Send + Sync {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
async fn save(&self, user: &User) -> Result<(), DomainError>;
async fn delete(&self, id: &UserId) -> Result<(), DomainError>;
}

View File

@@ -0,0 +1,42 @@
use crate::errors::DomainError;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Email(String);
impl Email {
pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
let value = value.into().trim().to_lowercase();
if value.is_empty() || !value.contains('@') {
return Err(DomainError::Validation("Invalid email address".to_string()));
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str { &self.0 }
}
impl std::fmt::Display for Email {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_empty() { assert!(Email::new("").is_err()); }
#[test]
fn rejects_no_at() { assert!(Email::new("notanemail").is_err()); }
#[test]
fn accepts_valid() { assert!(Email::new("user@example.com").is_ok()); }
#[test]
fn lowercases_and_trims() {
let email = Email::new(" User@Example.Com ").unwrap();
assert_eq!(email.as_str(), "user@example.com");
}
}

View File

@@ -0,0 +1,9 @@
mod email;
mod password;
mod role;
mod user_id;
pub use email::Email;
pub use password::PasswordHash;
pub use role::Role;
pub use user_id::UserId;

View File

@@ -0,0 +1,14 @@
// Manual Debug — redacts hash to prevent it appearing in logs
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct PasswordHash(String);
impl std::fmt::Debug for PasswordHash {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("PasswordHash").field(&"[redacted]").finish()
}
}
impl PasswordHash {
pub fn from_hash(hash: String) -> Self { Self(hash) }
pub fn as_str(&self) -> &str { &self.0 }
}

View File

@@ -0,0 +1,23 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role { User, Admin }
impl std::fmt::Display for Role {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Role::User => write!(f, "user"),
Role::Admin => write!(f, "admin"),
}
}
}
impl std::str::FromStr for Role {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"user" => Ok(Role::User),
"admin" => Ok(Role::Admin),
other => Err(format!("Unknown role: {other}")),
}
}
}

View File

@@ -0,0 +1,22 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct UserId(uuid::Uuid);
impl UserId {
pub fn new() -> Self { Self(uuid::Uuid::new_v4()) }
pub fn from_uuid(id: uuid::Uuid) -> Self { Self(id) }
pub fn as_uuid(&self) -> &uuid::Uuid { &self.0 }
}
impl Default for UserId {
fn default() -> Self { Self::new() }
}
impl std::fmt::Display for UserId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<uuid::Uuid> for UserId {
fn from(id: uuid::Uuid) -> Self { Self(id) }
}

View File

@@ -0,0 +1,19 @@
[package]
name = "presentation"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
application = { workspace = true }
api-types = { path = "../api-types" }
axum = { workspace = true }
tower-http = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
tracing = { workspace = true }
async-trait = { workspace = true }
utoipa = { workspace = true }
utoipa-scalar = { workspace = true }

View File

@@ -0,0 +1,25 @@
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use domain::errors::DomainError;
use serde_json::json;
pub struct AppError(DomainError);
impl From<DomainError> for AppError {
fn from(e: DomainError) -> Self { Self(e) }
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self.0 {
DomainError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
DomainError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()),
DomainError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg.clone()),
DomainError::Validation(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg.clone()),
DomainError::Internal(msg) => {
tracing::error!("Internal error: {msg}");
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".to_string())
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}

View File

@@ -0,0 +1,38 @@
use axum::{
extract::FromRequestParts,
http::{request::Parts, StatusCode},
response::{IntoResponse, Response},
Json,
};
use domain::value_objects::{Role, UserId};
use serde_json::json;
use crate::state::AppState;
pub struct JwtClaims {
pub user_id: UserId,
pub role: Role,
}
impl FromRequestParts<AppState> for JwtClaims {
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
let auth_header = parts
.headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| {
(StatusCode::UNAUTHORIZED, Json(json!({ "error": "Missing Authorization header" }))).into_response()
})?;
let token = auth_header.strip_prefix("Bearer ").ok_or_else(|| {
(StatusCode::UNAUTHORIZED, Json(json!({ "error": "Invalid Authorization format" }))).into_response()
})?;
let (user_id, role) = state.token_issuer.verify(token).await.map_err(|_| {
(StatusCode::UNAUTHORIZED, Json(json!({ "error": "Invalid or expired token" }))).into_response()
})?;
Ok(JwtClaims { user_id, role })
}
}

View File

@@ -0,0 +1,28 @@
use axum::{
extract::{rejection::JsonRejection, FromRequest, Request},
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::de::DeserializeOwned;
use serde_json::json;
pub struct ValidatedJson<T>(pub T);
impl<T, S> FromRequest<S> for ValidatedJson<T>
where
T: DeserializeOwned,
S: Send + Sync,
Json<T>: FromRequest<S, Rejection = JsonRejection>,
{
type Rejection = Response;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
Json::<T>::from_request(req, state)
.await
.map(|Json(value)| ValidatedJson(value))
.map_err(|rejection| {
(StatusCode::UNPROCESSABLE_ENTITY, Json(json!({ "error": rejection.body_text() }))).into_response()
})
}
}

View File

@@ -0,0 +1,5 @@
pub mod auth;
pub mod json;
pub use auth::JwtClaims;
pub use json::ValidatedJson;

View File

@@ -0,0 +1,56 @@
use axum::{extract::State, http::StatusCode, Json};
use api_types::{
requests::{LoginRequest, RegisterRequest},
responses::{AuthResponse, UserResponse},
};
use crate::{errors::AppError, extractors::{JwtClaims, ValidatedJson}, state::AppState};
#[utoipa::path(
post, path = "/api/v1/auth/register",
request_body = RegisterRequest,
responses(
(status = 201, description = "User registered", body = AuthResponse),
(status = 409, description = "Email already taken"),
(status = 422, description = "Validation error")
)
)]
pub async fn register(
State(state): State<AppState>,
ValidatedJson(req): ValidatedJson<RegisterRequest>,
) -> Result<(StatusCode, Json<AuthResponse>), AppError> {
let user = state.register_uc.execute(&req.email, &req.password).await?;
let token = state.token_issuer.issue(&user.id, &user.role).await.map_err(AppError::from)?;
Ok((StatusCode::CREATED, Json(AuthResponse { token, user: UserResponse::from_domain(&user) })))
}
#[utoipa::path(
post, path = "/api/v1/auth/login",
request_body = LoginRequest,
responses(
(status = 200, description = "Login successful", body = AuthResponse),
(status = 401, description = "Invalid credentials")
)
)]
pub async fn login(
State(state): State<AppState>,
ValidatedJson(req): ValidatedJson<LoginRequest>,
) -> Result<Json<AuthResponse>, AppError> {
let (user, token) = state.login_uc.execute(&req.email, &req.password).await?;
Ok(Json(AuthResponse { token, user: UserResponse::from_domain(&user) }))
}
#[utoipa::path(
get, path = "/api/v1/auth/me",
security(("bearer_token" = [])),
responses(
(status = 200, description = "Current user profile", body = UserResponse),
(status = 401, description = "Unauthorized")
)
)]
pub async fn me(
State(state): State<AppState>,
claims: JwtClaims,
) -> Result<Json<UserResponse>, AppError> {
let user = state.get_profile_uc.execute(&claims.user_id).await?;
Ok(Json(UserResponse::from_domain(&user)))
}

View File

@@ -0,0 +1,7 @@
use axum::{http::StatusCode, Json};
use serde_json::json;
#[utoipa::path(get, path = "/health", responses((status = 200, description = "Service is healthy")))]
pub async fn health() -> (StatusCode, Json<serde_json::Value>) {
(StatusCode::OK, Json(json!({ "status": "ok" })))
}

View File

@@ -0,0 +1,2 @@
pub mod auth;
pub mod health;

View File

@@ -0,0 +1,6 @@
pub mod errors;
pub mod extractors;
pub mod handlers;
pub mod openapi;
pub mod routes;
pub mod state;

View File

@@ -0,0 +1,41 @@
use utoipa::{openapi::security::{Http, HttpAuthScheme, SecurityScheme}, Modify, OpenApi};
use utoipa_scalar::{Scalar, Servable};
use axum::Router;
use crate::state::AppState;
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::health::health,
crate::handlers::auth::register,
crate::handlers::auth::login,
crate::handlers::auth::me,
),
components(schemas(
api_types::requests::RegisterRequest,
api_types::requests::LoginRequest,
api_types::responses::AuthResponse,
api_types::responses::UserResponse,
)),
modifiers(&SecurityAddon),
info(title = "k-template", version = "0.1.0")
)]
pub struct ApiDoc;
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
if let Some(components) = openapi.components.as_mut() {
components.add_security_scheme(
"bearer_token",
SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)),
);
}
}
}
pub fn openapi_router() -> Router<AppState> {
Router::new()
.merge(Scalar::with_url("/scalar", ApiDoc::openapi()))
.route("/api-docs/openapi.json", axum::routing::get(|| async { axum::Json(ApiDoc::openapi()) }))
}

View File

@@ -0,0 +1,16 @@
use axum::{routing::{get, post}, Router};
use crate::{handlers::{auth, health}, openapi::openapi_router, state::AppState};
pub fn api_v1_router() -> Router<AppState> {
Router::new()
.route("/auth/register", post(auth::register))
.route("/auth/login", post(auth::login))
.route("/auth/me", get(auth::me))
}
pub fn app_router() -> Router<AppState> {
Router::new()
.route("/health", get(health::health))
.nest("/api/v1", api_v1_router())
.merge(openapi_router())
}

View File

@@ -0,0 +1,22 @@
use std::sync::Arc;
use application::use_cases::{GetProfile, LoginUser, RegisterUser};
use domain::ports::TokenIssuer;
#[derive(Clone)]
pub struct AppState {
pub register_uc: Arc<RegisterUser>,
pub login_uc: Arc<LoginUser>,
pub get_profile_uc: Arc<GetProfile>,
pub token_issuer: Arc<dyn TokenIssuer>,
}
impl AppState {
pub fn new(
register_uc: Arc<RegisterUser>,
login_uc: Arc<LoginUser>,
get_profile_uc: Arc<GetProfile>,
token_issuer: Arc<dyn TokenIssuer>,
) -> Self {
Self { register_uc, login_uc, get_profile_uc, token_issuer }
}
}

View File

@@ -1,16 +0,0 @@
[package]
name = "domain"
version = "0.1.0"
edition = "2024"
[dependencies]
async-trait = "0.1.89"
chrono = { version = "0.4.42", features = ["serde"] }
email_address = "0.2"
serde = { version = "1.0.228", features = ["derive"] }
thiserror = "2.0.17"
url = { version = "2.5", features = ["serde"] }
uuid = { version = "1.19.0", features = ["v4", "serde"] }
[dev-dependencies]
tokio = { version = "1", features = ["rt", "macros"] }

View File

@@ -1,60 +0,0 @@
//! Domain entities
//!
//! This module contains pure domain types with no I/O dependencies.
//! These represent the core business concepts of the application.
pub use crate::value_objects::{Email, UserId};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// A user in the system.
///
/// Designed to be OIDC-ready: the `subject` field stores the OIDC subject claim
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: UserId,
pub subject: String,
pub email: Email,
pub password_hash: Option<String>,
pub created_at: DateTime<Utc>,
}
impl User {
pub fn new(subject: impl Into<String>, email: Email) -> Self {
Self {
id: Uuid::new_v4(),
subject: subject.into(),
email,
password_hash: None,
created_at: Utc::now(),
}
}
pub fn with_id(
id: Uuid,
subject: impl Into<String>,
email: Email,
password_hash: Option<String>,
created_at: DateTime<Utc>,
) -> Self {
Self {
id,
subject: subject.into(),
email,
password_hash,
created_at,
}
}
pub fn new_local(email: Email, password_hash: impl Into<String>) -> Self {
Self {
id: Uuid::new_v4(),
subject: format!("local|{}", Uuid::new_v4()),
email,
password_hash: Some(password_hash.into()),
created_at: Utc::now(),
}
}
}

View File

@@ -1,77 +0,0 @@
//! Domain errors for K-Notes
//!
//! Uses `thiserror` for ergonomic error definitions.
//! These errors represent domain-level failures and will be mapped
//! to HTTP status codes in the API layer.
use thiserror::Error;
use uuid::Uuid;
/// Domain-level errors for K-Notes operations
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum DomainError {
/// The requested user was not found
#[error("User not found: {0}")]
UserNotFound(Uuid),
/// User with this email/subject already exists
#[error("User already exists: {0}")]
UserAlreadyExists(String),
/// A validation error occurred
#[error("Validation error: {0}")]
ValidationError(String),
/// User is not authenticated (maps to HTTP 401)
#[error("Unauthenticated: {0}")]
Unauthenticated(String),
/// User is not allowed to perform this action (maps to HTTP 403)
#[error("Forbidden: {0}")]
Forbidden(String),
/// A repository/infrastructure error occurred
#[error("Repository error: {0}")]
RepositoryError(String),
/// An infrastructure adapter error occurred
#[error("Infrastructure error: {0}")]
InfrastructureError(String),
}
impl DomainError {
/// Create a validation error
pub fn validation(message: impl Into<String>) -> Self {
Self::ValidationError(message.into())
}
/// Create an unauthenticated error (not logged in → 401)
pub fn unauthenticated(message: impl Into<String>) -> Self {
Self::Unauthenticated(message.into())
}
/// Create a forbidden error (not allowed → 403)
pub fn forbidden(message: impl Into<String>) -> Self {
Self::Forbidden(message.into())
}
/// Check if this error indicates a "not found" condition
pub fn is_not_found(&self) -> bool {
matches!(self, DomainError::UserNotFound(_))
}
/// Check if this error indicates a conflict (already exists)
pub fn is_conflict(&self) -> bool {
matches!(self, DomainError::UserAlreadyExists(_))
}
}
impl From<crate::value_objects::ValidationError> for DomainError {
fn from(error: crate::value_objects::ValidationError) -> Self {
DomainError::ValidationError(error.to_string())
}
}
/// Result type alias for domain operations
pub type DomainResult<T> = Result<T, DomainError>;

View File

@@ -1,17 +0,0 @@
//! Domain Logic
//!
//! This crate contains the core business logic, entities, and repository interfaces.
//! It is completely independent of the infrastructure layer (databases, HTTP, etc.).
pub mod entities;
pub mod errors;
pub mod repositories;
pub mod services;
pub mod value_objects;
// Re-export commonly used types
pub use entities::*;
pub use errors::{DomainError, DomainResult};
pub use repositories::*;
pub use services::UserService;
pub use value_objects::*;

View File

@@ -1,28 +0,0 @@
//! Reference Repository ports (traits)
//!
//! These traits define the interface for data persistence.
use async_trait::async_trait;
use uuid::Uuid;
use crate::entities::User;
use crate::errors::DomainResult;
/// Repository port for User persistence
#[async_trait]
pub trait UserRepository: Send + Sync {
/// Find a user by their internal ID
async fn find_by_id(&self, id: Uuid) -> DomainResult<Option<User>>;
/// Find a user by their OIDC subject (used for authentication)
async fn find_by_subject(&self, subject: &str) -> DomainResult<Option<User>>;
/// Find a user by their email
async fn find_by_email(&self, email: &str) -> DomainResult<Option<User>>;
/// Save a new user or update an existing one
async fn save(&self, user: &User) -> DomainResult<()>;
/// Delete a user by their ID
async fn delete(&self, id: Uuid) -> DomainResult<()>;
}

View File

@@ -1,64 +0,0 @@
//! Domain Services
//!
//! Services contain the business logic of the application.
use std::sync::Arc;
use uuid::Uuid;
use crate::entities::User;
use crate::errors::{DomainError, DomainResult};
use crate::repositories::UserRepository;
use crate::value_objects::Email;
/// Service for managing users
pub struct UserService {
user_repository: Arc<dyn UserRepository>,
}
impl UserService {
pub fn new(user_repository: Arc<dyn UserRepository>) -> Self {
Self { user_repository }
}
pub async fn find_or_create(&self, subject: &str, email: &str) -> DomainResult<User> {
// 1. Try to find by subject (OIDC id)
if let Some(user) = self.user_repository.find_by_subject(subject).await? {
return Ok(user);
}
// 2. Try to find by email
if let Some(mut user) = self.user_repository.find_by_email(email).await? {
// Link subject if missing (account linking logic)
if user.subject != subject {
user.subject = subject.to_string();
self.user_repository.save(&user).await?;
}
return Ok(user);
}
// 3. Create new user
let email = Email::try_from(email)?;
let user = User::new(subject, email);
self.user_repository.save(&user).await?;
Ok(user)
}
pub async fn find_by_id(&self, id: Uuid) -> DomainResult<User> {
self.user_repository
.find_by_id(id)
.await?
.ok_or(DomainError::UserNotFound(id))
}
pub async fn find_by_email(&self, email: &str) -> DomainResult<Option<User>> {
self.user_repository.find_by_email(email).await
}
pub async fn create_local(&self, email: &str, password_hash: &str) -> DomainResult<User> {
let email = Email::try_from(email)?;
let user = User::new_local(email, password_hash);
self.user_repository.save(&user).await?;
Ok(user)
}
}

View File

@@ -1,652 +0,0 @@
//! Value Objects for K-Notes Domain
//!
//! Newtypes that encapsulate validation logic, following the "parse, don't validate" pattern.
//! These types can only be constructed if the input is valid, providing compile-time guarantees.
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;
use thiserror::Error;
use url::Url;
use uuid::Uuid;
pub type UserId = Uuid;
// ============================================================================
// Validation Error
// ============================================================================
/// Errors that occur when parsing/validating value objects
#[derive(Debug, Error, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ValidationError {
#[error("Invalid email format: {0}")]
InvalidEmail(String),
#[error("Password must be at least {min} characters, got {actual}")]
PasswordTooShort { min: usize, actual: usize },
#[error("Invalid URL: {0}")]
InvalidUrl(String),
#[error("Value cannot be empty: {0}")]
Empty(String),
#[error("Secret too short: minimum {min} bytes required, got {actual}")]
SecretTooShort { min: usize, actual: usize },
}
// ============================================================================
// Email (using email_address crate for RFC-compliant validation)
// ============================================================================
/// A validated email address using RFC-compliant validation.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Email(email_address::EmailAddress);
impl Email {
/// Create a new validated email address
pub fn new(value: impl AsRef<str>) -> Result<Self, ValidationError> {
let value = value.as_ref().trim().to_lowercase();
let addr: email_address::EmailAddress = value
.parse()
.map_err(|_| ValidationError::InvalidEmail(value.clone()))?;
Ok(Self(addr))
}
/// Get the inner value
pub fn into_inner(self) -> String {
self.0.to_string()
}
}
impl AsRef<str> for Email {
fn as_ref(&self) -> &str {
self.0.as_ref()
}
}
impl fmt::Display for Email {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl TryFrom<String> for Email {
type Error = ValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl TryFrom<&str> for Email {
type Error = ValidationError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl Serialize for Email {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.0.as_ref())
}
}
impl<'de> Deserialize<'de> for Email {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Self::new(s).map_err(serde::de::Error::custom)
}
}
// ============================================================================
// Password
// ============================================================================
/// A validated password input (NOT the hash).
///
/// Enforces minimum length of 6 characters.
#[derive(Clone, PartialEq, Eq)]
pub struct Password(String);
/// Minimum password length (NIST recommendation)
pub const MIN_PASSWORD_LENGTH: usize = 8;
impl Password {
pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
let value = value.into();
if value.len() < MIN_PASSWORD_LENGTH {
return Err(ValidationError::PasswordTooShort {
min: MIN_PASSWORD_LENGTH,
actual: value.len(),
});
}
Ok(Self(value))
}
pub fn into_inner(self) -> String {
self.0
}
}
impl AsRef<str> for Password {
fn as_ref(&self) -> &str {
&self.0
}
}
// Intentionally hide password content in Debug
impl fmt::Debug for Password {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Password(***)")
}
}
impl TryFrom<String> for Password {
type Error = ValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl TryFrom<&str> for Password {
type Error = ValidationError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl<'de> Deserialize<'de> for Password {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Self::new(s).map_err(serde::de::Error::custom)
}
}
// Note: Password should NOT implement Serialize to prevent accidental exposure
// ============================================================================
// OIDC Configuration Newtypes
// ============================================================================
/// OIDC Issuer URL - validated URL for the identity provider
///
/// Stores the original string to preserve exact formatting (e.g., trailing slashes)
/// since OIDC providers expect issuer URLs to match exactly.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct IssuerUrl(String);
impl IssuerUrl {
pub fn new(value: impl AsRef<str>) -> Result<Self, ValidationError> {
let value = value.as_ref().trim().to_string();
// Validate URL format but store original string to preserve exact formatting
Url::parse(&value).map_err(|e| ValidationError::InvalidUrl(e.to_string()))?;
Ok(Self(value))
}
}
impl AsRef<str> for IssuerUrl {
fn as_ref(&self) -> &str {
&self.0
}
}
impl fmt::Display for IssuerUrl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl TryFrom<String> for IssuerUrl {
type Error = ValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<IssuerUrl> for String {
fn from(val: IssuerUrl) -> Self {
val.0
}
}
/// OIDC Client Identifier
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct ClientId(String);
impl ClientId {
pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
let value = value.into().trim().to_string();
if value.is_empty() {
return Err(ValidationError::Empty("client_id".to_string()));
}
Ok(Self(value))
}
}
impl AsRef<str> for ClientId {
fn as_ref(&self) -> &str {
&self.0
}
}
impl fmt::Display for ClientId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl TryFrom<String> for ClientId {
type Error = ValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<ClientId> for String {
fn from(val: ClientId) -> Self {
val.0
}
}
/// OIDC Client Secret - hidden in Debug output
#[derive(Clone, PartialEq, Eq)]
pub struct ClientSecret(String);
impl ClientSecret {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
/// Check if the secret is empty (for public clients)
pub fn is_empty(&self) -> bool {
self.0.trim().is_empty()
}
}
impl AsRef<str> for ClientSecret {
fn as_ref(&self) -> &str {
&self.0
}
}
impl fmt::Debug for ClientSecret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "ClientSecret(***)")
}
}
impl fmt::Display for ClientSecret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "***")
}
}
impl<'de> Deserialize<'de> for ClientSecret {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Ok(Self::new(s))
}
}
// Note: ClientSecret should NOT implement Serialize
/// OAuth Redirect URL - validated URL
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct RedirectUrl(Url);
impl RedirectUrl {
pub fn new(value: impl AsRef<str>) -> Result<Self, ValidationError> {
let value = value.as_ref().trim();
let url = Url::parse(value).map_err(|e| ValidationError::InvalidUrl(e.to_string()))?;
Ok(Self(url))
}
pub fn as_url(&self) -> &Url {
&self.0
}
}
impl AsRef<str> for RedirectUrl {
fn as_ref(&self) -> &str {
self.0.as_str()
}
}
impl fmt::Display for RedirectUrl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl TryFrom<String> for RedirectUrl {
type Error = ValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<RedirectUrl> for String {
fn from(val: RedirectUrl) -> Self {
val.0.to_string()
}
}
/// OIDC Resource Identifier (optional audience)
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct ResourceId(String);
impl ResourceId {
pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
let value = value.into().trim().to_string();
if value.is_empty() {
return Err(ValidationError::Empty("resource_id".to_string()));
}
Ok(Self(value))
}
}
impl AsRef<str> for ResourceId {
fn as_ref(&self) -> &str {
&self.0
}
}
impl fmt::Display for ResourceId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl TryFrom<String> for ResourceId {
type Error = ValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<ResourceId> for String {
fn from(val: ResourceId) -> Self {
val.0
}
}
// ============================================================================
// OIDC Flow Newtypes (for type-safe session storage)
// ============================================================================
/// CSRF Token for OIDC state parameter
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CsrfToken(String);
impl CsrfToken {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
}
impl AsRef<str> for CsrfToken {
fn as_ref(&self) -> &str {
&self.0
}
}
impl fmt::Display for CsrfToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
/// Nonce for OIDC ID token verification
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OidcNonce(String);
impl OidcNonce {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
}
impl AsRef<str> for OidcNonce {
fn as_ref(&self) -> &str {
&self.0
}
}
impl fmt::Display for OidcNonce {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
/// PKCE Code Verifier
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PkceVerifier(String);
impl PkceVerifier {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
}
impl AsRef<str> for PkceVerifier {
fn as_ref(&self) -> &str {
&self.0
}
}
// Hide PKCE verifier in Debug (security)
impl fmt::Debug for PkceVerifier {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "PkceVerifier(***)")
}
}
/// OAuth2 Authorization Code
#[derive(Clone, PartialEq, Eq)]
pub struct AuthorizationCode(String);
impl AuthorizationCode {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
}
impl AsRef<str> for AuthorizationCode {
fn as_ref(&self) -> &str {
&self.0
}
}
// Hide authorization code in Debug (security)
impl fmt::Debug for AuthorizationCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "AuthorizationCode(***)")
}
}
impl<'de> Deserialize<'de> for AuthorizationCode {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Ok(Self::new(s))
}
}
/// Complete authorization URL data returned when starting OIDC flow
#[derive(Debug, Clone)]
pub struct AuthorizationUrlData {
/// The URL to redirect the user to
pub url: Url,
/// CSRF token to store in session
pub csrf_token: CsrfToken,
/// Nonce to store in session
pub nonce: OidcNonce,
/// PKCE verifier to store in session
pub pkce_verifier: PkceVerifier,
}
// ============================================================================
// Configuration Newtypes
// ============================================================================
/// JWT signing secret with minimum length requirement
pub const MIN_JWT_SECRET_LENGTH: usize = 32;
#[derive(Clone, PartialEq, Eq)]
pub struct JwtSecret(String);
impl JwtSecret {
pub fn new(value: impl Into<String>, is_production: bool) -> Result<Self, ValidationError> {
let value = value.into();
if is_production && value.len() < MIN_JWT_SECRET_LENGTH {
return Err(ValidationError::SecretTooShort {
min: MIN_JWT_SECRET_LENGTH,
actual: value.len(),
});
}
Ok(Self(value))
}
/// Create without validation (for development/testing)
pub fn new_unchecked(value: impl Into<String>) -> Self {
Self(value.into())
}
}
impl AsRef<str> for JwtSecret {
fn as_ref(&self) -> &str {
&self.0
}
}
impl fmt::Debug for JwtSecret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "JwtSecret(***)")
}
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
mod email_tests {
use super::*;
#[test]
fn test_valid_email() {
assert!(Email::new("user@example.com").is_ok());
assert!(Email::new("USER@EXAMPLE.COM").is_ok()); // Should lowercase
assert!(Email::new(" user@example.com ").is_ok()); // Should trim
}
#[test]
fn test_email_normalizes() {
let email = Email::new(" USER@EXAMPLE.COM ").unwrap();
assert_eq!(email.as_ref(), "user@example.com");
}
#[test]
fn test_invalid_email_no_at() {
assert!(Email::new("userexample.com").is_err());
}
#[test]
fn test_invalid_email_no_domain() {
assert!(Email::new("user@").is_err());
}
#[test]
fn test_invalid_email_no_local() {
assert!(Email::new("@example.com").is_err());
}
}
mod password_tests {
use super::*;
#[test]
fn test_valid_password() {
assert!(Password::new("secret123").is_ok());
assert!(Password::new("12345678").is_ok()); // Exactly 8 chars
}
#[test]
fn test_password_too_short() {
assert!(Password::new("1234567").is_err()); // 7 chars
assert!(Password::new("").is_err());
}
#[test]
fn test_password_debug_hides_content() {
let password = Password::new("supersecret").unwrap();
let debug = format!("{:?}", password);
assert!(!debug.contains("supersecret"));
assert!(debug.contains("***"));
}
}
mod oidc_tests {
use super::*;
#[test]
fn test_issuer_url_valid() {
assert!(IssuerUrl::new("https://auth.example.com").is_ok());
}
#[test]
fn test_issuer_url_invalid() {
assert!(IssuerUrl::new("not-a-url").is_err());
}
#[test]
fn test_client_id_non_empty() {
assert!(ClientId::new("my-client").is_ok());
assert!(ClientId::new("").is_err());
assert!(ClientId::new(" ").is_err());
}
#[test]
fn test_client_secret_hides_in_debug() {
let secret = ClientSecret::new("super-secret");
let debug = format!("{:?}", secret);
assert!(!debug.contains("super-secret"));
assert!(debug.contains("***"));
}
}
mod secret_tests {
use super::*;
#[test]
fn test_jwt_secret_production_check() {
let short = "short";
let long = "a".repeat(32);
// Production mode enforces length
assert!(JwtSecret::new(short, true).is_err());
assert!(JwtSecret::new(&long, true).is_ok());
// Development mode allows short secrets
assert!(JwtSecret::new(short, false).is_ok());
}
#[test]
fn test_secrets_hide_in_debug() {
let jwt = JwtSecret::new_unchecked("secret");
assert!(!format!("{:?}", jwt).contains("secret"));
}
}
}

View File

@@ -1,46 +0,0 @@
[package]
name = "infra"
version = "0.1.0"
edition = "2024"
[features]
default = ["sqlite"]
sqlite = ["sqlx/sqlite", "k-core/sqlite"]
postgres = ["sqlx/postgres", "k-core/postgres"]
broker-nats = ["dep:futures-util", "k-core/broker-nats"]
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",
] }
domain = { path = "../domain" }
async-trait = "0.1.89"
chrono = { version = "0.4.42", features = ["serde"] }
sqlx = { version = "0.8.6", features = ["runtime-tokio", "chrono", "migrate"] }
thiserror = "2.0.17"
anyhow = "1.0"
tokio = { version = "1.48.0", features = ["full"] }
tracing = "0.1"
uuid = { version = "1.19.0", features = ["v4", "serde"] }
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 }
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",
"hmac",
"rsa",
"rust_crypto",
], optional = true }

View File

@@ -1,40 +0,0 @@
[package]
name = "infra"
version = "0.1.0"
edition = "2024"
[features]
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-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",
] }
domain = { path = "../domain" }
async-trait = "0.1.89"
chrono = { version = "0.4.42", features = ["serde"] }
sqlx = { version = "0.8.6", features = ["runtime-tokio", "chrono", "migrate"] }
thiserror = "2.0.17"
anyhow = "1.0"
tokio = { version = "1.48.0", features = ["full"] }
tracing = "0.1"
uuid = { version = "1.19.0", features = ["v4", "serde"] }
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 }
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 }

View File

@@ -1,278 +0,0 @@
//! JWT Authentication Infrastructure
//!
//! Provides JWT token creation and validation using HS256 (secret-based).
//! For OIDC/JWKS validation, see the `oidc` module.
use domain::User;
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
/// Minimum secret length for production (256 bits = 32 bytes)
const MIN_SECRET_LENGTH: usize = 32;
/// JWT configuration
#[derive(Debug, Clone)]
pub struct JwtConfig {
/// Secret key for HS256 signing/verification
pub secret: String,
/// Expected issuer (for validation)
pub issuer: Option<String>,
/// Expected audience (for validation)
pub audience: Option<String>,
/// Token expiry in hours (default: 24)
pub expiry_hours: u64,
}
impl JwtConfig {
/// Create a new JWT config with validation
///
/// In production mode, this will reject weak secrets.
pub fn new(
secret: String,
issuer: Option<String>,
audience: Option<String>,
expiry_hours: Option<u64>,
is_production: bool,
) -> Result<Self, JwtError> {
// Validate secret strength in production
if is_production && secret.len() < MIN_SECRET_LENGTH {
return Err(JwtError::WeakSecret {
min_length: MIN_SECRET_LENGTH,
actual_length: secret.len(),
});
}
Ok(Self {
secret,
issuer,
audience,
expiry_hours: expiry_hours.unwrap_or(24),
})
}
/// Create config without validation (for testing)
pub fn new_unchecked(secret: String) -> Self {
Self {
secret,
issuer: None,
audience: None,
expiry_hours: 24,
}
}
}
/// JWT claims structure
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct JwtClaims {
/// Subject - the user's unique identifier (user ID as string)
pub sub: String,
/// User's email address
pub email: String,
/// Expiry timestamp (seconds since UNIX epoch)
pub exp: usize,
/// Issued at timestamp (seconds since UNIX epoch)
pub iat: usize,
/// Issuer
#[serde(skip_serializing_if = "Option::is_none")]
pub iss: Option<String>,
/// Audience
#[serde(skip_serializing_if = "Option::is_none")]
pub aud: Option<String>,
}
/// JWT-related errors
#[derive(Debug, thiserror::Error)]
pub enum JwtError {
#[error("JWT secret is too weak: minimum {min_length} bytes required, got {actual_length}")]
WeakSecret {
min_length: usize,
actual_length: usize,
},
#[error("Token creation failed: {0}")]
CreationFailed(#[from] jsonwebtoken::errors::Error),
#[error("Token validation failed: {0}")]
ValidationFailed(String),
#[error("Token expired")]
Expired,
#[error("Invalid token format")]
InvalidFormat,
#[error("Missing configuration")]
MissingConfig,
}
/// JWT token validator and generator
#[derive(Clone)]
pub struct JwtValidator {
config: JwtConfig,
encoding_key: EncodingKey,
decoding_key: DecodingKey,
validation: Validation,
}
impl JwtValidator {
/// Create a new JWT validator with the given configuration
pub fn new(config: JwtConfig) -> Self {
let encoding_key = EncodingKey::from_secret(config.secret.as_bytes());
let decoding_key = DecodingKey::from_secret(config.secret.as_bytes());
let mut validation = Validation::new(Algorithm::HS256);
// Configure issuer validation if set
if let Some(ref issuer) = config.issuer {
validation.set_issuer(&[issuer]);
}
// Configure audience validation if set
if let Some(ref audience) = config.audience {
validation.set_audience(&[audience]);
}
Self {
config,
encoding_key,
decoding_key,
validation,
}
}
/// Create a JWT token for the given user
pub fn create_token(&self, user: &User) -> Result<String, JwtError> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
.as_secs() as usize;
let expiry = now + (self.config.expiry_hours as usize * 3600);
let claims = JwtClaims {
sub: user.id.to_string(),
email: user.email.as_ref().to_string(),
exp: expiry,
iat: now,
iss: self.config.issuer.clone(),
aud: self.config.audience.clone(),
};
let header = Header::new(Algorithm::HS256);
encode(&header, &claims, &self.encoding_key).map_err(JwtError::CreationFailed)
}
/// Validate a JWT token and return the claims
pub fn validate_token(&self, token: &str) -> Result<JwtClaims, JwtError> {
let token_data = decode::<JwtClaims>(token, &self.decoding_key, &self.validation).map_err(
|e| match e.kind() {
jsonwebtoken::errors::ErrorKind::ExpiredSignature => JwtError::Expired,
jsonwebtoken::errors::ErrorKind::InvalidToken => JwtError::InvalidFormat,
_ => JwtError::ValidationFailed(e.to_string()),
},
)?;
Ok(token_data.claims)
}
/// Get the user ID (subject) from a token without full validation
/// Useful for logging/debugging, but should not be trusted for auth
pub fn decode_unverified(&self, token: &str) -> Result<JwtClaims, JwtError> {
let mut validation = Validation::new(Algorithm::HS256);
validation.insecure_disable_signature_validation();
validation.validate_exp = false;
let token_data = decode::<JwtClaims>(token, &self.decoding_key, &validation)
.map_err(|_| JwtError::InvalidFormat)?;
Ok(token_data.claims)
}
}
impl std::fmt::Debug for JwtValidator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("JwtValidator")
.field("issuer", &self.config.issuer)
.field("audience", &self.config.audience)
.field("expiry_hours", &self.config.expiry_hours)
.finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::*;
use domain::Email;
fn create_test_user() -> User {
let email = Email::try_from("test@example.com").unwrap();
User::new("test-subject", email)
}
#[test]
fn test_create_and_validate_token() {
let config = JwtConfig::new_unchecked("test-secret-key-that-is-long-enough".to_string());
let validator = JwtValidator::new(config);
let user = create_test_user();
let token = validator.create_token(&user).expect("Should create token");
let claims = validator
.validate_token(&token)
.expect("Should validate token");
assert_eq!(claims.sub, user.id.to_string());
assert_eq!(claims.email, "test@example.com");
}
#[test]
fn test_weak_secret_rejected_in_production() {
let result = JwtConfig::new(
"short".to_string(), // Too short
None,
None,
None,
true, // Production mode
);
assert!(matches!(result, Err(JwtError::WeakSecret { .. })));
}
#[test]
fn test_weak_secret_allowed_in_development() {
let result = JwtConfig::new(
"short".to_string(), // Too short but OK in dev
None,
None,
None,
false, // Development mode
);
assert!(result.is_ok());
}
#[test]
fn test_invalid_token_rejected() {
let config = JwtConfig::new_unchecked("test-secret-key-that-is-long-enough".to_string());
let validator = JwtValidator::new(config);
let result = validator.validate_token("invalid.token.here");
assert!(result.is_err());
}
#[test]
fn test_wrong_secret_rejected() {
let config1 = JwtConfig::new_unchecked("secret-one-that-is-long-enough".to_string());
let config2 = JwtConfig::new_unchecked("secret-two-that-is-long-enough".to_string());
let validator1 = JwtValidator::new(config1);
let validator2 = JwtValidator::new(config2);
let user = create_test_user();
let token = validator1.create_token(&user).unwrap();
// Token from validator1 should fail on validator2
let result = validator2.validate_token(&token);
assert!(result.is_err());
}
}

View File

@@ -1,19 +0,0 @@
//! Authentication infrastructure
//!
//! This module contains the concrete implementation of authentication mechanisms.
/// Hash a password using the password-auth crate
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")]
pub mod oidc;
#[cfg(feature = "auth-jwt")]
pub mod jwt;

View File

@@ -1,212 +0,0 @@
use anyhow::anyhow;
use domain::{
AuthorizationCode, AuthorizationUrlData, ClientId, ClientSecret, CsrfToken, IssuerUrl,
OidcNonce, PkceVerifier, RedirectUrl, ResourceId,
};
use openidconnect::{
AccessTokenHash, Client, EmptyAdditionalClaims, EndpointMaybeSet, EndpointNotSet, EndpointSet,
OAuth2TokenResponse, PkceCodeChallenge, Scope, StandardErrorResponse, TokenResponse,
UserInfoClaims,
core::{
CoreAuthDisplay, CoreAuthPrompt, CoreAuthenticationFlow, CoreClient, CoreErrorResponseType,
CoreGenderClaim, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreProviderMetadata,
CoreRevocableToken, CoreRevocationErrorResponse, CoreTokenIntrospectionResponse,
CoreTokenResponse,
},
reqwest,
};
use serde::{Deserialize, Serialize};
pub type OidcClient = Client<
EmptyAdditionalClaims,
CoreAuthDisplay,
CoreGenderClaim,
CoreJweContentEncryptionAlgorithm,
CoreJsonWebKey,
CoreAuthPrompt,
StandardErrorResponse<CoreErrorResponseType>,
CoreTokenResponse,
CoreTokenIntrospectionResponse,
CoreRevocableToken,
CoreRevocationErrorResponse,
EndpointSet, // HasAuthUrl (Required and guaranteed by discovery)
EndpointNotSet, // HasDeviceAuthUrl
EndpointNotSet, // HasIntrospectionUrl
EndpointNotSet, // HasRevocationUrl
EndpointMaybeSet, // HasTokenUrl (Discovered, might be missing)
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>,
}
#[derive(Debug)]
pub struct OidcUser {
pub subject: String,
pub email: String,
}
impl OidcService {
/// Create a new OIDC service with validated configuration newtypes
pub async fn new(
issuer: IssuerUrl,
client_id: ClientId,
client_secret: Option<ClientSecret>,
redirect_url: RedirectUrl,
resource_id: Option<ResourceId>,
) -> anyhow::Result<Self> {
tracing::debug!("🔵 OIDC Setup: Client ID = '{}'", client_id);
tracing::debug!("🔵 OIDC Setup: Redirect = '{}'", redirect_url);
tracing::debug!(
"🔵 OIDC Setup: Secret = {:?}",
if client_secret.is_some() { "SET" } else { "NONE" }
);
let http_client = reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.build()?;
let provider_metadata = CoreProviderMetadata::discover_async(
openidconnect::IssuerUrl::new(issuer.as_ref().to_string())?,
&http_client,
)
.await?;
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 client = CoreClient::from_provider_metadata(
provider_metadata,
oidc_client_id,
oidc_client_secret,
)
.set_redirect_uri(oidc_redirect_url);
Ok(Self {
client,
http_client,
resource_id,
})
}
/// Get the authorization URL and associated state for OIDC login.
///
/// 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
.client
.authorize_url(
CoreAuthenticationFlow::AuthorizationCode,
openidconnect::CsrfToken::new_random,
openidconnect::Nonce::new_random,
)
.add_scope(Scope::new("profile".to_string()))
.add_scope(Scope::new("email".to_string()))
.set_pkce_challenge(pkce_challenge)
.url();
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
pub async fn resolve_callback(
&self,
code: AuthorizationCode,
nonce: OidcNonce,
pkce_verifier: PkceVerifier,
) -> anyhow::Result<OidcUser> {
let oidc_pkce_verifier =
openidconnect::PkceCodeVerifier::new(pkce_verifier.as_ref().to_string());
let oidc_nonce = openidconnect::Nonce::new(nonce.as_ref().to_string());
let token_response = self
.client
.exchange_code(openidconnect::AuthorizationCode::new(
code.as_ref().to_string(),
))?
.set_pkce_verifier(oidc_pkce_verifier)
.request_async(&self.http_client)
.await?;
let id_token = token_response
.id_token()
.ok_or_else(|| anyhow!("Server did not return an ID token"))?;
let mut id_token_verifier = self.client.id_token_verifier().clone();
if let Some(resource_id) = &self.resource_id {
let trusted_resource_id = resource_id.as_ref().to_string();
id_token_verifier = id_token_verifier
.set_other_audience_verifier_fn(move |aud| aud.as_str() == trusted_resource_id);
}
let claims = id_token.claims(&id_token_verifier, &oidc_nonce)?;
if let Some(expected_access_token_hash) = claims.access_token_hash() {
let actual_access_token_hash = AccessTokenHash::from_token(
token_response.access_token(),
id_token.signing_alg()?,
id_token.signing_key(&id_token_verifier)?,
)?;
if actual_access_token_hash != *expected_access_token_hash {
return Err(anyhow!("Invalid access token"));
}
}
let email = if let Some(email) = claims.email() {
Some(email.as_str().to_string())
} else {
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(&self.http_client)
.await?;
user_info.email().map(|e| e.as_str().to_string())
};
let email =
email.ok_or_else(|| anyhow!("User has no verified email address in ZITADEL"))?;
Ok(OidcUser {
subject: claims.subject().to_string(),
email,
})
}
}

View File

@@ -1,17 +0,0 @@
pub use k_core::db::DatabasePool;
pub async fn run_migrations(pool: &DatabasePool) -> Result<(), sqlx::Error> {
match pool {
#[cfg(feature = "sqlite")]
DatabasePool::Sqlite(pool) => {
// Point specifically to the sqlite folder
sqlx::migrate!("../migrations_sqlite").run(pool).await?;
}
#[cfg(feature = "postgres")]
DatabasePool::Postgres(pool) => {
// Point specifically to the postgres folder
sqlx::migrate!("../migrations_postgres").run(pool).await?;
}
}
Ok(())
}

View File

@@ -1,33 +0,0 @@
use std::sync::Arc;
#[cfg(feature = "sqlite")]
use crate::SqliteUserRepository;
use crate::db::DatabasePool;
use domain::UserRepository;
#[derive(Debug, thiserror::Error)]
pub enum FactoryError {
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Not implemented: {0}")]
NotImplemented(String),
#[error("Infrastructure error: {0}")]
Infrastructure(#[from] domain::DomainError),
}
pub type FactoryResult<T> = Result<T, FactoryError>;
pub async fn build_user_repository(pool: &DatabasePool) -> FactoryResult<Arc<dyn UserRepository>> {
match pool {
#[cfg(feature = "sqlite")]
DatabasePool::Sqlite(pool) => Ok(Arc::new(SqliteUserRepository::new(pool.clone()))),
#[cfg(feature = "postgres")]
DatabasePool::Postgres(pool) => Ok(Arc::new(
crate::user_repository::PostgresUserRepository::new(pool.clone()),
)),
#[allow(unreachable_patterns)]
_ => Err(FactoryError::NotImplemented(
"No database feature enabled".to_string(),
)),
}
}

View File

@@ -1,23 +0,0 @@
//! K-Notes Infrastructure Layer
//!
//! This crate provides concrete implementations (adapters) for the
//! repository ports defined in the domain layer.
//!
//! ## Adapters
//!
//! - [`SqliteUserRepository`] - SQLite adapter for users (OIDC-ready)
//!
//! ## Database
//!
//! - [`db::create_pool`] - Create a database connection pool
//! - [`db::run_migrations`] - Run database migrations
pub mod auth;
pub mod db;
pub mod factory;
mod user_repository;
// Re-export for convenience
pub use db::run_migrations;
#[cfg(feature = "sqlite")]
pub use user_repository::SqliteUserRepository;

View File

@@ -1,324 +0,0 @@
//! SQLite and PostgreSQL implementations of UserRepository
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::FromRow;
use uuid::Uuid;
use domain::{DomainError, DomainResult, Email, User, UserRepository};
/// Row type for database query results (shared between SQLite and PostgreSQL)
#[derive(Debug, FromRow)]
struct UserRow {
id: String,
subject: String,
email: String,
password_hash: Option<String>,
created_at: String,
}
impl TryFrom<UserRow> for User {
type Error = DomainError;
fn try_from(row: UserRow) -> Result<Self, Self::Error> {
let id = Uuid::parse_str(&row.id)
.map_err(|e| DomainError::RepositoryError(format!("Invalid UUID: {}", e)))?;
let created_at = DateTime::parse_from_rfc3339(&row.created_at)
.map(|dt| dt.with_timezone(&Utc))
.or_else(|_| {
// Fallback for SQLite datetime format
chrono::NaiveDateTime::parse_from_str(&row.created_at, "%Y-%m-%d %H:%M:%S")
.map(|dt| dt.and_utc())
})
.map_err(|e| DomainError::RepositoryError(format!("Invalid datetime: {}", e)))?;
let email = Email::try_from(row.email)
.map_err(|e| DomainError::RepositoryError(format!("Invalid email in DB: {}", e)))?;
Ok(User::with_id(
id,
row.subject,
email,
row.password_hash,
created_at,
))
}
}
/// 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 {
async fn find_by_id(&self, id: Uuid) -> DomainResult<Option<User>> {
let id_str = id.to_string();
let row: Option<UserRow> = sqlx::query_as(
"SELECT id, subject, email, password_hash, created_at FROM users WHERE id = ?",
)
.bind(&id_str)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
row.map(User::try_from).transpose()
}
async fn find_by_subject(&self, subject: &str) -> DomainResult<Option<User>> {
let row: Option<UserRow> = sqlx::query_as(
"SELECT id, subject, email, password_hash, created_at FROM users WHERE subject = ?",
)
.bind(subject)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
row.map(User::try_from).transpose()
}
async fn find_by_email(&self, email: &str) -> DomainResult<Option<User>> {
let row: Option<UserRow> = sqlx::query_as(
"SELECT id, subject, email, password_hash, created_at FROM users WHERE email = ?",
)
.bind(email)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
row.map(User::try_from).transpose()
}
async fn save(&self, user: &User) -> DomainResult<()> {
let id = user.id.to_string();
let created_at = user.created_at.to_rfc3339();
sqlx::query(
r#"
INSERT INTO users (id, subject, email, password_hash, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
subject = excluded.subject,
email = excluded.email,
password_hash = excluded.password_hash
"#,
)
.bind(&id)
.bind(&user.subject)
.bind(user.email.as_ref())
.bind(&user.password_hash)
.bind(&created_at)
.execute(&self.pool)
.await
.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(())
}
async fn delete(&self, id: Uuid) -> DomainResult<()> {
let id_str = id.to_string();
sqlx::query("DELETE FROM users WHERE id = ?")
.bind(&id_str)
.execute(&self.pool)
.await
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
Ok(())
}
}
#[cfg(all(test, feature = "sqlite"))]
mod tests {
use super::*;
use crate::db::run_migrations;
use k_core::db::{DatabaseConfig, DatabasePool, connect};
async fn setup_test_db() -> sqlx::SqlitePool {
let config = DatabaseConfig::default();
let db_pool = connect(&config).await.expect("Failed to create pool");
run_migrations(&db_pool).await.unwrap();
match db_pool {
DatabasePool::Sqlite(pool) => pool,
}
}
#[tokio::test]
async fn test_save_and_find_user() {
let pool = setup_test_db().await;
let repo = SqliteUserRepository::new(pool);
let email = Email::try_from("test@example.com").unwrap();
let user = User::new("oidc|123", email);
repo.save(&user).await.unwrap();
let found = repo.find_by_id(user.id).await.unwrap();
assert!(found.is_some());
let found = found.unwrap();
assert_eq!(found.subject, "oidc|123");
assert_eq!(found.email.as_ref(), "test@example.com");
assert!(found.password_hash.is_none());
}
#[tokio::test]
async fn test_save_and_find_user_with_password() {
let pool = setup_test_db().await;
let repo = SqliteUserRepository::new(pool);
let email = Email::try_from("local@example.com").unwrap();
let user = User::new_local(email, "hashed_pw");
repo.save(&user).await.unwrap();
let found = repo.find_by_id(user.id).await.unwrap();
assert!(found.is_some());
let found = found.unwrap();
assert_eq!(found.email.as_ref(), "local@example.com");
assert_eq!(found.password_hash, Some("hashed_pw".to_string()));
}
#[tokio::test]
async fn test_find_by_subject() {
let pool = setup_test_db().await;
let repo = SqliteUserRepository::new(pool);
let email = Email::try_from("user@gmail.com").unwrap();
let user = User::new("google|456", email);
repo.save(&user).await.unwrap();
let found = repo.find_by_subject("google|456").await.unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().id, user.id);
}
#[tokio::test]
async fn test_delete_user() {
let pool = setup_test_db().await;
let repo = SqliteUserRepository::new(pool);
let email = Email::try_from("delete@test.com").unwrap();
let user = User::new("test|789", email);
repo.save(&user).await.unwrap();
repo.delete(user.id).await.unwrap();
let found = repo.find_by_id(user.id).await.unwrap();
assert!(found.is_none());
}
}
/// PostgreSQL adapter for UserRepository
#[cfg(feature = "postgres")]
#[derive(Clone)]
pub struct PostgresUserRepository {
pool: sqlx::Pool<sqlx::Postgres>,
}
#[cfg(feature = "postgres")]
impl PostgresUserRepository {
pub fn new(pool: sqlx::Pool<sqlx::Postgres>) -> Self {
Self { pool }
}
}
#[cfg(feature = "postgres")]
#[async_trait]
impl UserRepository for PostgresUserRepository {
async fn find_by_id(&self, id: Uuid) -> DomainResult<Option<User>> {
let id_str = id.to_string();
let row: Option<UserRow> = sqlx::query_as(
"SELECT id, subject, email, password_hash, created_at FROM users WHERE id = $1",
)
.bind(&id_str)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
row.map(User::try_from).transpose()
}
async fn find_by_subject(&self, subject: &str) -> DomainResult<Option<User>> {
let row: Option<UserRow> = sqlx::query_as(
"SELECT id, subject, email, password_hash, created_at FROM users WHERE subject = $1",
)
.bind(subject)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
row.map(User::try_from).transpose()
}
async fn find_by_email(&self, email: &str) -> DomainResult<Option<User>> {
let row: Option<UserRow> = sqlx::query_as(
"SELECT id, subject, email, password_hash, created_at FROM users WHERE email = $1",
)
.bind(email)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
row.map(User::try_from).transpose()
}
async fn save(&self, user: &User) -> DomainResult<()> {
let id = user.id.to_string();
let created_at = user.created_at.to_rfc3339();
sqlx::query(
r#"
INSERT INTO users (id, subject, email, password_hash, created_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT(id) DO UPDATE SET
subject = excluded.subject,
email = excluded.email,
password_hash = excluded.password_hash
"#,
)
.bind(&id)
.bind(&user.subject)
.bind(user.email.as_ref())
.bind(&user.password_hash)
.bind(&created_at)
.execute(&self.pool)
.await
.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(())
}
async fn delete(&self, id: Uuid) -> DomainResult<()> {
let id_str = id.to_string();
sqlx::query("DELETE FROM users WHERE id = $1")
.bind(&id_str)
.execute(&self.pool)
.await
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
Ok(())
}
}

View File

@@ -1,11 +1,10 @@
-- Create users table
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY NOT NULL,
subject TEXT NOT NULL,
email TEXT NOT NULL,
password_hash TEXT,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
created_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_subject ON users(subject);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email);