rustauth-plugins 0.2.0

Official RustAuth plugin modules.
Documentation
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;

use rustauth_core::db::MemoryAdapter;
use rustauth_core::options::{
    AfterEmailVerification, BeforeEmailVerification, EmailVerificationCallbackPayload,
    EmailVerificationOptions, OnPasswordReset, PasswordOptions, PasswordResetPayload,
    RustAuthOptions,
};
use rustauth_core::session::{CreateSessionInput, DbSessionStore};
use rustauth_plugins::email_otp::{ChangeEmailOptions, EmailOtpOptions};
use time::{Duration, OffsetDateTime};

use super::common::*;

struct CountBefore(Arc<AtomicUsize>);

impl BeforeEmailVerification for CountBefore {
    fn before_email_verification(
        &self,
        _payload: EmailVerificationCallbackPayload,
        _request: Option<&http::Request<Vec<u8>>>,
    ) -> Result<(), rustauth_core::error::RustAuthError> {
        self.0.fetch_add(1, Ordering::SeqCst);
        Ok(())
    }
}

struct CountAfter(Arc<AtomicUsize>);

impl AfterEmailVerification for CountAfter {
    fn after_email_verification(
        &self,
        _payload: EmailVerificationCallbackPayload,
        _request: Option<&http::Request<Vec<u8>>>,
    ) -> Result<(), rustauth_core::error::RustAuthError> {
        self.0.fetch_add(1, Ordering::SeqCst);
        Ok(())
    }
}

struct CountReset(Arc<AtomicUsize>);

impl OnPasswordReset for CountReset {
    fn on_password_reset(
        &self,
        _payload: PasswordResetPayload,
        _request: Option<&http::Request<Vec<u8>>>,
    ) -> Result<(), rustauth_core::error::RustAuthError> {
        self.0.fetch_add(1, Ordering::SeqCst);
        Ok(())
    }
}

#[tokio::test]
async fn verify_email_invokes_before_and_after_callbacks() {
    let adapter = Arc::new(MemoryAdapter::new());
    create_user(&adapter, "ada@example.com", false).await;
    let sender = CaptureSender::default();
    let before = Arc::new(AtomicUsize::new(0));
    let after = Arc::new(AtomicUsize::new(0));
    let router = router_with_auth_options(
        adapter,
        sender.clone(),
        EmailOtpOptions::default(),
        RustAuthOptions {
            email_verification: EmailVerificationOptions {
                before_email_verification: Some(Arc::new(CountBefore(Arc::clone(&before)))),
                after_email_verification: Some(Arc::new(CountAfter(Arc::clone(&after)))),
                ..EmailVerificationOptions::default()
            },
            ..RustAuthOptions::default()
        },
    )
    .unwrap();

    router
        .handle_async(
            json_request(
                "/email-otp/send-verification-otp",
                r#"{"email":"ada@example.com","type":"email-verification"}"#,
                None,
            )
            .unwrap(),
        )
        .await
        .unwrap();
    let otp = sender.last_otp().await;
    let response = router
        .handle_async(
            json_request(
                "/email-otp/verify-email",
                &format!(r#"{{"email":"ada@example.com","otp":"{otp}"}}"#),
                None,
            )
            .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::OK);
    assert_eq!(before.load(Ordering::SeqCst), 1);
    assert_eq!(after.load(Ordering::SeqCst), 1);
}

#[tokio::test]
async fn reset_password_invokes_callback_and_revokes_sessions() {
    let adapter = Arc::new(MemoryAdapter::new());
    let user = create_user(&adapter, "ada@example.com", true).await;
    create_credential(&adapter, &user.id, "old-password").await;
    let session = DbSessionStore::new(adapter.as_ref())
        .create_session(CreateSessionInput::new(
            &user.id,
            OffsetDateTime::now_utc() + Duration::days(7),
        ))
        .await
        .unwrap();
    let sender = CaptureSender::default();
    let resets = Arc::new(AtomicUsize::new(0));
    let router = router_with_auth_options(
        adapter.clone(),
        sender.clone(),
        EmailOtpOptions::default(),
        RustAuthOptions {
            password: PasswordOptions {
                on_password_reset: Some(Arc::new(CountReset(Arc::clone(&resets)))),
                revoke_sessions_on_password_reset: true,
                ..PasswordOptions::default()
            },
            ..RustAuthOptions::default()
        },
    )
    .unwrap();

    router
        .handle_async(
            json_request(
                "/email-otp/request-password-reset",
                r#"{"email":"ada@example.com"}"#,
                None,
            )
            .unwrap(),
        )
        .await
        .unwrap();
    let otp = sender.last_otp().await;
    let response = router
        .handle_async(
            json_request(
                "/email-otp/reset-password",
                &format!(
                    r#"{{"email":"ada@example.com","otp":"{otp}","password":"new-password"}}"#
                ),
                None,
            )
            .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::OK);
    assert_eq!(resets.load(Ordering::SeqCst), 1);
    assert!(DbSessionStore::new(adapter.as_ref())
        .find_session(&session.token)
        .await
        .unwrap()
        .is_none());
}

#[tokio::test]
async fn change_email_revalidates_target_after_valid_otp() {
    let adapter = Arc::new(MemoryAdapter::new());
    let user = create_user(&adapter, "ada@example.com", true).await;
    let cookie = session_cookie(&adapter, &user.id).await;
    let sender = CaptureSender::default();
    let router = router(
        adapter.clone(),
        sender.clone(),
        EmailOtpOptions {
            change_email: ChangeEmailOptions {
                enabled: true,
                verify_current_email: false,
            },
            ..EmailOtpOptions::default()
        },
    )
    .unwrap();

    router
        .handle_async(
            json_request(
                "/email-otp/request-email-change",
                r#"{"newEmail":"grace@example.com"}"#,
                Some(&cookie),
            )
            .unwrap(),
        )
        .await
        .unwrap();
    let otp = sender.last_otp().await;
    create_user(&adapter, "grace@example.com", true).await;

    let response = router
        .handle_async(
            json_request(
                "/email-otp/change-email",
                &format!(r#"{{"newEmail":"grace@example.com","otp":"{otp}"}}"#),
                Some(&cookie),
            )
            .unwrap(),
        )
        .await
        .unwrap();
    let body: Value = serde_json::from_slice(response.body()).unwrap();

    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    assert_eq!(body["code"], "EMAIL_ALREADY_IN_USE");
}

#[tokio::test]
async fn change_email_invokes_before_and_after_callbacks() {
    let adapter = Arc::new(MemoryAdapter::new());
    let user = create_user(&adapter, "ada@example.com", true).await;
    let cookie = session_cookie(&adapter, &user.id).await;
    let sender = CaptureSender::default();
    let before = Arc::new(AtomicUsize::new(0));
    let after = Arc::new(AtomicUsize::new(0));
    let router = router_with_auth_options(
        adapter,
        sender.clone(),
        EmailOtpOptions {
            change_email: ChangeEmailOptions {
                enabled: true,
                verify_current_email: false,
            },
            ..EmailOtpOptions::default()
        },
        RustAuthOptions {
            email_verification: EmailVerificationOptions {
                before_email_verification: Some(Arc::new(CountBefore(Arc::clone(&before)))),
                after_email_verification: Some(Arc::new(CountAfter(Arc::clone(&after)))),
                ..EmailVerificationOptions::default()
            },
            ..RustAuthOptions::default()
        },
    )
    .unwrap();

    router
        .handle_async(
            json_request(
                "/email-otp/request-email-change",
                r#"{"newEmail":"grace@example.com"}"#,
                Some(&cookie),
            )
            .unwrap(),
        )
        .await
        .unwrap();
    let otp = sender.last_otp().await;
    let response = router
        .handle_async(
            json_request(
                "/email-otp/change-email",
                &format!(r#"{{"newEmail":"grace@example.com","otp":"{otp}"}}"#),
                Some(&cookie),
            )
            .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::OK);
    assert_eq!(before.load(Ordering::SeqCst), 1);
    assert_eq!(after.load(Ordering::SeqCst), 1);
}