rustauth-core 0.2.0

Core types and primitives for RustAuth.
Documentation
use time::OffsetDateTime;

use crate::api::{request_base_url, ApiRequest};
use crate::context::AuthContext;
use crate::crypto::random::generate_random_string;
use crate::db::{Session, User};
use crate::error::RustAuthError;
use crate::options::{ChangeEmailConfirmation, DeleteAccountVerificationEmail, VerificationEmail};
use crate::outbound::dispatch_outbound;
use crate::verification::CreateVerificationInput;

#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
pub(in crate::api) enum DeleteUserError {
    #[error("invalid password")]
    InvalidPassword,
    #[error("invalid token")]
    InvalidToken,
    #[error("session expired")]
    SessionExpired,
    #[error("credential account not found")]
    CredentialAccountNotFound,
}

#[derive(Debug, thiserror::Error)]
pub(in crate::api) enum DeleteUserErrorOrRustAuth {
    #[error(transparent)]
    Service(#[from] DeleteUserError),
    #[error(transparent)]
    RustAuth(#[from] RustAuthError),
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(in crate::api) enum DeleteUserResult {
    Deleted,
    VerificationSent,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(in crate::api) struct ChangeEmailInput {
    pub(in crate::api) new_email: String,
    pub(in crate::api) callback_url: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(in crate::api) enum ChangeEmailResult {
    Updated(User),
    VerificationSent,
}

#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
pub(in crate::api) enum ChangeEmailError {
    #[error("change email is disabled")]
    Disabled,
    #[error("email is the same")]
    EmailIsSame,
    #[error("verification email is not enabled")]
    VerificationEmailNotEnabled,
}

#[derive(Debug, thiserror::Error)]
pub(in crate::api) enum ChangeEmailErrorOrRustAuth {
    #[error(transparent)]
    Service(#[from] ChangeEmailError),
    #[error(transparent)]
    RustAuth(#[from] RustAuthError),
}

pub(in crate::api) async fn change_email(
    context: &AuthContext,
    request: Option<&ApiRequest>,
    user: User,
    input: ChangeEmailInput,
) -> Result<ChangeEmailResult, ChangeEmailErrorOrRustAuth> {
    if !context.options.user.change_email.enabled {
        return Err(ChangeEmailError::Disabled.into());
    }
    let new_email = input.new_email.to_lowercase();
    if new_email == user.email {
        return Err(ChangeEmailError::EmailIsSame.into());
    }

    let users = context.users()?;
    if users.find_user_by_email(&new_email).await?.is_some() {
        super::super::routes::email_verification::create_email_verification_token(
            context,
            &user.email,
            Some(&new_email),
            None,
        )?;
        return Ok(ChangeEmailResult::VerificationSent);
    }

    if !user.email_verified
        && context
            .options
            .user
            .change_email
            .update_email_without_verification
    {
        let updated = users
            .update_user_email(&user.id, &new_email, false)
            .await?
            .unwrap_or(user);
        return Ok(ChangeEmailResult::Updated(updated));
    }

    let Some(sender) = context
        .options
        .email_verification
        .send_verification_email
        .clone()
    else {
        return Err(ChangeEmailError::VerificationEmailNotEnabled.into());
    };
    let token = super::super::routes::email_verification::create_email_verification_token(
        context,
        &user.email,
        Some(&new_email),
        Some("change-email-verification"),
    )?;
    let callback_url = input.callback_url.unwrap_or_else(|| "/".to_owned());
    let url = format!(
        "{}/verify-email?token={token}&callbackURL={}",
        request_base_url(context, request),
        percent_encode(&callback_url)
    );
    if let Some(confirm) = &context
        .options
        .user
        .change_email
        .send_change_email_confirmation
    {
        confirm.send_change_email_confirmation(
            ChangeEmailConfirmation {
                user: user.clone(),
                new_email: new_email.clone(),
                url: url.clone(),
                token: token.clone(),
            },
            request,
        )?;
    }
    let send = sender.send_verification_email(
        VerificationEmail {
            user: User {
                email: new_email,
                ..user
            },
            url,
            token,
        },
        request,
    );
    dispatch_outbound(context, send);
    Ok(ChangeEmailResult::VerificationSent)
}

pub(in crate::api) async fn delete_user_with_password_or_fresh_session(
    context: &AuthContext,
    request: Option<&ApiRequest>,
    session: &Session,
    user: &User,
    password: Option<&str>,
    callback_url: Option<&str>,
) -> Result<DeleteUserResult, DeleteUserErrorOrRustAuth> {
    if let Some(password) = password {
        let has_credential = context
            .users()?
            .find_credential_account(&user.id)
            .await?
            .is_some();
        if !has_credential {
            return Err(DeleteUserError::CredentialAccountNotFound.into());
        }
        if !verify_delete_password(context, &user.id, password).await? {
            return Err(DeleteUserError::InvalidPassword.into());
        }
        perform_delete(context, request, user).await?;
        return Ok(DeleteUserResult::Deleted);
    }

    if context
        .options
        .user
        .delete_user
        .send_delete_account_verification
        .is_some()
    {
        send_delete_account_verification(context, request, user, callback_url).await?;
        return Ok(DeleteUserResult::VerificationSent);
    }

    crate::api::middleware::ensure_fresh_session(context, session)
        .map_err(|_| DeleteUserError::SessionExpired)?;
    perform_delete(context, request, user).await?;
    Ok(DeleteUserResult::Deleted)
}

pub(in crate::api) async fn delete_user_with_token(
    context: &AuthContext,
    request: Option<&ApiRequest>,
    user: &User,
    token: &str,
) -> Result<(), DeleteUserErrorOrRustAuth> {
    let identifier = format!("delete-account-{token}");
    let verifications = context.verifications()?;
    let Some(verification) = verifications
        .find_verification_including_expired(&identifier)
        .await?
    else {
        return Err(DeleteUserError::InvalidToken.into());
    };
    if verification.value != user.id {
        return Err(DeleteUserError::InvalidToken.into());
    }
    if verification.expires_at <= OffsetDateTime::now_utc() {
        verifications.delete_verification(&identifier).await?;
        return Err(DeleteUserError::InvalidToken.into());
    }
    perform_delete(context, request, user).await?;
    verifications.delete_verification(&identifier).await?;
    Ok(())
}

async fn send_delete_account_verification(
    context: &AuthContext,
    request: Option<&ApiRequest>,
    user: &User,
    callback_url: Option<&str>,
) -> Result<(), RustAuthError> {
    let Some(sender) = &context
        .options
        .user
        .delete_user
        .send_delete_account_verification
    else {
        return Ok(());
    };
    let token = generate_random_string(24);
    let expires_in = context
        .options
        .user
        .delete_user
        .delete_token_expires_in
        .unwrap_or(time::Duration::hours(1));
    let identifier = format!("delete-account-{token}");
    context
        .verifications()?
        .create_verification(CreateVerificationInput::new(
            identifier,
            user.id.clone(),
            OffsetDateTime::now_utc() + expires_in,
        ))
        .await?;
    let callback = callback_url.unwrap_or("/");
    let url = format!(
        "{}/delete-user/callback?token={token}&callbackURL={}",
        request_base_url(context, request),
        percent_encode(callback)
    );
    sender.send_delete_account_verification(
        DeleteAccountVerificationEmail {
            user: user.clone(),
            url,
            token,
        },
        request,
    )
}

async fn perform_delete(
    context: &AuthContext,
    request: Option<&ApiRequest>,
    user: &User,
) -> Result<(), RustAuthError> {
    if let Some(before) = &context.options.user.delete_user.before_delete {
        before.before_delete(user, request)?;
    }
    delete_user_records(context, &user.id).await?;
    if let Some(after) = &context.options.user.delete_user.after_delete {
        after.after_delete(user, request)?;
    }
    Ok(())
}

async fn verify_delete_password(
    context: &AuthContext,
    user_id: &str,
    password: &str,
) -> Result<bool, RustAuthError> {
    let Some(account) = context.users()?.find_credential_account(user_id).await? else {
        return Ok(false);
    };
    let Some(password_hash) = account.password.as_deref() else {
        return Ok(false);
    };
    (context.password.verify)(password_hash, password)
}

async fn delete_user_records(context: &AuthContext, user_id: &str) -> Result<(), RustAuthError> {
    let users = context.users()?;
    users.delete_user_accounts(user_id).await?;
    context.sessions()?.delete_user_sessions(user_id).await?;
    users.delete_user(user_id).await
}

fn percent_encode(value: &str) -> String {
    let mut encoded = String::new();
    for byte in value.bytes() {
        match byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                encoded.push(byte as char);
            }
            _ => encoded.push_str(&format!("%{byte:02X}")),
        }
    }
    encoded
}