Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion crates/auth-domain/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ publish = false
[dependencies]
models = { path = "../models" }

db.workspace = true
meta-domain = { path = "../meta-domain" }
mutate-domain = { path = "../mutate-domain" }

miette.workspace = true
thiserror.workspace = true
Expand Down
43 changes: 5 additions & 38 deletions crates/auth-domain/src/errors.rs
Original file line number Diff line number Diff line change
@@ -1,50 +1,17 @@
use db::{FetchModelByIndexError, FetchModelError, PatchModelError};
use models::{Org, User, dvf::EmailAddress, model::RecordId};
use models::dvf::EmailAddress;

/// An error that occurs during user creation.
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum CreateUserError {
/// Indicates that the user's email address is already in use.
#[error("The email address is already in use: \"{0}\"")]
EmailAlreadyUsed(EmailAddress),
/// Indicates than an error occurred while hashing the password.
#[error("Failed to hash password")]
PasswordHashing(miette::Report),
/// Indicates that an error occurred while creating the user.
/// Indicates that an internal error occurred.
#[error("Failed to create the user")]
CreateError(miette::Report),
/// Indicates that an error occurred while fetching users by index.
#[error("Failed to fetch users by index")]
FetchByIndexError(#[from] FetchModelByIndexError),
InternalError(miette::Report),
}

/// An error that occurs during user authentication.
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum AuthenticationError {
/// Indicates that an error occurred while fetching users.
#[error("Failed to fetch user")]
FetchError(#[from] FetchModelError),
/// Indicates that an error occurred while fetching users by index.
#[error("Failed to fetch user by index")]
FetchByIndexError(#[from] FetchModelByIndexError),
/// Indicates than an error occurred while hashing the password.
#[error("Failed to hash password")]
PasswordHashing(miette::Report),
}

/// An error that occurs while updating a user's active org.
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum UpdateActiveOrgError {
/// Indicates that an error occurred while fetching the user.
#[error("Failed to fetch user")]
FetchError(#[from] FetchModelError),
/// Indicates that the user does not exist.
#[error("Failed to find user: {0}")]
MissingUser(RecordId<User>),
/// Indicates that the org supplied could not be switched to.
#[error("Failed to switch to org: {0}")]
InvalidOrg(RecordId<Org>),
/// Indicates that an error occurred while patching the user record.
#[error("Failed to patch user")]
PatchError(#[from] PatchModelError),
}
#[error("Internal error: {0}")]
pub struct AuthenticationError(pub miette::Report);
120 changes: 49 additions & 71 deletions crates/auth-domain/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ mod tests;

use axum_login::AuthUser as AxumLoginAuthUser;
pub use axum_login::AuthnBackend;
use db::{Database, FetchModelByIndexError, FetchModelError, kv::LaxSlug};
use miette::{IntoDiagnostic, miette};
use miette::{Context, IntoDiagnostic, miette};
use models::{
AuthUser, Org, OrgIdent, User, UserAuthCredentials,
UserSubmittedAuthCredentials, UserUniqueIndexSelector,
dvf::{EitherSlug, EmailAddress, HumanName},
UserSubmittedAuthCredentials,
dvf::{EmailAddress, HumanName},
model::RecordId,
};
pub use mutate_domain::UpdateActiveOrgError;

pub use self::errors::*;

Expand All @@ -24,70 +24,29 @@ pub type AuthSession = axum_login::AuthSession<AuthDomainService>;
/// A dynamic [`AuthDomainService`] trait object.
#[derive(Clone, Debug)]
pub struct AuthDomainService {
org_repo: Database<Org>,
user_repo: Database<User>,
meta: meta_domain::MetaService,
mutate: mutate_domain::MutationService,
}

impl AuthDomainService {
/// Creates a new [`AuthDomainService`].
#[must_use]
pub fn new(org_repo: Database<Org>, user_repo: Database<User>) -> Self {
Self {
org_repo,
user_repo,
}
pub fn new(
meta: meta_domain::MetaService,
mutate: mutate_domain::MutationService,
) -> Self {
Self { meta, mutate }
}
}

impl AuthDomainService {
/// Fetch a [`User`] by ID.
async fn fetch_user_by_id(
&self,
id: RecordId<User>,
) -> Result<Option<User>, FetchModelError> {
self.user_repo.fetch_model_by_id(id).await
}

/// Fetch a [`User`] by [`EmailAddress`](EmailAddress).
async fn fetch_user_by_email(
&self,
email: EmailAddress,
) -> Result<Option<User>, FetchModelByIndexError> {
self
.user_repo
.fetch_model_by_unique_index(
UserUniqueIndexSelector::Email,
EitherSlug::Lax(LaxSlug::new(email.as_ref())),
)
.await
}

/// Switch a [`User`]'s active org.
/// Switches the active org of a [`User`].
pub async fn switch_active_org(
&self,
user: RecordId<User>,
new_active_org: RecordId<Org>,
) -> Result<RecordId<Org>, errors::UpdateActiveOrgError> {
let user = self
.user_repo
.fetch_model_by_id(user)
.await?
.ok_or(errors::UpdateActiveOrgError::MissingUser(user))?;

let new_index = user
.iter_orgs()
.position(|o| o == new_active_org)
.ok_or(errors::UpdateActiveOrgError::InvalidOrg(new_active_org))?;

self
.user_repo
.patch_model(user.id, User {
active_org_index: new_index as _,
..user
})
.await?;

Ok(new_active_org)
) -> Result<RecordId<Org>, UpdateActiveOrgError> {
self.mutate.switch_active_org(user, new_active_org).await
}

/// Sign up a [`User`].
Expand All @@ -99,7 +58,15 @@ impl AuthDomainService {
) -> Result<User, errors::CreateUserError> {
use argon2::PasswordHasher;

if self.fetch_user_by_email(email.clone()).await?.is_some() {
if self
.meta
.fetch_user_by_email(email.clone())
.await
.into_diagnostic()
.context("failed to check for conflicting user by email")
.map_err(CreateUserError::InternalError)?
.is_some()
{
return Err(errors::CreateUserError::EmailAlreadyUsed(email));
}

Expand All @@ -113,8 +80,8 @@ impl AuthDomainService {
argon
.hash_password(password.as_bytes(), &salt)
.map_err(|e| {
errors::CreateUserError::PasswordHashing(miette!(
"failed to hash password: {e}"
CreateUserError::InternalError(miette!(
"failed to parse password hash: {e}"
))
})?
.to_string(),
Expand Down Expand Up @@ -143,29 +110,40 @@ impl AuthDomainService {
};

self
.org_repo
.create_model(org)
.mutate
.create_org(org)
.await
.into_diagnostic()
.map_err(errors::CreateUserError::CreateError)?;
.context("failed to create personal org for user")
.map_err(errors::CreateUserError::InternalError)?;

self
.user_repo
.create_model(user)
.mutate
.create_user(user.clone())
.await
.into_diagnostic()
.map_err(errors::CreateUserError::CreateError)
.context("failed to create user")
.map_err(errors::CreateUserError::InternalError)?;

Ok(user)
}

/// Authenticate a [`User`].
pub async fn user_authenticate(
&self,
email: EmailAddress,
creds: UserSubmittedAuthCredentials,
) -> Result<Option<User>, errors::AuthenticationError> {
) -> Result<Option<User>, AuthenticationError> {
use argon2::PasswordVerifier;

let Some(user) = self.fetch_user_by_email(email).await? else {
let Some(user) = self
.meta
.fetch_user_by_email(email)
.await
.into_diagnostic()
.context("failed to fetch user by email")
.map_err(AuthenticationError)?
else {
return Ok(None);
};

Expand All @@ -176,9 +154,7 @@ impl AuthDomainService {
) => {
let password_hash = argon2::PasswordHash::new(&password_hash.0)
.map_err(|e| {
errors::AuthenticationError::PasswordHashing(miette!(
"failed to parse password hash: {e}"
))
AuthenticationError(miette!("failed to parse password hash: {e}"))
})?;

let argon = argon2::Argon2::default();
Expand All @@ -189,7 +165,7 @@ impl AuthDomainService {
Err(e) => Err(e),
})
.map_err(|e| {
errors::AuthenticationError::PasswordHashing(miette!(
AuthenticationError(miette!(
"failed to verify password against hash: {e}"
))
})?;
Expand Down Expand Up @@ -220,9 +196,11 @@ impl AuthnBackend for AuthDomainService {
id: &<Self::User as AxumLoginAuthUser>::Id,
) -> Result<Option<Self::User>, Self::Error> {
self
.meta
.fetch_user_by_id(*id)
.await
.into_diagnostic()
.map(|u| u.map(Into::into))
.map_err(Into::into)
.map_err(AuthenticationError)
}
}
16 changes: 10 additions & 6 deletions crates/auth-domain/src/tests.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
use meta_domain::MetaService;
use models::dvf::{EmailAddress, HumanName};
use mutate_domain::MutationService;

use super::*;

#[tokio::test]
async fn test_user_signup() {
let org_repo = Database::new_mock();
let user_repo = Database::new_mock();
let service = AuthDomainService::new(org_repo, user_repo);
let service = AuthDomainService::new(
MetaService::new_mock(),
MutationService::new_mock(),
);

let name = HumanName::try_new("Test User 1").unwrap();
let email = EmailAddress::try_new("test@example.com").unwrap();
Expand All @@ -30,9 +33,10 @@ async fn test_user_signup() {

#[tokio::test]
async fn test_user_authenticate() {
let org_repo = Database::new_mock();
let user_repo = Database::new_mock();
let service = AuthDomainService::new(org_repo, user_repo);
let service = AuthDomainService::new(
MetaService::new_mock(),
MutationService::new_mock(),
);

let name = HumanName::try_new("Test User 1").unwrap();
let email = EmailAddress::try_new("test@example.com").unwrap();
Expand Down
1 change: 1 addition & 0 deletions crates/domain/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ publish = false
[dependencies]
meta-domain = { path = "../meta-domain" }
models = { path = "../models" }
mutate-domain = { path = "../mutate-domain" }
owl = { path = "../owl" }

belt.workspace = true
Expand Down
Loading