From fa867a837fd4d2bbd95d4612f7be85fa6e6c0901 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 17 May 2026 23:46:02 +0200 Subject: [PATCH] chore: strip old structure, scaffold new hexagonal workspace --- Cargo.toml | 38 +- Cargo.toml.liquid | 39 +++ api/Cargo.toml | 49 --- api/Cargo.toml.template | 49 --- api/src/config.rs | 119 ------- api/src/dto.rs | 49 --- api/src/error.rs | 126 ------- api/src/extractors.rs | 89 ----- api/src/main.rs | 95 ----- api/src/routes/auth.rs | 253 -------------- api/src/routes/config.rs | 13 - api/src/routes/mod.rs | 16 - api/src/state.rs | 116 ------- domain/Cargo.toml | 16 - domain/src/entities.rs | 60 ---- domain/src/errors.rs | 77 ----- domain/src/lib.rs | 17 - domain/src/repositories.rs | 28 -- domain/src/services.rs | 64 ---- domain/src/value_objects.rs | 652 ----------------------------------- infra/Cargo.toml | 46 --- infra/Cargo.toml.template | 40 --- infra/src/auth/jwt.rs | 278 --------------- infra/src/auth/mod.rs | 19 - infra/src/auth/oidc.rs | 212 ------------ infra/src/db.rs | 17 - infra/src/factory.rs | 33 -- infra/src/lib.rs | 23 -- infra/src/user_repository.rs | 324 ----------------- 29 files changed, 76 insertions(+), 2881 deletions(-) create mode 100644 Cargo.toml.liquid delete mode 100644 api/Cargo.toml delete mode 100644 api/Cargo.toml.template delete mode 100644 api/src/config.rs delete mode 100644 api/src/dto.rs delete mode 100644 api/src/error.rs delete mode 100644 api/src/extractors.rs delete mode 100644 api/src/main.rs delete mode 100644 api/src/routes/auth.rs delete mode 100644 api/src/routes/config.rs delete mode 100644 api/src/routes/mod.rs delete mode 100644 api/src/state.rs delete mode 100644 domain/Cargo.toml delete mode 100644 domain/src/entities.rs delete mode 100644 domain/src/errors.rs delete mode 100644 domain/src/lib.rs delete mode 100644 domain/src/repositories.rs delete mode 100644 domain/src/services.rs delete mode 100644 domain/src/value_objects.rs delete mode 100644 infra/Cargo.toml delete mode 100644 infra/Cargo.toml.template delete mode 100644 infra/src/auth/jwt.rs delete mode 100644 infra/src/auth/mod.rs delete mode 100644 infra/src/auth/oidc.rs delete mode 100644 infra/src/db.rs delete mode 100644 infra/src/factory.rs delete mode 100644 infra/src/lib.rs delete mode 100644 infra/src/user_repository.rs diff --git a/Cargo.toml b/Cargo.toml index 045eb21..11ba162 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 = "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" } diff --git a/Cargo.toml.liquid b/Cargo.toml.liquid new file mode 100644 index 0000000..452d4e8 --- /dev/null +++ b/Cargo.toml.liquid @@ -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" } diff --git a/api/Cargo.toml b/api/Cargo.toml deleted file mode 100644 index 4e12b83..0000000 --- a/api/Cargo.toml +++ /dev/null @@ -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" diff --git a/api/Cargo.toml.template b/api/Cargo.toml.template deleted file mode 100644 index f0820de..0000000 --- a/api/Cargo.toml.template +++ /dev/null @@ -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" diff --git a/api/src/config.rs b/api/src/config.rs deleted file mode 100644 index 870a759..0000000 --- a/api/src/config.rs +++ /dev/null @@ -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, - 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, - pub oidc_client_id: Option, - pub oidc_client_secret: Option, - pub oidc_redirect_url: Option, - pub oidc_resource_id: Option, - - // JWT configuration - pub jwt_secret: Option, - pub jwt_issuer: Option, - pub jwt_audience: Option, - 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, - } - } -} diff --git a/api/src/dto.rs b/api/src/dto.rs deleted file mode 100644 index 4723301..0000000 --- a/api/src/dto.rs +++ /dev/null @@ -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, -} - -/// 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, -} diff --git a/api/src/error.rs b/api/src/error.rs deleted file mode 100644 index 04ef4b0..0000000 --- a/api/src/error.rs +++ /dev/null @@ -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, -} - -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) -> Self { - Self::Validation(msg.into()) - } - - pub fn internal(msg: impl Into) -> Self { - Self::Internal(msg.into()) - } -} - -/// Result type alias for API handlers -pub type ApiResult = Result; diff --git a/api/src/extractors.rs b/api/src/extractors.rs deleted file mode 100644 index 0b25acf..0000000 --- a/api/src/extractors.rs +++ /dev/null @@ -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 for CurrentUser { - type Rejection = ApiError; - - async fn from_request_parts( - parts: &mut Parts, - state: &AppState, - ) -> Result { - #[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 { - 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) -} diff --git a/api/src/main.rs b/api/src/main.rs deleted file mode 100644 index 563dfeb..0000000 --- a/api/src/main.rs +++ /dev/null @@ -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(()) -} diff --git a/api/src/routes/auth.rs b/api/src/routes/auth.rs deleted file mode 100644 index 4bfbed5..0000000 --- a/api/src/routes/auth.rs +++ /dev/null @@ -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 { - 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, - Json(payload): Json, -) -> Result { - 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, - Json(payload): Json, -) -> Result { - 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 { - 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, - CurrentUser(user): CurrentUser, -) -> Result { - 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 { - 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 { - 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, - jar: axum_extra::extract::PrivateCookieJar, -) -> Result { - 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, - jar: axum_extra::extract::PrivateCookieJar, - axum::extract::Query(params): axum::extract::Query, -) -> Result { - 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, - }), - )) -} diff --git a/api/src/routes/config.rs b/api/src/routes/config.rs deleted file mode 100644 index 28278f8..0000000 --- a/api/src/routes/config.rs +++ /dev/null @@ -1,13 +0,0 @@ -use axum::{Json, Router, routing::get}; -use crate::dto::ConfigResponse; -use crate::state::AppState; - -pub fn router() -> Router { - Router::new().route("/", get(get_config)) -} - -async fn get_config() -> Json { - Json(ConfigResponse { - allow_registration: true, // Default to true for template - }) -} diff --git a/api/src/routes/mod.rs b/api/src/routes/mod.rs deleted file mode 100644 index b69f48b..0000000 --- a/api/src/routes/mod.rs +++ /dev/null @@ -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 { - Router::new() - .nest("/auth", auth::router()) - .nest("/config", config::router()) -} diff --git a/api/src/state.rs b/api/src/state.rs deleted file mode 100644 index ecdce4d..0000000 --- a/api/src/state.rs +++ /dev/null @@ -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, - pub cookie_key: Key, - #[cfg(feature = "auth-oidc")] - pub oidc_service: Option>, - #[cfg(feature = "auth-jwt")] - pub jwt_validator: Option>, - pub config: Arc, -} - -impl AppState { - pub async fn new(user_service: UserService, config: Config) -> anyhow::Result { - 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 for Arc { - fn from_ref(input: &AppState) -> Self { - input.user_service.clone() - } -} - -impl FromRef for Arc { - fn from_ref(input: &AppState) -> Self { - input.config.clone() - } -} - -impl FromRef for Key { - fn from_ref(input: &AppState) -> Self { - input.cookie_key.clone() - } -} diff --git a/domain/Cargo.toml b/domain/Cargo.toml deleted file mode 100644 index 82e7556..0000000 --- a/domain/Cargo.toml +++ /dev/null @@ -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"] } diff --git a/domain/src/entities.rs b/domain/src/entities.rs deleted file mode 100644 index 593fb57..0000000 --- a/domain/src/entities.rs +++ /dev/null @@ -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, - pub created_at: DateTime, -} - -impl User { - pub fn new(subject: impl Into, 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, - email: Email, - password_hash: Option, - created_at: DateTime, - ) -> Self { - Self { - id, - subject: subject.into(), - email, - password_hash, - created_at, - } - } - - pub fn new_local(email: Email, password_hash: impl Into) -> Self { - Self { - id: Uuid::new_v4(), - subject: format!("local|{}", Uuid::new_v4()), - email, - password_hash: Some(password_hash.into()), - created_at: Utc::now(), - } - } - -} diff --git a/domain/src/errors.rs b/domain/src/errors.rs deleted file mode 100644 index 1add194..0000000 --- a/domain/src/errors.rs +++ /dev/null @@ -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) -> Self { - Self::ValidationError(message.into()) - } - - /// Create an unauthenticated error (not logged in → 401) - pub fn unauthenticated(message: impl Into) -> Self { - Self::Unauthenticated(message.into()) - } - - /// Create a forbidden error (not allowed → 403) - pub fn forbidden(message: impl Into) -> 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 for DomainError { - fn from(error: crate::value_objects::ValidationError) -> Self { - DomainError::ValidationError(error.to_string()) - } -} - -/// Result type alias for domain operations -pub type DomainResult = Result; diff --git a/domain/src/lib.rs b/domain/src/lib.rs deleted file mode 100644 index 3c86fac..0000000 --- a/domain/src/lib.rs +++ /dev/null @@ -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::*; diff --git a/domain/src/repositories.rs b/domain/src/repositories.rs deleted file mode 100644 index 7b7c911..0000000 --- a/domain/src/repositories.rs +++ /dev/null @@ -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>; - - /// Find a user by their OIDC subject (used for authentication) - async fn find_by_subject(&self, subject: &str) -> DomainResult>; - - /// Find a user by their email - async fn find_by_email(&self, email: &str) -> DomainResult>; - - /// 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<()>; -} diff --git a/domain/src/services.rs b/domain/src/services.rs deleted file mode 100644 index e38077e..0000000 --- a/domain/src/services.rs +++ /dev/null @@ -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, -} - -impl UserService { - pub fn new(user_repository: Arc) -> Self { - Self { user_repository } - } - - pub async fn find_or_create(&self, subject: &str, email: &str) -> DomainResult { - // 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 { - self.user_repository - .find_by_id(id) - .await? - .ok_or(DomainError::UserNotFound(id)) - } - - pub async fn find_by_email(&self, email: &str) -> DomainResult> { - self.user_repository.find_by_email(email).await - } - - pub async fn create_local(&self, email: &str, password_hash: &str) -> DomainResult { - let email = Email::try_from(email)?; - let user = User::new_local(email, password_hash); - self.user_repository.save(&user).await?; - Ok(user) - } -} diff --git a/domain/src/value_objects.rs b/domain/src/value_objects.rs deleted file mode 100644 index 0cf4586..0000000 --- a/domain/src/value_objects.rs +++ /dev/null @@ -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) -> Result { - 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 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 for Email { - type Error = ValidationError; - - fn try_from(value: String) -> Result { - Self::new(value) - } -} - -impl TryFrom<&str> for Email { - type Error = ValidationError; - - fn try_from(value: &str) -> Result { - Self::new(value) - } -} - -impl Serialize for Email { - fn serialize(&self, serializer: S) -> Result { - serializer.serialize_str(self.0.as_ref()) - } -} - -impl<'de> Deserialize<'de> for Email { - fn deserialize>(deserializer: D) -> Result { - 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) -> Result { - 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 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 for Password { - type Error = ValidationError; - - fn try_from(value: String) -> Result { - Self::new(value) - } -} - -impl TryFrom<&str> for Password { - type Error = ValidationError; - - fn try_from(value: &str) -> Result { - Self::new(value) - } -} - -impl<'de> Deserialize<'de> for Password { - fn deserialize>(deserializer: D) -> Result { - 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) -> Result { - 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 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 for IssuerUrl { - type Error = ValidationError; - fn try_from(value: String) -> Result { - Self::new(value) - } -} - -impl From 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) -> Result { - let value = value.into().trim().to_string(); - if value.is_empty() { - return Err(ValidationError::Empty("client_id".to_string())); - } - Ok(Self(value)) - } -} - -impl AsRef 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 for ClientId { - type Error = ValidationError; - fn try_from(value: String) -> Result { - Self::new(value) - } -} - -impl From 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) -> 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 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>(deserializer: D) -> Result { - 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) -> Result { - 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 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 for RedirectUrl { - type Error = ValidationError; - fn try_from(value: String) -> Result { - Self::new(value) - } -} - -impl From 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) -> Result { - let value = value.into().trim().to_string(); - if value.is_empty() { - return Err(ValidationError::Empty("resource_id".to_string())); - } - Ok(Self(value)) - } -} - -impl AsRef 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 for ResourceId { - type Error = ValidationError; - fn try_from(value: String) -> Result { - Self::new(value) - } -} - -impl From 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) -> Self { - Self(value.into()) - } -} - -impl AsRef 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) -> Self { - Self(value.into()) - } -} - -impl AsRef 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) -> Self { - Self(value.into()) - } -} - -impl AsRef 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) -> Self { - Self(value.into()) - } -} - -impl AsRef 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>(deserializer: D) -> Result { - 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, is_production: bool) -> Result { - 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) -> Self { - Self(value.into()) - } -} - -impl AsRef 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")); - } - } -} diff --git a/infra/Cargo.toml b/infra/Cargo.toml deleted file mode 100644 index d2a6367..0000000 --- a/infra/Cargo.toml +++ /dev/null @@ -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 } diff --git a/infra/Cargo.toml.template b/infra/Cargo.toml.template deleted file mode 100644 index f8cbc1b..0000000 --- a/infra/Cargo.toml.template +++ /dev/null @@ -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 } diff --git a/infra/src/auth/jwt.rs b/infra/src/auth/jwt.rs deleted file mode 100644 index 6fb54b5..0000000 --- a/infra/src/auth/jwt.rs +++ /dev/null @@ -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, - /// Expected audience (for validation) - pub audience: Option, - /// 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, - audience: Option, - expiry_hours: Option, - is_production: bool, - ) -> Result { - // 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, - /// Audience - #[serde(skip_serializing_if = "Option::is_none")] - pub aud: Option, -} - -/// 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 { - 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 { - let token_data = decode::(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 { - let mut validation = Validation::new(Algorithm::HS256); - validation.insecure_disable_signature_validation(); - validation.validate_exp = false; - - let token_data = decode::(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()); - } -} diff --git a/infra/src/auth/mod.rs b/infra/src/auth/mod.rs deleted file mode 100644 index c2ef8ed..0000000 --- a/infra/src/auth/mod.rs +++ /dev/null @@ -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; diff --git a/infra/src/auth/oidc.rs b/infra/src/auth/oidc.rs deleted file mode 100644 index 6dcfdec..0000000 --- a/infra/src/auth/oidc.rs +++ /dev/null @@ -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, - 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, -} - -#[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, - redirect_url: RedirectUrl, - resource_id: Option, - ) -> anyhow::Result { - 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 { - 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 = 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, - }) - } -} diff --git a/infra/src/db.rs b/infra/src/db.rs deleted file mode 100644 index def2d5d..0000000 --- a/infra/src/db.rs +++ /dev/null @@ -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(()) -} diff --git a/infra/src/factory.rs b/infra/src/factory.rs deleted file mode 100644 index 7d3b4c1..0000000 --- a/infra/src/factory.rs +++ /dev/null @@ -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 = Result; - -pub async fn build_user_repository(pool: &DatabasePool) -> FactoryResult> { - 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(), - )), - } -} diff --git a/infra/src/lib.rs b/infra/src/lib.rs deleted file mode 100644 index 12b4453..0000000 --- a/infra/src/lib.rs +++ /dev/null @@ -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; diff --git a/infra/src/user_repository.rs b/infra/src/user_repository.rs deleted file mode 100644 index 4038adb..0000000 --- a/infra/src/user_repository.rs +++ /dev/null @@ -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, - created_at: String, -} - -impl TryFrom for User { - type Error = DomainError; - - fn try_from(row: UserRow) -> Result { - 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> { - let id_str = id.to_string(); - let row: Option = 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> { - let row: Option = 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> { - let row: Option = 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, -} - -#[cfg(feature = "postgres")] -impl PostgresUserRepository { - pub fn new(pool: sqlx::Pool) -> Self { - Self { pool } - } -} - -#[cfg(feature = "postgres")] -#[async_trait] -impl UserRepository for PostgresUserRepository { - async fn find_by_id(&self, id: Uuid) -> DomainResult> { - let id_str = id.to_string(); - let row: Option = 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> { - let row: Option = 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> { - let row: Option = 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(()) - } -}