oidc integration

Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
2026-01-06 20:11:22 +00:00
parent ede9567e09
commit bf9c688e6b
39 changed files with 2853 additions and 516 deletions

View File

@@ -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"] }

View File

@@ -91,6 +91,12 @@ impl DomainError {
}
}
impl From<crate::value_objects::ValidationError> for DomainError {
fn from(error: crate::value_objects::ValidationError) -> Self {
DomainError::ValidationError(error.to_string())
}
}
/// Result type alias for domain operations
pub type DomainResult<T> = Result<T, DomainError>;

View File

@@ -17,12 +17,9 @@ pub mod services;
pub mod value_objects;
// Re-export commonly used types at crate root
pub use entities::{MAX_TAGS_PER_NOTE, Note, NoteFilter, NoteVersion, Tag, User};
pub use entities::*;
pub use errors::{DomainError, DomainResult};
pub use ports::MessageBroker;
pub use repositories::{NoteRepository, TagRepository, UserRepository};
pub use services::{CreateNoteRequest, NoteService, TagService, UpdateNoteRequest, UserService};
pub use value_objects::{
Email, MAX_NOTE_TITLE_LENGTH, MAX_TAG_NAME_LENGTH, MIN_PASSWORD_LENGTH, NoteTitle, Password,
TagName, ValidationError,
};
pub use ports::*;
pub use repositories::*;
pub use services::*;
pub use value_objects::*;

View File

@@ -375,36 +375,46 @@ impl UserService {
Self { user_repo }
}
/// Find or create a user by OIDC subject
/// This is the main entry point for OIDC authentication
pub async fn find_or_create_by_subject(
&self,
subject: &str,
email: Email,
) -> DomainResult<User> {
pub async fn find_or_create(&self, subject: &str, email: &str) -> DomainResult<User> {
// 1. Try to find by subject (OIDC id)
if let Some(user) = self.user_repo.find_by_subject(subject).await? {
Ok(user)
} else {
let user = User::new(subject, email);
self.user_repo.save(&user).await?;
Ok(user)
return Ok(user);
}
// 2. Try to find by email
if let Some(mut user) = self.user_repo.find_by_email(email).await? {
// Link subject if missing (account linking logic)
if user.subject != subject {
user.subject = subject.to_string();
self.user_repo.save(&user).await?;
}
return Ok(user);
}
// 3. Create new user
let email = Email::try_from(email)?;
let user = User::new(subject, email);
self.user_repo.save(&user).await?;
Ok(user)
}
/// Get a user by ID
pub async fn get_user(&self, id: Uuid) -> DomainResult<User> {
pub async fn find_by_id(&self, id: Uuid) -> DomainResult<User> {
self.user_repo
.find_by_id(id)
.await?
.ok_or(DomainError::UserNotFound(id))
}
/// Delete a user and all associated data
pub async fn delete_user(&self, id: Uuid) -> DomainResult<()> {
// Note: In practice, we'd also need to delete notes and tags
// This would be handled by cascade delete in the database
// or by coordinating with other services
self.user_repo.delete(id).await
pub async fn find_by_email(&self, email: &str) -> DomainResult<Option<User>> {
self.user_repo.find_by_email(email).await
}
pub async fn create_local(&self, email: &str, password_hash: &str) -> DomainResult<User> {
let email = Email::try_from(email)?;
let user = User::new_local(email, password_hash);
self.user_repo.save(&user).await?;
Ok(user)
}
}
@@ -889,7 +899,7 @@ mod tests {
let email = Email::try_from("test@example.com").unwrap();
let user = service
.find_or_create_by_subject("oidc|123", email)
.find_or_create("oidc|123", email.as_ref())
.await
.unwrap();
@@ -903,13 +913,13 @@ mod tests {
let email1 = Email::try_from("test@example.com").unwrap();
let user1 = service
.find_or_create_by_subject("oidc|123", email1)
.find_or_create("oidc|123", email1.as_ref())
.await
.unwrap();
let email2 = Email::try_from("test@example.com").unwrap();
let user2 = service
.find_or_create_by_subject("oidc|123", email2)
.find_or_create("oidc|123", email2.as_ref())
.await
.unwrap();

View File

@@ -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<String>) -> Result<Self, ValidationError> {
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<str>) -> Result<Self, ValidationError> {
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<str> 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<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
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<str>) -> Result<Self, ValidationError> {
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<str> 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<String> for IssuerUrl {
type Error = ValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<IssuerUrl> 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<String>) -> Result<Self, ValidationError> {
let value = value.into().trim().to_string();
if value.is_empty() {
return Err(ValidationError::Empty("client_id".to_string()));
}
Ok(Self(value))
}
}
impl AsRef<str> 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<String> for ClientId {
type Error = ValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<ClientId> 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<String>) -> 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<str> 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<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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<str>) -> Result<Self, ValidationError> {
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<str> 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<String> for RedirectUrl {
type Error = ValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<RedirectUrl> 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<String>) -> Result<Self, ValidationError> {
let value = value.into().trim().to_string();
if value.is_empty() {
return Err(ValidationError::Empty("resource_id".to_string()));
}
Ok(Self(value))
}
}
impl AsRef<str> 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<String> for ResourceId {
type Error = ValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<ResourceId> 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<String>) -> Self {
Self(value.into())
}
}
impl AsRef<str> 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<String>) -> Self {
Self(value.into())
}
}
impl AsRef<str> 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<String>) -> Self {
Self(value.into())
}
}
impl AsRef<str> 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<String>) -> Self {
Self(value.into())
}
}
impl AsRef<str> 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<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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<String>) -> Result<Self, ValidationError> {
let value = value.into();
if value.trim().is_empty() {
return Err(ValidationError::Empty("database_url".to_string()));
}
Ok(Self(value))
}
}
impl AsRef<str> 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<String> for DatabaseUrl {
type Error = ValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<DatabaseUrl> 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<String>) -> Result<Self, ValidationError> {
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<String>) -> Self {
Self(value.into())
}
}
impl AsRef<str> 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<String>, is_production: bool) -> Result<Self, ValidationError> {
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<String>) -> Self {
Self(value.into())
}
}
impl AsRef<str> 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"));
}
}
}