feat: v2 rewrite — hexagonal arch, ActivityPub federation, NATS, deployment-ready (#1)
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled

This commit was merged in pull request #1.
This commit is contained in:
2026-05-16 09:42:40 +00:00
parent 071809bc3f
commit 9aee4ceb6d
224 changed files with 35418 additions and 1469 deletions

View File

@@ -0,0 +1,19 @@
[package]
name = "auth"
version = "0.1.0"
edition = "2021"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
thiserror = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
jsonwebtoken = "9"
argon2 = "0.5"
bcrypt = "0.15"
rand = "0.8"
sha2 = "0.10"
hex = "0.4"

View File

@@ -0,0 +1,89 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
ports::{ApiKeyRepository, ApiKeyService},
value_objects::UserId,
};
use sha2::{Digest, Sha256};
use std::sync::Arc;
pub struct ApiKeyServiceImpl {
repo: Arc<dyn ApiKeyRepository>,
}
impl ApiKeyServiceImpl {
pub fn new(repo: Arc<dyn ApiKeyRepository>) -> Self {
Self { repo }
}
fn hash(raw: &str) -> String {
hex::encode(Sha256::digest(raw.as_bytes()))
}
}
#[async_trait]
impl ApiKeyService for ApiKeyServiceImpl {
async fn validate_key(&self, raw_key: &str) -> Result<Option<UserId>, DomainError> {
let hash = Self::hash(raw_key);
Ok(self.repo.find_by_hash(&hash).await?.map(|k| k.user_id))
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use chrono::Utc;
use domain::{
errors::DomainError,
models::api_key::ApiKey,
ports::ApiKeyRepository,
value_objects::{ApiKeyId, UserId},
};
use std::sync::{Arc, Mutex};
struct FakeApiKeyRepo(Mutex<Vec<ApiKey>>);
#[async_trait]
impl ApiKeyRepository for FakeApiKeyRepo {
async fn save(&self, key: &ApiKey) -> Result<(), DomainError> {
self.0.lock().unwrap().push(key.clone());
Ok(())
}
async fn find_by_hash(&self, hash: &str) -> Result<Option<ApiKey>, DomainError> {
Ok(self.0.lock().unwrap().iter().find(|k| k.key_hash == hash).cloned())
}
async fn list_for_user(&self, _uid: &UserId) -> Result<Vec<ApiKey>, DomainError> {
Ok(vec![])
}
async fn delete(&self, _id: &ApiKeyId, _uid: &UserId) -> Result<(), DomainError> {
Ok(())
}
}
#[tokio::test]
async fn validate_known_key_returns_user_id() {
let uid = UserId::new();
let raw = "super-secret-key";
let hash = ApiKeyServiceImpl::hash(raw);
let key = ApiKey {
id: ApiKeyId::new(),
user_id: uid.clone(),
key_hash: hash,
name: "test".into(),
created_at: Utc::now(),
};
let repo = Arc::new(FakeApiKeyRepo(Mutex::new(vec![key])));
let svc = ApiKeyServiceImpl::new(repo);
let result = svc.validate_key(raw).await.unwrap();
assert_eq!(result.unwrap().as_uuid(), uid.as_uuid());
}
#[tokio::test]
async fn validate_unknown_key_returns_none() {
let repo = Arc::new(FakeApiKeyRepo(Mutex::new(vec![])));
let svc = ApiKeyServiceImpl::new(repo);
let result = svc.validate_key("unknown-key").await.unwrap();
assert!(result.is_none());
}
}

View File

@@ -0,0 +1,123 @@
mod api_key_service;
use async_trait::async_trait;
use chrono::{Duration, Utc};
use domain::{
errors::DomainError,
ports::{AuthService, GeneratedToken, PasswordHasher},
value_objects::{PasswordHash, UserId},
};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
pub use api_key_service::ApiKeyServiceImpl;
#[derive(Serialize, Deserialize)]
struct Claims {
sub: String,
exp: usize,
}
pub struct JwtAuthService {
secret: String,
ttl_seconds: i64,
}
impl JwtAuthService {
pub fn new(secret: String, ttl_seconds: i64) -> Self {
Self {
secret,
ttl_seconds,
}
}
}
impl AuthService for JwtAuthService {
fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError> {
let exp = (Utc::now() + Duration::seconds(self.ttl_seconds)).timestamp() as usize;
let claims = Claims {
sub: user_id.as_uuid().to_string(),
exp,
};
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(self.secret.as_bytes()),
)
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(GeneratedToken {
token,
user_id: user_id.clone(),
})
}
fn validate_token(&self, token: &str) -> Result<UserId, DomainError> {
let data = decode::<Claims>(
token,
&DecodingKey::from_secret(self.secret.as_bytes()),
&Validation::default(),
)
.map_err(|_| DomainError::Unauthorized)?;
let uuid =
uuid::Uuid::parse_str(&data.claims.sub).map_err(|_| DomainError::Unauthorized)?;
Ok(UserId::from_uuid(uuid))
}
}
pub struct Argon2PasswordHasher;
#[async_trait]
impl PasswordHasher for Argon2PasswordHasher {
async fn hash(&self, plain: &str) -> Result<PasswordHash, DomainError> {
use argon2::{password_hash::SaltString, Argon2, PasswordHasher as _};
use rand::rngs::OsRng;
let salt = SaltString::generate(OsRng);
let hash = Argon2::default()
.hash_password(plain.as_bytes(), &salt)
.map_err(|e| DomainError::Internal(e.to_string()))?
.to_string();
Ok(PasswordHash(hash))
}
async fn verify(&self, plain: &str, hash: &PasswordHash) -> Result<bool, DomainError> {
if hash.0.starts_with("$2") {
return bcrypt::verify(plain, &hash.0)
.map_err(|e| DomainError::Internal(e.to_string()));
}
use argon2::{password_hash::PasswordHash as ArgonHash, Argon2, PasswordVerifier};
let parsed = ArgonHash::new(&hash.0).map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(Argon2::default()
.verify_password(plain.as_bytes(), &parsed)
.is_ok())
}
}
#[cfg(test)]
mod tests {
use super::*;
use domain::ports::AuthService;
#[test]
fn generate_and_validate_token() {
let svc = JwtAuthService::new("a-secret-that-is-at-least-32-bytes!!".into(), 3600);
let id = UserId::new();
let tok = svc.generate_token(&id).unwrap();
let parsed = svc.validate_token(&tok.token).unwrap();
assert_eq!(parsed.as_uuid(), id.as_uuid());
}
#[test]
fn invalid_token_returns_unauthorized() {
let svc = JwtAuthService::new("a-secret-that-is-at-least-32-bytes!!".into(), 3600);
let err = svc.validate_token("not.a.token").unwrap_err();
assert!(matches!(err, DomainError::Unauthorized));
}
#[tokio::test]
async fn hash_and_verify() {
let hasher = Argon2PasswordHasher;
let hash = hasher.hash("mypassword").await.unwrap();
assert!(hasher.verify("mypassword", &hash).await.unwrap());
assert!(!hasher.verify("wrongpassword", &hash).await.unwrap());
}
}