feat: JWT auth, /api prefix, SPA serving, OpenAPI, lean main.rs

- auth: register/login/refresh/logout w/ JWT+Argon2, protected mutations
- domain: User, RefreshSession, auth ports, Unauthorized/Forbidden errors
- presentation: context/state/factory/errors/extractors/openapi modules
- routes behind /api, SPA served from root w/ fallback
- OpenAPI Scalar at /docs
- frontend ssr:false, single-binary Dockerfile
This commit is contained in:
2026-07-11 21:28:52 +02:00
parent 13031347cc
commit 7bd27d9b9c
50 changed files with 1604 additions and 213 deletions

View File

@@ -0,0 +1,14 @@
[package]
name = "auth"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
uuid = { workspace = true }
async-trait = { workspace = true }
chrono = { version = "0.4", features = ["serde"] }
jsonwebtoken = "9"
argon2 = { version = "0.5", features = ["std"] }
rand_core = { version = "0.6", features = ["getrandom"] }
serde = { workspace = true }

View File

@@ -0,0 +1,100 @@
use std::sync::Arc;
use argon2::password_hash::SaltString;
use argon2::{Argon2, PasswordHash, PasswordHasher as ArgonHasher, PasswordVerifier};
use async_trait::async_trait;
use chrono::{Duration, Utc};
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
use rand_core::OsRng;
use serde::{Deserialize, Serialize};
use domain::errors::DomainError;
use domain::models::GeneratedToken;
use domain::value_objects::UserId;
pub struct JwtAuthService {
encoding_key: EncodingKey,
decoding_key: DecodingKey,
ttl_seconds: i64,
}
#[derive(Serialize, Deserialize)]
struct Claims {
sub: String,
exp: usize,
}
impl JwtAuthService {
pub fn new(secret: &str, ttl_seconds: u64) -> Self {
Self {
encoding_key: EncodingKey::from_secret(secret.as_bytes()),
decoding_key: DecodingKey::from_secret(secret.as_bytes()),
ttl_seconds: ttl_seconds as i64,
}
}
}
#[async_trait]
impl domain::ports::AuthService for JwtAuthService {
async fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError> {
let expires_at = Utc::now() + Duration::seconds(self.ttl_seconds);
let claims = Claims {
sub: user_id.value().to_string(),
exp: expires_at.timestamp() as usize,
};
let token = jsonwebtoken::encode(&Header::default(), &claims, &self.encoding_key)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(GeneratedToken { token, expires_at })
}
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError> {
let data =
jsonwebtoken::decode::<Claims>(token, &self.decoding_key, &Validation::default())
.map_err(|_| DomainError::Unauthorized("invalid or expired token".into()))?;
let uuid = uuid::Uuid::parse_str(&data.claims.sub)
.map_err(|_| DomainError::Unauthorized("invalid token subject".into()))?;
Ok(UserId::from_uuid(uuid))
}
}
pub struct Argon2PasswordHasher;
#[async_trait]
impl domain::ports::PasswordHasher for Argon2PasswordHasher {
async fn hash(
&self,
plain_password: &str,
) -> Result<domain::value_objects::PasswordHash, DomainError> {
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(plain_password.as_bytes(), &salt)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.to_string();
domain::value_objects::PasswordHash::new(hash)
}
async fn verify(
&self,
plain_password: &str,
hash: &domain::value_objects::PasswordHash,
) -> Result<bool, DomainError> {
let parsed = PasswordHash::new(hash.value())
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(Argon2::default()
.verify_password(plain_password.as_bytes(), &parsed)
.is_ok())
}
}
pub fn create(
secret: &str,
ttl_seconds: u64,
) -> (
Arc<dyn domain::ports::AuthService>,
Arc<dyn domain::ports::PasswordHasher>,
) {
(
Arc::new(JwtAuthService::new(secret, ttl_seconds)),
Arc::new(Argon2PasswordHasher),
)
}