chore: strip old structure, scaffold new hexagonal workspace
This commit is contained in:
38
Cargo.toml
38
Cargo.toml
@@ -1,3 +1,39 @@
|
|||||||
[workspace]
|
[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"
|
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" }
|
||||||
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user