Compare commits
7 Commits
c10b850982
...
1c5ae5d239
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c5ae5d239 | |||
| 15e81c88d1 | |||
| 5d926e0f61 | |||
| 4cab050ee8 | |||
| 531b8f6eae | |||
| ed5e238a9c | |||
| fa867a837f |
1781
Cargo.lock
generated
1781
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
38
Cargo.toml
38
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 = "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
39
Cargo.toml.liquid
Normal 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" }
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
126
api/src/error.rs
126
api/src/error.rs
@@ -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>;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
))
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
116
api/src/state.rs
116
api/src/state.rs
@@ -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()
|
||||
}
|
||||
}
|
||||
15
crates/adapters/auth/Cargo.toml
Normal file
15
crates/adapters/auth/Cargo.toml
Normal 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 }
|
||||
74
crates/adapters/auth/src/jwt.rs
Normal file
74
crates/adapters/auth/src/jwt.rs
Normal 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(_))));
|
||||
}
|
||||
}
|
||||
7
crates/adapters/auth/src/lib.rs
Normal file
7
crates/adapters/auth/src/lib.rs
Normal 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;
|
||||
10
crates/adapters/auth/src/oidc.rs
Normal file
10
crates/adapters/auth/src/oidc.rs
Normal 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() }
|
||||
}
|
||||
38
crates/adapters/auth/src/password.rs
Normal file
38
crates/adapters/auth/src/password.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
12
crates/adapters/postgres/Cargo.toml
Normal file
12
crates/adapters/postgres/Cargo.toml
Normal 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 }
|
||||
7
crates/adapters/postgres/migrations/001_init_users.sql
Normal file
7
crates/adapters/postgres/migrations/001_init_users.sql
Normal 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()
|
||||
);
|
||||
14
crates/adapters/postgres/src/db.rs
Normal file
14
crates/adapters/postgres/src/db.rs
Normal 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(())
|
||||
}
|
||||
5
crates/adapters/postgres/src/lib.rs
Normal file
5
crates/adapters/postgres/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod db;
|
||||
pub mod user_repository;
|
||||
|
||||
pub use db::{connect, run_migrations, PgPool};
|
||||
pub use user_repository::PostgresUserRepository;
|
||||
86
crates/adapters/postgres/src/user_repository.rs
Normal file
86
crates/adapters/postgres/src/user_repository.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM users WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "73ffdf5be39aa5c4c160c2f77d6634a6970eeb4e1d3395f045ded747f0ce9d2a"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
12
crates/adapters/sqlite/Cargo.toml
Normal file
12
crates/adapters/sqlite/Cargo.toml
Normal 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 }
|
||||
7
crates/adapters/sqlite/migrations/001_init_users.sql
Normal file
7
crates/adapters/sqlite/migrations/001_init_users.sql
Normal 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
|
||||
);
|
||||
14
crates/adapters/sqlite/src/db.rs
Normal file
14
crates/adapters/sqlite/src/db.rs
Normal 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(())
|
||||
}
|
||||
5
crates/adapters/sqlite/src/lib.rs
Normal file
5
crates/adapters/sqlite/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod db;
|
||||
pub mod user_repository;
|
||||
|
||||
pub use db::{connect, run_migrations, SqlitePool};
|
||||
pub use user_repository::SqliteUserRepository;
|
||||
95
crates/adapters/sqlite/src/user_repository.rs
Normal file
95
crates/adapters/sqlite/src/user_repository.rs
Normal 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 })
|
||||
}
|
||||
11
crates/api-types/Cargo.toml
Normal file
11
crates/api-types/Cargo.toml
Normal 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 }
|
||||
2
crates/api-types/src/lib.rs
Normal file
2
crates/api-types/src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod requests;
|
||||
pub mod responses;
|
||||
11
crates/api-types/src/requests.rs
Normal file
11
crates/api-types/src/requests.rs
Normal 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,
|
||||
}
|
||||
27
crates/api-types/src/responses.rs
Normal file
27
crates/api-types/src/responses.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
12
crates/application/Cargo.toml
Normal file
12
crates/application/Cargo.toml
Normal 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 }
|
||||
2
crates/application/src/lib.rs
Normal file
2
crates/application/src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod testing;
|
||||
pub mod use_cases;
|
||||
79
crates/application/src/testing.rs
Normal file
79
crates/application/src/testing.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
40
crates/application/src/use_cases/get_profile.rs
Normal file
40
crates/application/src/use_cases/get_profile.rs
Normal 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(_))));
|
||||
}
|
||||
}
|
||||
74
crates/application/src/use_cases/login.rs
Normal file
74
crates/application/src/use_cases/login.rs
Normal 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(_))));
|
||||
}
|
||||
}
|
||||
7
crates/application/src/use_cases/mod.rs
Normal file
7
crates/application/src/use_cases/mod.rs
Normal 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;
|
||||
72
crates/application/src/use_cases/register.rs
Normal file
72
crates/application/src/use_cases/register.rs
Normal 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(_))));
|
||||
}
|
||||
}
|
||||
22
crates/bootstrap/Cargo.toml
Normal file
22
crates/bootstrap/Cargo.toml
Normal 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 }
|
||||
28
crates/bootstrap/src/config.rs
Normal file
28
crates/bootstrap/src/config.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
43
crates/bootstrap/src/factory.rs
Normal file
43
crates/bootstrap/src/factory.rs
Normal 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))
|
||||
}
|
||||
0
crates/bootstrap/src/lib.rs
Normal file
0
crates/bootstrap/src/lib.rs
Normal file
28
crates/bootstrap/src/main.rs
Normal file
28
crates/bootstrap/src/main.rs
Normal 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
11
crates/domain/Cargo.toml
Normal 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 }
|
||||
2
crates/domain/src/entities/mod.rs
Normal file
2
crates/domain/src/entities/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
mod user;
|
||||
pub use user::User;
|
||||
17
crates/domain/src/entities/user.rs
Normal file
17
crates/domain/src/entities/user.rs
Normal 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() }
|
||||
}
|
||||
}
|
||||
13
crates/domain/src/errors.rs
Normal file
13
crates/domain/src/errors.rs
Normal 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),
|
||||
}
|
||||
7
crates/domain/src/events.rs
Normal file
7
crates/domain/src/events.rs
Normal 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
5
crates/domain/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod entities;
|
||||
pub mod errors;
|
||||
pub mod events;
|
||||
pub mod ports;
|
||||
pub mod value_objects;
|
||||
14
crates/domain/src/ports/auth.rs
Normal file
14
crates/domain/src/ports/auth.rs
Normal 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>;
|
||||
}
|
||||
5
crates/domain/src/ports/mod.rs
Normal file
5
crates/domain/src/ports/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod auth;
|
||||
mod user_repo;
|
||||
|
||||
pub use auth::{PasswordHasher, TokenIssuer};
|
||||
pub use user_repo::UserRepository;
|
||||
10
crates/domain/src/ports/user_repo.rs
Normal file
10
crates/domain/src/ports/user_repo.rs
Normal 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>;
|
||||
}
|
||||
42
crates/domain/src/value_objects/email.rs
Normal file
42
crates/domain/src/value_objects/email.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
9
crates/domain/src/value_objects/mod.rs
Normal file
9
crates/domain/src/value_objects/mod.rs
Normal 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;
|
||||
14
crates/domain/src/value_objects/password.rs
Normal file
14
crates/domain/src/value_objects/password.rs
Normal 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 }
|
||||
}
|
||||
23
crates/domain/src/value_objects/role.rs
Normal file
23
crates/domain/src/value_objects/role.rs
Normal 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}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
22
crates/domain/src/value_objects/user_id.rs
Normal file
22
crates/domain/src/value_objects/user_id.rs
Normal 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) }
|
||||
}
|
||||
19
crates/presentation/Cargo.toml
Normal file
19
crates/presentation/Cargo.toml
Normal 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 }
|
||||
25
crates/presentation/src/errors.rs
Normal file
25
crates/presentation/src/errors.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
38
crates/presentation/src/extractors/auth.rs
Normal file
38
crates/presentation/src/extractors/auth.rs
Normal 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 })
|
||||
}
|
||||
}
|
||||
28
crates/presentation/src/extractors/json.rs
Normal file
28
crates/presentation/src/extractors/json.rs
Normal 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()
|
||||
})
|
||||
}
|
||||
}
|
||||
5
crates/presentation/src/extractors/mod.rs
Normal file
5
crates/presentation/src/extractors/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod auth;
|
||||
pub mod json;
|
||||
|
||||
pub use auth::JwtClaims;
|
||||
pub use json::ValidatedJson;
|
||||
56
crates/presentation/src/handlers/auth.rs
Normal file
56
crates/presentation/src/handlers/auth.rs
Normal 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)))
|
||||
}
|
||||
7
crates/presentation/src/handlers/health.rs
Normal file
7
crates/presentation/src/handlers/health.rs
Normal 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" })))
|
||||
}
|
||||
2
crates/presentation/src/handlers/mod.rs
Normal file
2
crates/presentation/src/handlers/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod auth;
|
||||
pub mod health;
|
||||
6
crates/presentation/src/lib.rs
Normal file
6
crates/presentation/src/lib.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod errors;
|
||||
pub mod extractors;
|
||||
pub mod handlers;
|
||||
pub mod openapi;
|
||||
pub mod routes;
|
||||
pub mod state;
|
||||
41
crates/presentation/src/openapi/mod.rs
Normal file
41
crates/presentation/src/openapi/mod.rs
Normal 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()) }))
|
||||
}
|
||||
16
crates/presentation/src/routes.rs
Normal file
16
crates/presentation/src/routes.rs
Normal 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())
|
||||
}
|
||||
22
crates/presentation/src/state.rs
Normal file
22
crates/presentation/src/state.rs
Normal 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 }
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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::*;
|
||||
@@ -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<()>;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user