refactor: Replace raw strings with domain value objects for improved type safety in authentication and OIDC.

This commit is contained in:
2026-01-06 05:16:16 +01:00
parent 16dcc4b95e
commit 32a0faf302
9 changed files with 667 additions and 232 deletions

View File

@@ -44,8 +44,7 @@ tokio = { version = "1.48.0", features = ["full"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0"
# Validation
validator = { version = "0.20", features = ["derive"] }
# Validation via domain newtypes (Email, Password)
# Error handling
thiserror = "2.0.17"

View File

@@ -1,30 +1,29 @@
//! 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;
use validator::Validate;
/// Login request
#[derive(Debug, Deserialize, Validate)]
/// Login request with validated email and password newtypes
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
#[validate(email(message = "Invalid email format"))]
pub email: String,
#[validate(length(min = 6, message = "Password must be at least 6 characters"))]
pub password: String,
/// Email is validated on deserialization
pub email: Email,
/// Password is validated on deserialization (min 6 chars)
pub password: Password,
}
/// Register request
#[derive(Debug, Deserialize, Validate)]
/// Register request with validated email and password newtypes
#[derive(Debug, Deserialize)]
pub struct RegisterRequest {
#[validate(email(message = "Invalid email format"))]
pub email: String,
#[validate(length(min = 6, message = "Password must be at least 6 characters"))]
pub password: String,
/// Email is validated on deserialization
pub email: Email,
/// Password is validated on deserialization (min 6 chars)
pub password: Password,
}
/// User response DTO
@@ -40,12 +39,3 @@ pub struct UserResponse {
pub struct ConfigResponse {
pub allow_registration: bool,
}
#[cfg(feature = "auth-jwt")]
#[derive(Debug, Serialize, Deserialize)]
// also newtypes
pub struct Claims {
pub sub: String,
pub email: String,
pub exp: usize,
}

View File

@@ -25,7 +25,7 @@ use crate::{
state::AppState,
};
#[cfg(feature = "auth-axum-login")]
use domain::{DomainError, Email};
use domain::DomainError;
/// Token response for JWT authentication
#[derive(Debug, Serialize)]
@@ -140,19 +140,20 @@ async fn register(
mut auth_session: crate::auth::AuthSession,
Json(payload): Json<RegisterRequest>,
) -> Result<impl IntoResponse, ApiError> {
// Email is already validated by the newtype deserialization
let email = payload.email;
if state
.user_service
.find_by_email(&payload.email)
.find_by_email(email.as_ref())
.await?
.is_some()
{
return Err(ApiError::Domain(DomainError::UserAlreadyExists(
payload.email,
email.as_ref().to_string(),
)));
}
let email = Email::try_from(payload.email).map_err(|e| ApiError::Validation(e.to_string()))?;
// Using email as subject for local auth for now
let user = state
.user_service
@@ -274,22 +275,22 @@ async fn oidc_login(State(state): State<AppState>, session: Session) -> Result<R
.as_ref()
.ok_or(ApiError::Internal("OIDC not configured".into()))?;
let (url, csrf, nonce, pkce) = service.get_authorization_url();
let auth_data = service.get_authorization_url();
session
.insert("oidc_csrf", csrf)
.insert("oidc_csrf", &auth_data.csrf_token)
.await
.map_err(|_| ApiError::Internal("Session error".into()))?;
session
.insert("oidc_nonce", nonce)
.insert("oidc_nonce", &auth_data.nonce)
.await
.map_err(|_| ApiError::Internal("Session error".into()))?;
session
.insert("oidc_pkce", pkce)
.insert("oidc_pkce", &auth_data.pkce_verifier)
.await
.map_err(|_| ApiError::Internal("Session error".into()))?;
let response = axum::response::Redirect::to(&url).into_response();
let response = axum::response::Redirect::to(auth_data.url.as_str()).into_response();
let (mut parts, body) = response.into_parts();
parts.headers.insert(
@@ -323,29 +324,33 @@ async fn oidc_callback(
.as_ref()
.ok_or(ApiError::Internal("OIDC not configured".into()))?;
let stored_csrf: String = session
let stored_csrf: domain::CsrfToken = session
.get("oidc_csrf")
.await
.map_err(|_| ApiError::Internal("Session error".into()))?
.ok_or(ApiError::Validation("Missing CSRF token".into()))?;
if params.state != stored_csrf {
if params.state != stored_csrf.as_ref() {
return Err(ApiError::Validation("Invalid CSRF token".into()));
}
let stored_pkce: String = session
let stored_pkce: domain::PkceVerifier = session
.get("oidc_pkce")
.await
.map_err(|_| ApiError::Internal("Session error".into()))?
.ok_or(ApiError::Validation("Missing PKCE".into()))?;
let stored_nonce: String = session
let stored_nonce: domain::OidcNonce = session
.get("oidc_nonce")
.await
.map_err(|_| ApiError::Internal("Session error".into()))?
.ok_or(ApiError::Validation("Missing Nonce".into()))?;
let oidc_user = service
.resolve_callback(params.code, stored_nonce, stored_pkce)
.resolve_callback(
domain::AuthorizationCode::new(params.code),
stored_nonce,
stored_pkce,
)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
@@ -412,29 +417,33 @@ async fn oidc_callback(
.as_ref()
.ok_or(ApiError::Internal("OIDC not configured".into()))?;
let stored_csrf: String = session
let stored_csrf: domain::CsrfToken = session
.get("oidc_csrf")
.await
.map_err(|_| ApiError::Internal("Session error".into()))?
.ok_or(ApiError::Validation("Missing CSRF token".into()))?;
if params.state != stored_csrf {
if params.state != stored_csrf.as_ref() {
return Err(ApiError::Validation("Invalid CSRF token".into()));
}
let stored_pkce: String = session
let stored_pkce: domain::PkceVerifier = session
.get("oidc_pkce")
.await
.map_err(|_| ApiError::Internal("Session error".into()))?
.ok_or(ApiError::Validation("Missing PKCE".into()))?;
let stored_nonce: String = session
let stored_nonce: domain::OidcNonce = session
.get("oidc_nonce")
.await
.map_err(|_| ApiError::Internal("Session error".into()))?
.ok_or(ApiError::Validation("Missing Nonce".into()))?;
let oidc_user = service
.resolve_callback(params.code, stored_nonce, stored_pkce)
.resolve_callback(
domain::AuthorizationCode::new(params.code),
stored_nonce,
stored_pkce,
)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;

View File

@@ -33,15 +33,24 @@ impl AppState {
&config.oidc_resource_id,
) {
tracing::info!("Initializing OIDC service with issuer: {}", issuer);
// Construct newtypes from config strings
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.clone(),
id.clone(),
secret.clone().unwrap_or_default(),
redirect.clone(),
resource_id.clone(),
)
.await?,
OidcService::new(issuer_url, client_id, client_secret, redirect_url, resource)
.await?,
))
} else {
None