alibi-plugins 0.7.0

Built-in authentication plugins and API metadata for Alibi
Documentation
use super::types::{
    ChangePasswordRequest, ChangePasswordResponse, RequestPasswordResetRequest,
    RequestPasswordResetResponse, ResetPasswordRequest, ResetPasswordTokenQuery,
    VerifyPasswordRequest, VerifyPasswordResponse,
};
use super::{PasswordManagementConfig, StatusResponse};
use crate::helpers::{get_credential_account, get_credential_password_hash};
use alibi_core::utils::password as password_utils;
use alibi_core::wire::UserView;
use alibi_core::{
    AuthAccount, AuthContext, AuthError, AuthResult, AuthSession, AuthUser, CreateAccount,
    RequestMeta, UpdateAccount,
};
use chrono::{Duration, Utc};
use url::Url;

const PASSWORD_RESET_SUCCESS_MESSAGE: &str =
    "If this email exists in our system, check your email for the reset link";

pub(crate) async fn request_password_reset_core(
    body: &RequestPasswordResetRequest,
    config: &PasswordManagementConfig,
    ctx: &AuthContext<impl alibi_core::AuthSchema>,
    request: Option<&alibi_core::AuthRequest>,
) -> AuthResult<RequestPasswordResetResponse> {
    if request.is_some()
        && let Some(redirect_to) = body.redirect_to.as_deref().filter(|url| !url.is_empty())
    {
        validate_redirect_target(redirect_to, ctx, "Invalid redirectURL")?;
    }

    let sender = config
        .send_reset_password
        .as_ref()
        .ok_or_else(|| AuthError::bad_request("Reset password isn't enabled"))?;

    let success = RequestPasswordResetResponse {
        status: true,
        message: PASSWORD_RESET_SUCCESS_MESSAGE.to_owned(),
    };

    let Some(user) = ctx.database.get_user_by_email_record(&body.email).await? else {
        _ = alibi_core::utils::id::generate_id(24);
        _ = ctx.verifications().find("dummy-verification-token").await?;
        tracing::warn!("Reset Password: User not found");
        return Ok(success);
    };

    let reset_token = alibi_core::utils::id::generate_id(24);
    let expires_at = Utc::now()
        + config
            .reset_token_expiry
            .filter(|duration| !duration.is_zero())
            .unwrap_or_else(|| Duration::hours(config.reset_token_expiry_hours));

    _ = ctx
        .verifications()
        .create(alibi_core::CreateVerification {
            identifier: format!("reset-password:{reset_token}"),
            value: user.id().to_string(),
            expires_at,
        })
        .await?;

    let callback_url = body
        .redirect_to
        .as_deref()
        .map(urlencoding::encode)
        .unwrap_or_default();
    let auth_url = crate::helpers::auth_base_url(&ctx.config);
    let reset_url = format!("{auth_url}/reset-password/{reset_token}?callbackURL={callback_url}");

    let user_value = password_utils::serialize_to_value(&ctx.user_view(&user))?;
    let sender = std::sync::Arc::clone(sender);
    crate::authentication_helpers::run_owned_notification(
        ctx,
        async move { sender.send(&user_value, &reset_url, &reset_token).await },
        ctx.config.awaited_notification_errors,
    )
    .await?;

    Ok(success)
}

pub(crate) async fn reset_password_core(
    body: &ResetPasswordRequest,
    config: &PasswordManagementConfig,
    ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> AuthResult<StatusResponse> {
    let token = body.token.as_deref().unwrap_or("");
    if token.is_empty() {
        return Err(AuthError::bad_request("Invalid token"));
    }

    let (minimum, maximum) = crate::email_password::password_length_limits(ctx);
    password_utils::validate_password(&body.new_password, minimum, maximum, ctx)?;
    let policy = ctx.extensions.get::<crate::EmailPasswordConfig>();

    let verification = ctx
        .verifications()
        .consume(&format!("reset-password:{token}"))
        .await?
        .ok_or_else(|| AuthError::bad_request("Invalid token"))?;
    let user_id = verification.value()?.to_owned();
    let user = ctx
        .database
        .get_user_by_id_record(&user_id)
        .await?
        .ok_or(AuthError::Upstream {
            status: 400,
            code: "USER_NOT_FOUND",
            message: "User not found",
        })?;

    let hasher = policy
        .as_ref()
        .and_then(|policy| policy.password_hasher.as_ref())
        .or(config.password_hasher.as_ref());
    let password_hash = ctx.hash_password(hasher, &body.new_password).await?;

    if let Some(account) = get_credential_account(ctx, &user_id).await? {
        _ = ctx
            .database
            .update_account_record(
                &account.id(),
                UpdateAccount {
                    password: Some(password_hash),
                    ..Default::default()
                },
            )
            .await?;
    } else {
        _ = ctx
            .database
            .create_account_record(CreateAccount {
                additional_fields: alibi_core::field_policy::FieldValues::default(),
                user_id: user_id.clone(),
                account_id: user_id.clone(),
                provider_id: "credential".to_owned(),
                access_token: None,
                refresh_token: None,
                id_token: None,
                access_token_expires_at: None,
                refresh_token_expires_at: None,
                scope: None,
                password: Some(password_hash),
            })
            .await?;
    }

    if let Some(callback) = &config.on_password_reset {
        callback(password_utils::serialize_to_value(&ctx.user_view(&user))?).await?;
    }

    if config.revoke_sessions_on_password_reset {
        ctx.database.delete_user_sessions(&user_id).await?;
    }

    Ok(StatusResponse { status: true })
}

/// The redirect URL for `GET /reset-password/{token}`.
pub(crate) async fn reset_password_token_core(
    token: Option<&str>,
    query: &ResetPasswordTokenQuery,
    ctx: &AuthContext<impl alibi_core::AuthSchema>,
    request: Option<&alibi_core::AuthRequest>,
) -> AuthResult<String> {
    let callback_url = query.callback_url.as_deref().filter(|url| !url.is_empty());
    if request.is_some()
        && let Some(callback_url) = callback_url
    {
        validate_redirect_target(callback_url, ctx, "Invalid callbackURL")?;
    }
    let default_error_url = format!("{}/error", crate::helpers::auth_base_url(&ctx.config));
    let redirect = |param: (&str, &str)| {
        build_redirect_url(
            &ctx.config.base_url,
            callback_url.or(Some(default_error_url.as_str())),
            &[param],
        )
    };

    let Some(token) = token.filter(|_| callback_url.is_some()) else {
        return redirect(("error", "INVALID_TOKEN"));
    };

    let verification = ctx
        .verifications()
        .find(&format!("reset-password:{token}"))
        .await?;
    if verification
        .as_ref()
        .is_none_or(alibi_core::verification::VerificationSnapshot::is_expired)
    {
        return redirect(("error", "INVALID_TOKEN"));
    }

    redirect(("token", token))
}

/// Change the user's password. Returns the response and an optional new session token.
pub(crate) async fn change_password_core<S: alibi_core::AuthSchema>(
    body: &ChangePasswordRequest,
    user: &alibi_core::AdapterRecord<S::User>,
    config: &PasswordManagementConfig,
    meta: &RequestMeta,
    ctx: &AuthContext<S>,
) -> AuthResult<(ChangePasswordResponse<UserView>, Option<String>)> {
    let credential_account = get_credential_account(ctx, user.id())
        .await?
        .ok_or_else(|| AuthError::bad_request("Credential account not found"))?;
    let stored_hash = if config.require_current_password {
        Some(
            credential_account
                .password()
                .ok_or_else(|| AuthError::bad_request("Credential account not found"))?
                .to_owned(),
        )
    } else {
        None
    };

    let (minimum, maximum) = crate::email_password::password_length_limits(ctx);
    password_utils::validate_password(&body.new_password, minimum, maximum, ctx)?;

    let password_hash = ctx
        .hash_password(config.password_hasher.as_ref(), &body.new_password)
        .await?;

    if let Some(stored_hash) = stored_hash {
        password_utils::verify_password(
            config.password_hasher.as_ref(),
            &body.current_password,
            &stored_hash,
        )
        .await
        .map_err(|_error| AuthError::bad_request("Invalid password"))?;
    }

    _ = ctx
        .database
        .update_account_record(
            &credential_account.id(),
            UpdateAccount {
                password: Some(password_hash),
                ..Default::default()
            },
        )
        .await?;

    let new_token = if body.revoke_other_sessions == Some(true) {
        ctx.database.delete_user_sessions(&user.id()).await?;
        let session = ctx
            .session_manager()
            .create_session_record(user, meta.ip_address.clone(), meta.user_agent.clone())
            .await?;
        let user = ctx.filter_user_record(user.clone());
        alibi_core::session::cookie_cache::runtime::emit_issuance(ctx, &user, &session).await?;
        crate::helpers::record_completed_session_record::<S>(&user, &session);
        Some(session.token().to_owned())
    } else {
        None
    };

    let response = ChangePasswordResponse {
        token: new_token.clone(),
        user: ctx.user_view(user),
    };

    Ok((response, new_token))
}

pub(crate) async fn verify_password_core(
    body: &VerifyPasswordRequest,
    user: &impl AuthUser,
    config: &PasswordManagementConfig,
    ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> AuthResult<VerifyPasswordResponse> {
    let (_, maximum) = crate::email_password::password_length_limits(ctx);
    if body.password.encode_utf16().count() > maximum {
        return Err(AuthError::bad_request("Password too long"));
    }
    let policy = ctx.extensions.get::<crate::EmailPasswordConfig>();
    let hasher = policy
        .as_ref()
        .and_then(|policy| policy.password_hasher.as_ref())
        .or(config.password_hasher.as_ref());
    let stored_hash = get_credential_password_hash(ctx, user)
        .await?
        .filter(|hash| !hash.is_empty())
        .ok_or_else(|| AuthError::bad_request("Invalid password"))?;

    password_utils::verify_password(hasher, &body.password, &stored_hash)
        .await
        .map_err(|error| {
            if error.status_code() == 500 {
                crate::helpers::callback_failure(error)
            } else {
                error
            }
        })
        .map_err(|error| match error {
            AuthError::InvalidCredentials => AuthError::bad_request("Invalid password"),
            other => other,
        })?;

    Ok(VerifyPasswordResponse { status: true })
}

fn validate_redirect_target(
    target: &str,
    ctx: &AuthContext<impl alibi_core::AuthSchema>,
    error_message: &str,
) -> AuthResult<()> {
    if ctx.config.current_origin_check_disabled() {
        return Ok(());
    }
    if ctx.config.is_redirect_target_trusted(target) {
        Ok(())
    } else {
        Err(AuthError::forbidden(error_message.to_owned()))
    }
}

fn build_redirect_url(
    base_url: &str,
    callback_url: Option<&str>,
    params: &[(&str, &str)],
) -> AuthResult<String> {
    let base = Url::parse(base_url)
        .map_err(|error| AuthError::internal(format!("Invalid base URL: {error}")))?;
    let mut url = if let Some(callback_url) = callback_url {
        base.join(callback_url).map_err(|error| {
            // Upstream sends the bare message so the response carries the
            // INVALID_CALLBACK_URL code; keep the parse detail in the log.
            tracing::warn!(error = %error, callback_url, "Invalid callbackURL");
            AuthError::bad_request("Invalid callbackURL")
        })?
    } else {
        base.join("/error")
            .map_err(|error| AuthError::internal(format!("Invalid error URL: {error}")))?
    };

    let mut pairs: Vec<(String, String)> = url
        .query_pairs()
        .map(|(key, value)| (key.into_owned(), value.into_owned()))
        .collect();
    for (key, value) in params {
        let mut replaced = false;
        pairs.retain_mut(|(name, previous)| {
            if name != key {
                return true;
            }
            if replaced {
                return false;
            }
            (*value).clone_into(previous);
            replaced = true;
            true
        });
        if !replaced {
            pairs.push(((*key).to_owned(), (*value).to_owned()));
        }
    }
    _ = url.query_pairs_mut().clear().extend_pairs(pairs);

    Ok(url.to_string())
}

// LCOV_EXCL_START
#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_helpers;

    // Upstream reference: packages/better-auth/src/api/middlewares/origin-check.ts :: originCheck respects ctx.context.skipOriginCheck.
    #[tokio::test]
    async fn validate_redirect_target_respects_disable_origin_check() {
        let config = test_helpers::create_test_config().disable_origin_check(true);
        let ctx = test_helpers::create_test_context_with_config(config).await;

        assert!(
            validate_redirect_target("https://evil.com/phish", &ctx, "Invalid redirectURL").is_ok()
        );
    }

    // Upstream reference: packages/better-auth/src/api/middlewares/origin-check.ts :: originCheck rejects untrusted origins by default.
    #[tokio::test]
    async fn validate_redirect_target_rejects_untrusted_by_default() {
        let ctx = test_helpers::create_test_context().await;

        assert!(
            validate_redirect_target("https://evil.com/phish", &ctx, "Invalid redirectURL")
                .is_err()
        );
    }
}
// LCOV_EXCL_STOP