diff --git a/notes-domain/Cargo.toml b/notes-domain/Cargo.toml index 5b2b2ef..1235086 100644 --- a/notes-domain/Cargo.toml +++ b/notes-domain/Cargo.toml @@ -13,6 +13,8 @@ thiserror = "2.0.17" tracing = "0.1" uuid = { version = "1.19.0", features = ["v4", "serde"] } futures-core = "0.3" +email_address = "0.2.9" +url = { version = "2.5.8", features = ["serde"] } [dev-dependencies] tokio = { version = "1", features = ["rt", "macros"] } diff --git a/notes-domain/src/value_objects.rs b/notes-domain/src/value_objects.rs index 979de18..fceab2a 100644 --- a/notes-domain/src/value_objects.rs +++ b/notes-domain/src/value_objects.rs @@ -6,6 +6,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::fmt; use thiserror::Error; +use url::Url; // ============================================================================ // Validation Error @@ -28,47 +29,44 @@ pub enum ValidationError { #[error("Note title cannot exceed {max} characters, got {actual}")] TitleTooLong { max: usize, actual: usize }, + + #[error("Invalid URL: {0}")] + InvalidUrl(String), + + #[error("Value cannot be empty: {0}")] + Empty(String), + + #[error("Secret too short: minimum {min} bytes required, got {actual}")] + SecretTooShort { min: usize, actual: usize }, } // ============================================================================ // Email // ============================================================================ -/// A validated email address. -/// -/// Simple validation: must contain exactly one `@` with non-empty parts on both sides. +/// A validated email address using RFC-compliant validation. #[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct Email(String); +pub struct Email(email_address::EmailAddress); impl Email { - /// Minimum validation: contains @ with non-empty local and domain parts - pub fn new(value: impl Into) -> Result { - let value = value.into(); - let trimmed = value.trim().to_lowercase(); - - // Basic email validation - let parts: Vec<&str> = trimmed.split('@').collect(); - if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() { - return Err(ValidationError::InvalidEmail(value)); - } - - // Domain must contain at least one dot - if !parts[1].contains('.') { - return Err(ValidationError::InvalidEmail(value)); - } - - Ok(Self(trimmed)) + /// Create a new validated email address + pub fn new(value: impl AsRef) -> Result { + let value = value.as_ref().trim().to_lowercase(); + let addr: email_address::EmailAddress = value + .parse() + .map_err(|_| ValidationError::InvalidEmail(value.clone()))?; + Ok(Self(addr)) } /// Get the inner value pub fn into_inner(self) -> String { - self.0 + self.0.to_string() } } impl AsRef for Email { fn as_ref(&self) -> &str { - &self.0 + self.0.as_ref() } } @@ -96,7 +94,7 @@ impl TryFrom<&str> for Email { impl Serialize for Email { fn serialize(&self, serializer: S) -> Result { - serializer.serialize_str(&self.0) + serializer.serialize_str(self.0.as_ref()) } } @@ -351,6 +349,446 @@ impl<'de> Deserialize<'de> for NoteTitle { } } +// ============================================================================ +// OIDC Configuration Newtypes +// ============================================================================ + +/// OIDC Issuer URL - validated URL for the identity provider +/// +/// Stores the original string to preserve exact formatting (e.g., trailing slashes) +/// since OIDC providers expect issuer URLs to match exactly. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct IssuerUrl(String); + +impl IssuerUrl { + pub fn new(value: impl AsRef) -> Result { + let value = value.as_ref().trim().to_string(); + // Validate URL format but store original string to preserve exact formatting + Url::parse(&value).map_err(|e| ValidationError::InvalidUrl(e.to_string()))?; + Ok(Self(value)) + } +} + +impl AsRef for IssuerUrl { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for IssuerUrl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for IssuerUrl { + type Error = ValidationError; + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl From for String { + fn from(val: IssuerUrl) -> Self { + val.0 + } +} + +/// OIDC Client Identifier +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct ClientId(String); + +impl ClientId { + pub fn new(value: impl Into) -> Result { + let value = value.into().trim().to_string(); + if value.is_empty() { + return Err(ValidationError::Empty("client_id".to_string())); + } + Ok(Self(value)) + } +} + +impl AsRef for ClientId { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for ClientId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for ClientId { + type Error = ValidationError; + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl From for String { + fn from(val: ClientId) -> Self { + val.0 + } +} + +/// OIDC Client Secret - hidden in Debug output +#[derive(Clone, PartialEq, Eq)] +pub struct ClientSecret(String); + +impl ClientSecret { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + /// Check if the secret is empty (for public clients) + pub fn is_empty(&self) -> bool { + self.0.trim().is_empty() + } +} + +impl AsRef for ClientSecret { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for ClientSecret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "ClientSecret(***)") + } +} + +impl fmt::Display for ClientSecret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "***") + } +} + +impl<'de> Deserialize<'de> for ClientSecret { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Ok(Self::new(s)) + } +} + +// Note: ClientSecret should NOT implement Serialize + +/// OAuth Redirect URL - validated URL +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct RedirectUrl(Url); + +impl RedirectUrl { + pub fn new(value: impl AsRef) -> Result { + let value = value.as_ref().trim(); + let url = Url::parse(value).map_err(|e| ValidationError::InvalidUrl(e.to_string()))?; + Ok(Self(url)) + } + + pub fn as_url(&self) -> &Url { + &self.0 + } +} + +impl AsRef for RedirectUrl { + fn as_ref(&self) -> &str { + self.0.as_str() + } +} + +impl fmt::Display for RedirectUrl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for RedirectUrl { + type Error = ValidationError; + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl From for String { + fn from(val: RedirectUrl) -> Self { + val.0.to_string() + } +} + +/// OIDC Resource Identifier (optional audience) +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct ResourceId(String); + +impl ResourceId { + pub fn new(value: impl Into) -> Result { + let value = value.into().trim().to_string(); + if value.is_empty() { + return Err(ValidationError::Empty("resource_id".to_string())); + } + Ok(Self(value)) + } +} + +impl AsRef for ResourceId { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for ResourceId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for ResourceId { + type Error = ValidationError; + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl From for String { + fn from(val: ResourceId) -> Self { + val.0 + } +} + +// ============================================================================ +// OIDC Flow Newtypes (for type-safe session storage) +// ============================================================================ + +/// CSRF Token for OIDC state parameter +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CsrfToken(String); + +impl CsrfToken { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } +} + +impl AsRef for CsrfToken { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for CsrfToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Nonce for OIDC ID token verification +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OidcNonce(String); + +impl OidcNonce { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } +} + +impl AsRef for OidcNonce { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for OidcNonce { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +/// PKCE Code Verifier +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PkceVerifier(String); + +impl PkceVerifier { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } +} + +impl AsRef for PkceVerifier { + fn as_ref(&self) -> &str { + &self.0 + } +} + +// Hide PKCE verifier in Debug (security) +impl fmt::Debug for PkceVerifier { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "PkceVerifier(***)") + } +} + +/// OAuth2 Authorization Code +#[derive(Clone, PartialEq, Eq)] +pub struct AuthorizationCode(String); + +impl AuthorizationCode { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } +} + +impl AsRef for AuthorizationCode { + fn as_ref(&self) -> &str { + &self.0 + } +} + +// Hide authorization code in Debug (security) +impl fmt::Debug for AuthorizationCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "AuthorizationCode(***)") + } +} + +impl<'de> Deserialize<'de> for AuthorizationCode { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Ok(Self::new(s)) + } +} + +/// Complete authorization URL data returned when starting OIDC flow +#[derive(Debug, Clone)] +pub struct AuthorizationUrlData { + /// The URL to redirect the user to + pub url: Url, + /// CSRF token to store in session + pub csrf_token: CsrfToken, + /// Nonce to store in session + pub nonce: OidcNonce, + /// PKCE verifier to store in session + pub pkce_verifier: PkceVerifier, +} + +// ============================================================================ +// Configuration Newtypes +// ============================================================================ + +/// Database connection URL +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct DatabaseUrl(String); + +impl DatabaseUrl { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.trim().is_empty() { + return Err(ValidationError::Empty("database_url".to_string())); + } + Ok(Self(value)) + } +} + +impl AsRef for DatabaseUrl { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for DatabaseUrl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for DatabaseUrl { + type Error = ValidationError; + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl From for String { + fn from(val: DatabaseUrl) -> Self { + val.0 + } +} + +/// Session secret with minimum length requirement +pub const MIN_SESSION_SECRET_LENGTH: usize = 64; + +#[derive(Clone, PartialEq, Eq)] +pub struct SessionSecret(String); + +impl SessionSecret { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.len() < MIN_SESSION_SECRET_LENGTH { + return Err(ValidationError::SecretTooShort { + min: MIN_SESSION_SECRET_LENGTH, + actual: value.len(), + }); + } + Ok(Self(value)) + } + + /// Create without validation (for development/testing) + pub fn new_unchecked(value: impl Into) -> Self { + Self(value.into()) + } +} + +impl AsRef for SessionSecret { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for SessionSecret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "SessionSecret(***)") + } +} + +/// JWT signing secret with minimum length requirement +pub const MIN_JWT_SECRET_LENGTH: usize = 32; + +#[derive(Clone, PartialEq, Eq)] +pub struct JwtSecret(String); + +impl JwtSecret { + pub fn new(value: impl Into, is_production: bool) -> Result { + let value = value.into(); + if is_production && value.len() < MIN_JWT_SECRET_LENGTH { + return Err(ValidationError::SecretTooShort { + min: MIN_JWT_SECRET_LENGTH, + actual: value.len(), + }); + } + Ok(Self(value)) + } + + /// Create without validation (for development/testing) + pub fn new_unchecked(value: impl Into) -> Self { + Self(value.into()) + } +} + +impl AsRef for JwtSecret { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for JwtSecret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "JwtSecret(***)") + } +} + // ============================================================================ // Tests // ============================================================================ @@ -389,11 +827,6 @@ mod tests { fn test_invalid_email_no_local() { assert!(Email::new("@example.com").is_err()); } - - #[test] - fn test_invalid_email_no_dot_in_domain() { - assert!(Email::new("user@localhost").is_err()); - } } mod password_tests { @@ -493,4 +926,68 @@ mod tests { assert_eq!(result.unwrap().as_ref(), "My Note"); } } + + mod oidc_tests { + use super::*; + + #[test] + fn test_issuer_url_valid() { + assert!(IssuerUrl::new("https://auth.example.com").is_ok()); + } + + #[test] + fn test_issuer_url_invalid() { + assert!(IssuerUrl::new("not-a-url").is_err()); + } + + #[test] + fn test_client_id_non_empty() { + assert!(ClientId::new("my-client").is_ok()); + assert!(ClientId::new("").is_err()); + assert!(ClientId::new(" ").is_err()); + } + + #[test] + fn test_client_secret_hides_in_debug() { + let secret = ClientSecret::new("super-secret"); + let debug = format!("{:?}", secret); + assert!(!debug.contains("super-secret")); + assert!(debug.contains("***")); + } + } + + mod secret_tests { + use super::*; + + #[test] + fn test_session_secret_min_length() { + let short = "short"; + let long = "a".repeat(64); + + assert!(SessionSecret::new(short).is_err()); + assert!(SessionSecret::new(long).is_ok()); + } + + #[test] + fn test_jwt_secret_production_check() { + let short = "short"; + let long = "a".repeat(32); + + // Production mode enforces length + assert!(JwtSecret::new(short, true).is_err()); + assert!(JwtSecret::new(&long, true).is_ok()); + + // Development mode allows short secrets + assert!(JwtSecret::new(short, false).is_ok()); + } + + #[test] + fn test_secrets_hide_in_debug() { + let session = SessionSecret::new_unchecked("secret"); + let jwt = JwtSecret::new_unchecked("secret"); + + assert!(!format!("{:?}", session).contains("secret")); + assert!(!format!("{:?}", jwt).contains("secret")); + } + } }