rustauth-actix-web 0.3.0

Actix Web integration for RustAuth.
Documentation
mod common;

use std::sync::{Arc, Mutex};

use actix_web::http::{header, Method, StatusCode};
use actix_web::test;
use common::*;
use http::Request;
use rustauth::db::MemoryAdapter;
use rustauth::error::RustAuthError;
use rustauth::options::PasswordResetEmail;
use rustauth::options::{PasswordOptions, RustAuthOptions, TrustedOriginOptions};
use rustauth_actix_web::RustAuthActixWebOptions;
use rustauth_core::OutboundSendFuture;

#[tokio::test]
async fn password_reset_flow_works_over_actix_web() -> Result<(), Box<dyn std::error::Error>> {
    let adapter = MemoryAdapter::new();
    let auth = Arc::new(auth_with_adapter(adapter.clone(), RustAuthOptions::default()).await?);
    let app = mounted_app!(auth, RustAuthActixWebOptions::default());

    let sign_up = test::call_service(
        &app,
        json_test_request(
            Method::POST,
            "/api/auth/sign-up/email",
            r#"{"name":"Ada","email":"ada@example.com","password":"secret123"}"#,
            None,
        )
        .to_request(),
    )
    .await;
    assert_eq!(sign_up.status(), StatusCode::OK);

    let request_reset = test::call_service(
        &app,
        json_test_request(
            Method::POST,
            "/api/auth/request-password-reset",
            r#"{"email":"ada@example.com"}"#,
            None,
        )
        .to_request(),
    )
    .await;
    assert_eq!(request_reset.status(), StatusCode::OK);

    let token = reset_token(&adapter).await?;
    let reset = test::call_service(
        &app,
        json_test_request(
            Method::POST,
            "/api/auth/reset-password",
            &format!(r#"{{"token":"{token}","newPassword":"changed123"}}"#),
            None,
        )
        .to_request(),
    )
    .await;
    assert_eq!(reset.status(), StatusCode::OK);

    let callback = test::call_service(
        &app,
        test_request(
            Method::GET,
            &format!("/api/auth/reset-password/{token}?callbackURL=/reset"),
            "",
            None,
        )
        .to_request(),
    )
    .await;
    assert_eq!(callback.status(), StatusCode::FOUND);

    let sign_in = test::call_service(
        &app,
        json_test_request(
            Method::POST,
            "/api/auth/sign-in/email",
            r#"{"email":"ada@example.com","password":"changed123"}"#,
            None,
        )
        .to_request(),
    )
    .await;
    assert_eq!(sign_in.status(), StatusCode::OK);
    Ok(())
}

#[tokio::test]
async fn password_reset_url_uses_inferred_base_url() -> Result<(), Box<dyn std::error::Error>> {
    let adapter = MemoryAdapter::new();
    let captured_url = Arc::new(Mutex::new(None::<String>));
    let url_sink = Arc::clone(&captured_url);
    let auth = Arc::new(
        auth_with_adapter(
            adapter,
            RustAuthOptions::default()
                .trusted_origins(TrustedOriginOptions::Static(vec![
                    "https://app.example.com".to_owned(),
                ]))
                .password(PasswordOptions::default().send_reset_password(
                    move |email: PasswordResetEmail,
                          _request: Option<&Request<Vec<u8>>>|
                          -> OutboundSendFuture {
                        let url_sink = Arc::clone(&url_sink);
                        Box::pin(async move {
                            let mut url = url_sink.lock().map_err(|_| {
                                RustAuthError::Api("url capture lock poisoned".to_owned())
                            })?;
                            *url = Some(email.url);
                            Ok(())
                        })
                    },
                )),
        )
        .await?,
    );
    let app = mounted_app!(
        auth,
        RustAuthActixWebOptions::new().infer_base_url_from_request(true),
    );

    let sign_up = test::call_service(
        &app,
        json_test_request(
            Method::POST,
            "/api/auth/sign-up/email",
            r#"{"name":"Ada","email":"ada@example.com","password":"secret123"}"#,
            None,
        )
        .insert_header((header::HOST, "app.example.com"))
        .to_request(),
    )
    .await;
    assert_eq!(sign_up.status(), StatusCode::OK);

    let request_reset = test::call_service(
        &app,
        json_test_request(
            Method::POST,
            "/api/auth/request-password-reset",
            r#"{"email":"ada@example.com","redirectTo":"/reset"}"#,
            None,
        )
        .insert_header((header::HOST, "app.example.com"))
        .to_request(),
    )
    .await;
    assert_eq!(request_reset.status(), StatusCode::OK);

    let url = wait_for_mutex_option(&captured_url).await?;
    assert!(url.starts_with("https://app.example.com/api/auth/reset-password/"));
    assert!(url.contains("callbackURL=%2Freset"));
    Ok(())
}

#[tokio::test]
async fn password_reset_url_does_not_infer_host_by_default(
) -> Result<(), Box<dyn std::error::Error>> {
    let adapter = MemoryAdapter::new();
    let captured_url = Arc::new(Mutex::new(None::<String>));
    let url_sink = Arc::clone(&captured_url);
    let auth = Arc::new(
        auth_with_adapter(
            adapter,
            RustAuthOptions::default()
                .base_url("https://app.example.com/api/auth")
                .password(PasswordOptions::default().send_reset_password(
                    move |email: PasswordResetEmail,
                          _request: Option<&Request<Vec<u8>>>|
                          -> OutboundSendFuture {
                        let url_sink = Arc::clone(&url_sink);
                        Box::pin(async move {
                            let mut url = url_sink.lock().map_err(|_| {
                                RustAuthError::Api("url capture lock poisoned".to_owned())
                            })?;
                            *url = Some(email.url);
                            Ok(())
                        })
                    },
                )),
        )
        .await?,
    );
    let app = mounted_app!(auth, RustAuthActixWebOptions::default());

    let sign_up = test::call_service(
        &app,
        json_test_request(
            Method::POST,
            "/api/auth/sign-up/email",
            r#"{"name":"Ada","email":"ada@example.com","password":"secret123"}"#,
            None,
        )
        .to_request(),
    )
    .await;
    assert_eq!(sign_up.status(), StatusCode::OK);

    let request_reset = test::call_service(
        &app,
        json_test_request(
            Method::POST,
            "/api/auth/request-password-reset",
            r#"{"email":"ada@example.com","redirectTo":"/reset"}"#,
            None,
        )
        .insert_header((header::HOST, "evil.example.com"))
        .to_request(),
    )
    .await;
    assert_eq!(request_reset.status(), StatusCode::OK);

    let url = wait_for_mutex_option(&captured_url).await?;
    assert!(url.starts_with("https://app.example.com/api/auth/reset-password/"));
    assert!(!url.contains("evil.example.com"));
    Ok(())
}