refactor (v2): better arch
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
27
crates/adapters/auth/Cargo.toml
Normal file
27
crates/adapters/auth/Cargo.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "auth"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
jwt = ["dep:jsonwebtoken"]
|
||||
oidc = ["dep:openidconnect", "dep:reqwest"]
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
argon2 = "0.5"
|
||||
url = "2"
|
||||
|
||||
jsonwebtoken = { version = "10", features = ["rust_crypto"], optional = true }
|
||||
openidconnect = { version = "4", optional = true }
|
||||
reqwest = { version = "0.12", features = ["json"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
30
crates/adapters/auth/src/config.rs
Normal file
30
crates/adapters/auth/src/config.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
/// Config for OIDC. Validated when constructing OidcService.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OidcConfig {
|
||||
pub issuer_url: String,
|
||||
pub client_id: String,
|
||||
pub client_secret: Option<String>,
|
||||
pub redirect_url: String,
|
||||
/// Optional audience / resource ID for token validation.
|
||||
pub resource_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Config for JWT. Validated when constructing JwtValidator.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JwtConfig {
|
||||
pub secret: String,
|
||||
pub issuer: Option<String>,
|
||||
pub audience: Option<String>,
|
||||
pub expiry_hours: u64,
|
||||
}
|
||||
|
||||
impl JwtConfig {
|
||||
pub fn new(secret: impl Into<String>) -> Self {
|
||||
Self {
|
||||
secret: secret.into(),
|
||||
issuer: None,
|
||||
audience: None,
|
||||
expiry_hours: 24,
|
||||
}
|
||||
}
|
||||
}
|
||||
100
crates/adapters/auth/src/jwt.rs
Normal file
100
crates/adapters/auth/src/jwt.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use thiserror::Error;
|
||||
|
||||
use domain::user::entity::User;
|
||||
|
||||
use crate::config::JwtConfig;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct JwtClaims {
|
||||
pub sub: String,
|
||||
pub email: String,
|
||||
pub exp: usize,
|
||||
pub iat: usize,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub iss: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aud: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum JwtError {
|
||||
#[error("token creation failed: {0}")]
|
||||
Creation(#[from] jsonwebtoken::errors::Error),
|
||||
#[error("token expired")]
|
||||
Expired,
|
||||
#[error("invalid token: {0}")]
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
pub struct JwtValidator {
|
||||
config: JwtConfig,
|
||||
encoding_key: EncodingKey,
|
||||
decoding_key: DecodingKey,
|
||||
validation: Validation,
|
||||
}
|
||||
|
||||
impl JwtValidator {
|
||||
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);
|
||||
if let Some(ref iss) = config.issuer {
|
||||
validation.set_issuer(&[iss]);
|
||||
}
|
||||
if let Some(ref aud) = config.audience {
|
||||
validation.set_audience(&[aud]);
|
||||
}
|
||||
|
||||
Self {
|
||||
config,
|
||||
encoding_key,
|
||||
decoding_key,
|
||||
validation,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_token(&self, user: &User) -> Result<String, JwtError> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock before epoch")
|
||||
.as_secs() as usize;
|
||||
|
||||
let claims = JwtClaims {
|
||||
sub: user.id.as_uuid().to_string(),
|
||||
email: user.email.as_ref().to_string(),
|
||||
exp: now + self.config.expiry_hours as usize * 3600,
|
||||
iat: now,
|
||||
iss: self.config.issuer.clone(),
|
||||
aud: self.config.audience.clone(),
|
||||
};
|
||||
|
||||
encode(&Header::new(Algorithm::HS256), &claims, &self.encoding_key)
|
||||
.map_err(JwtError::Creation)
|
||||
}
|
||||
|
||||
pub fn validate_token(&self, token: &str) -> Result<JwtClaims, JwtError> {
|
||||
decode::<JwtClaims>(token, &self.decoding_key, &self.validation)
|
||||
.map(|td| td.claims)
|
||||
.map_err(|e| match e.kind() {
|
||||
jsonwebtoken::errors::ErrorKind::ExpiredSignature => JwtError::Expired,
|
||||
_ => JwtError::Invalid(e.to_string()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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("expiry_hours", &self.config.expiry_hours)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/jwt.rs"]
|
||||
mod tests;
|
||||
8
crates/adapters/auth/src/lib.rs
Normal file
8
crates/adapters/auth/src/lib.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
pub mod config;
|
||||
pub mod password;
|
||||
|
||||
#[cfg(feature = "jwt")]
|
||||
pub mod jwt;
|
||||
|
||||
#[cfg(feature = "oidc")]
|
||||
pub mod oidc;
|
||||
178
crates/adapters/auth/src/oidc.rs
Normal file
178
crates/adapters/auth/src/oidc.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
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};
|
||||
|
||||
use crate::config::OidcConfig;
|
||||
|
||||
pub type OidcClient = Client<
|
||||
EmptyAdditionalClaims,
|
||||
CoreAuthDisplay,
|
||||
CoreGenderClaim,
|
||||
CoreJweContentEncryptionAlgorithm,
|
||||
CoreJsonWebKey,
|
||||
CoreAuthPrompt,
|
||||
StandardErrorResponse<CoreErrorResponseType>,
|
||||
CoreTokenResponse,
|
||||
CoreTokenIntrospectionResponse,
|
||||
CoreRevocableToken,
|
||||
CoreRevocationErrorResponse,
|
||||
EndpointSet,
|
||||
EndpointNotSet,
|
||||
EndpointNotSet,
|
||||
EndpointNotSet,
|
||||
EndpointMaybeSet,
|
||||
EndpointMaybeSet,
|
||||
>;
|
||||
|
||||
/// Data returned when starting the OIDC authorization flow.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthorizationUrlData {
|
||||
pub url: url::Url,
|
||||
pub csrf_token: String,
|
||||
pub nonce: String,
|
||||
pub pkce_verifier: String,
|
||||
}
|
||||
|
||||
/// Verified identity returned after a successful callback.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OidcUser {
|
||||
pub subject: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct OidcService {
|
||||
client: OidcClient,
|
||||
resource_id: Option<String>,
|
||||
}
|
||||
|
||||
impl OidcService {
|
||||
pub async fn new(config: OidcConfig) -> Result<Self> {
|
||||
let http_client = reqwest::ClientBuilder::new()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()?;
|
||||
|
||||
let provider_metadata = CoreProviderMetadata::discover_async(
|
||||
openidconnect::IssuerUrl::new(config.issuer_url)?,
|
||||
&http_client,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let client_secret = config
|
||||
.client_secret
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.map(openidconnect::ClientSecret::new);
|
||||
|
||||
let client = CoreClient::from_provider_metadata(
|
||||
provider_metadata,
|
||||
openidconnect::ClientId::new(config.client_id),
|
||||
client_secret,
|
||||
)
|
||||
.set_redirect_uri(openidconnect::RedirectUrl::new(config.redirect_url)?);
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
resource_id: config.resource_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn authorization_url(&self) -> AuthorizationUrlData {
|
||||
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||
|
||||
let (url, csrf_token, nonce) = self
|
||||
.client
|
||||
.authorize_url(
|
||||
CoreAuthenticationFlow::AuthorizationCode,
|
||||
openidconnect::CsrfToken::new_random,
|
||||
openidconnect::Nonce::new_random,
|
||||
)
|
||||
.add_scope(Scope::new("profile".into()))
|
||||
.add_scope(Scope::new("email".into()))
|
||||
.set_pkce_challenge(pkce_challenge)
|
||||
.url();
|
||||
|
||||
AuthorizationUrlData {
|
||||
url: url.into(),
|
||||
csrf_token: csrf_token.secret().clone(),
|
||||
nonce: nonce.secret().clone(),
|
||||
pkce_verifier: pkce_verifier.secret().clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn exchange_code(
|
||||
&self,
|
||||
code: &str,
|
||||
nonce: &str,
|
||||
pkce_verifier: &str,
|
||||
) -> Result<OidcUser> {
|
||||
let http_client = reqwest::ClientBuilder::new()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()?;
|
||||
|
||||
let token_response = self
|
||||
.client
|
||||
.exchange_code(openidconnect::AuthorizationCode::new(code.to_owned()))?
|
||||
.set_pkce_verifier(openidconnect::PkceCodeVerifier::new(
|
||||
pkce_verifier.to_owned(),
|
||||
))
|
||||
.request_async(&http_client)
|
||||
.await?;
|
||||
|
||||
let id_token = token_response
|
||||
.id_token()
|
||||
.ok_or_else(|| anyhow!("server did not return an ID token"))?;
|
||||
|
||||
let mut verifier = self.client.id_token_verifier().clone();
|
||||
if let Some(ref rid) = self.resource_id {
|
||||
let rid = rid.clone();
|
||||
verifier =
|
||||
verifier.set_other_audience_verifier_fn(move |aud| aud.as_str() == rid.as_str());
|
||||
}
|
||||
|
||||
let oidc_nonce = openidconnect::Nonce::new(nonce.to_owned());
|
||||
let claims = id_token.claims(&verifier, &oidc_nonce)?;
|
||||
|
||||
if let Some(expected_hash) = claims.access_token_hash() {
|
||||
let actual_hash = AccessTokenHash::from_token(
|
||||
token_response.access_token(),
|
||||
id_token.signing_alg()?,
|
||||
id_token.signing_key(&verifier)?,
|
||||
)?;
|
||||
if actual_hash != *expected_hash {
|
||||
return Err(anyhow!("access token hash mismatch"));
|
||||
}
|
||||
}
|
||||
|
||||
let email = match claims.email() {
|
||||
Some(e) => e.as_str().to_owned(),
|
||||
None => {
|
||||
tracing::debug!("email absent in ID token, fetching userinfo");
|
||||
let userinfo: UserInfoClaims<EmptyAdditionalClaims, CoreGenderClaim> = self
|
||||
.client
|
||||
.user_info(token_response.access_token().clone(), None)?
|
||||
.request_async(&http_client)
|
||||
.await?;
|
||||
userinfo
|
||||
.email()
|
||||
.map(|e| e.as_str().to_owned())
|
||||
.ok_or_else(|| anyhow!("no verified email in identity provider response"))?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(OidcUser {
|
||||
subject: claims.subject().to_string(),
|
||||
email,
|
||||
})
|
||||
}
|
||||
}
|
||||
51
crates/adapters/auth/src/password.rs
Normal file
51
crates/adapters/auth/src/password.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use argon2::{
|
||||
Argon2,
|
||||
password_hash::{
|
||||
PasswordHash, PasswordHasher as _, PasswordVerifier, SaltString, rand_core::OsRng,
|
||||
},
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
|
||||
use domain::{
|
||||
errors::{DomainError, DomainResult},
|
||||
user::{
|
||||
ports::PasswordHasher,
|
||||
value_objects::{Password, PasswordHash as DomainPasswordHash},
|
||||
},
|
||||
};
|
||||
|
||||
pub struct Argon2PasswordHasher;
|
||||
|
||||
#[async_trait]
|
||||
impl PasswordHasher for Argon2PasswordHasher {
|
||||
async fn hash(&self, password: &Password) -> DomainResult<DomainPasswordHash> {
|
||||
let password_str = password.as_ref().to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let hash = Argon2::default()
|
||||
.hash_password(password_str.as_bytes(), &salt)
|
||||
.map_err(|e| DomainError::Infrastructure(format!("hash failed: {e}")))?;
|
||||
Ok(DomainPasswordHash::new(hash.to_string()))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Infrastructure(format!("task panicked: {e}")))?
|
||||
}
|
||||
|
||||
async fn verify(&self, password: &Password, hash: &DomainPasswordHash) -> DomainResult<bool> {
|
||||
let password_str = password.as_ref().to_owned();
|
||||
let hash_str = hash.as_str().to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let parsed = PasswordHash::new(&hash_str)
|
||||
.map_err(|e| DomainError::Infrastructure(format!("invalid hash: {e}")))?;
|
||||
Ok(Argon2::default()
|
||||
.verify_password(password_str.as_bytes(), &parsed)
|
||||
.is_ok())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Infrastructure(format!("task panicked: {e}")))?
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/password.rs"]
|
||||
mod tests;
|
||||
68
crates/adapters/auth/src/tests/jwt.rs
Normal file
68
crates/adapters/auth/src/tests/jwt.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use domain::user::{entity::User, value_objects::Email};
|
||||
|
||||
use crate::{config::JwtConfig, jwt::JwtValidator};
|
||||
|
||||
fn validator() -> JwtValidator {
|
||||
JwtValidator::new(JwtConfig::new(
|
||||
"a-test-secret-that-is-long-enough-for-hs256",
|
||||
))
|
||||
}
|
||||
|
||||
fn user() -> User {
|
||||
User::new_oidc("sub|123", Email::new("test@example.com").unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_and_validate_round_trip() {
|
||||
let v = validator();
|
||||
let u = user();
|
||||
let token = v.create_token(&u).unwrap();
|
||||
let claims = v.validate_token(&token).unwrap();
|
||||
|
||||
assert_eq!(claims.email, "test@example.com");
|
||||
assert_eq!(claims.sub, u.id.as_uuid().to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_secret_rejects_token() {
|
||||
let v1 = JwtValidator::new(JwtConfig::new(
|
||||
"secret-one-long-enough-for-hs256-validation",
|
||||
));
|
||||
let v2 = JwtValidator::new(JwtConfig::new(
|
||||
"secret-two-long-enough-for-hs256-validation",
|
||||
));
|
||||
|
||||
let token = v1.create_token(&user()).unwrap();
|
||||
assert!(v2.validate_token(&token).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_token_is_rejected() {
|
||||
let v = validator();
|
||||
assert!(v.validate_token("not.a.valid.jwt").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_token_returns_expired_error() {
|
||||
use crate::jwt::JwtError;
|
||||
use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
|
||||
|
||||
let secret = "a-test-secret-that-is-long-enough-for-hs256";
|
||||
let claims = crate::jwt::JwtClaims {
|
||||
sub: "user-id".into(),
|
||||
email: "x@example.com".into(),
|
||||
exp: 1, // epoch + 1 second — already expired
|
||||
iat: 0,
|
||||
iss: None,
|
||||
aud: None,
|
||||
};
|
||||
let token = encode(
|
||||
&Header::new(Algorithm::HS256),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let v = JwtValidator::new(JwtConfig::new(secret));
|
||||
assert!(matches!(v.validate_token(&token), Err(JwtError::Expired)));
|
||||
}
|
||||
36
crates/adapters/auth/src/tests/password.rs
Normal file
36
crates/adapters/auth/src/tests/password.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use domain::user::{
|
||||
ports::PasswordHasher,
|
||||
value_objects::{Password, PasswordHash},
|
||||
};
|
||||
|
||||
use crate::password::Argon2PasswordHasher;
|
||||
|
||||
#[tokio::test]
|
||||
async fn hash_produces_verifiable_hash() {
|
||||
let hasher = Argon2PasswordHasher;
|
||||
let password = Password::new("correcthorsebattery").unwrap();
|
||||
|
||||
let hash = hasher.hash(&password).await.unwrap();
|
||||
assert!(hasher.verify(&password, &hash).await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wrong_password_does_not_verify() {
|
||||
let hasher = Argon2PasswordHasher;
|
||||
let password = Password::new("correcthorsebattery").unwrap();
|
||||
let wrong = Password::new("wrongpassword12345").unwrap();
|
||||
|
||||
let hash = hasher.hash(&password).await.unwrap();
|
||||
assert!(!hasher.verify(&wrong, &hash).await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_password_produces_different_hashes() {
|
||||
let hasher = Argon2PasswordHasher;
|
||||
let password = Password::new("samepassword123").unwrap();
|
||||
|
||||
let hash1 = hasher.hash(&password).await.unwrap();
|
||||
let hash2 = hasher.hash(&password).await.unwrap();
|
||||
|
||||
assert_ne!(hash1.as_str(), hash2.as_str());
|
||||
}
|
||||
Reference in New Issue
Block a user