refactor: Replace raw strings with domain value objects for improved type safety in authentication and OIDC.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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()))?;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user