rustauth-axum 0.3.0

Axum integration for RustAuth.
Documentation
mod common;

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

use axum::http::{header, Method, Request, StatusCode};
use common::*;
use rustauth::db::MemoryAdapter;
use rustauth::error::RustAuthError;
use rustauth::options::PasswordResetEmail;
use rustauth::options::{PasswordOptions, RustAuthOptions, TrustedOriginOptions};
use rustauth_axum::{RustAuthAxumExt, RustAuthAxumOptions};
use rustauth_core::OutboundSendFuture;
use tower::ServiceExt;

#[tokio::test]
async fn password_reset_flow_works_over_axum() -> Result<(), Box<dyn std::error::Error>> {
    let adapter = MemoryAdapter::new();
    let app = auth_with_adapter(adapter.clone(), RustAuthOptions::default())
        .await?
        .mount_at_base_path(RustAuthAxumOptions::default())?;

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

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

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

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

    let sign_in = app
        .oneshot(json_request(
            Method::POST,
            "/api/auth/sign-in/email",
            r#"{"email":"ada@example.com","password":"changed123"}"#,
            None,
        )?)
        .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 app = 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?
    .mount_at_base_path(RustAuthAxumOptions::new().infer_base_url_from_request(true))?;

    let sign_up = app
        .clone()
        .oneshot(
            json_request(
                Method::POST,
                "/api/auth/sign-up/email",
                r#"{"name":"Ada","email":"ada@example.com","password":"secret123"}"#,
                None,
            )?
            .with_header(axum::http::header::HOST, "app.example.com")?,
        )
        .await?;
    assert_eq!(sign_up.status(), StatusCode::OK);

    let request_reset = app
        .oneshot(
            json_request(
                Method::POST,
                "/api/auth/request-password-reset",
                r#"{"email":"ada@example.com","redirectTo":"/reset"}"#,
                None,
            )?
            .with_header(axum::http::header::HOST, "app.example.com")?,
        )
        .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 app = 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?
    .mount_at_base_path(RustAuthAxumOptions::default())?;

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

    let request_reset = app
        .oneshot(
            json_request(
                Method::POST,
                "/api/auth/request-password-reset",
                r#"{"email":"ada@example.com","redirectTo":"/reset"}"#,
                None,
            )?
            .with_header(header::HOST, "evil.example.com")?,
        )
        .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(())
}