rustauth-plugins 0.2.0

Official RustAuth plugin modules.
Documentation
use std::sync::Arc;

use http::{header, Method, Request, StatusCode};
use rustauth_core::api::{core_auth_async_endpoints, AuthRouter};
use rustauth_core::context::create_auth_context_with_adapter;
use rustauth_core::db::{Create, DbAdapter, DbValue, MemoryAdapter};
use rustauth_core::error::RustAuthError;
use rustauth_core::options::{AdvancedOptions, IpAddressOptions, RustAuthOptions, SessionOptions};
use rustauth_plugins::api_key::{
    api_key, default_key_hasher, ApiKeyConfiguration, ApiKeyOptions, ApiKeyReference,
    API_KEY_MODEL, INVALID_API_KEY, INVALID_REFERENCE_ID_FROM_API_KEY,
};
use serde_json::{json, Value};
use time::OffsetDateTime;

use super::helpers::{
    request_json, request_json_with_headers, sign_up, test_router, with_test_defaults,
};

#[tokio::test]
async fn api_key_can_mock_get_session_when_enabled() -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(MemoryAdapter::new());
    let router = test_router(
        adapter,
        api_key(
            ApiKeyOptions::builder()
                .configuration(ApiKeyConfiguration {
                    enable_session_for_api_keys: true,
                    ..ApiKeyConfiguration::default()
                })
                .build()?,
        )?,
    )?;
    let user = sign_up(&router, "Bea", "bea-api@example.com").await?;
    let created = request_json(
        &router,
        Method::POST,
        "/api/auth/api-key/create",
        json!({"name":"session-key"}),
        Some(&user.cookie),
        None,
    )
    .await?;
    let key = created.body["key"].as_str().ok_or("missing api key")?;

    let session = request_json(
        &router,
        Method::GET,
        "/api/auth/get-session",
        Value::Null,
        None,
        Some(("x-api-key", key)),
    )
    .await?;
    assert_eq!(session.status, StatusCode::OK);
    assert_eq!(session.body["user"]["id"], user.user_id);
    assert_eq!(session.body["session"]["token"], key);
    Ok(())
}

#[tokio::test]
async fn custom_api_key_getter_can_mock_session() -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(MemoryAdapter::new());
    let router = test_router(
        adapter,
        api_key(
            ApiKeyOptions::builder()
                .configuration(ApiKeyConfiguration {
                    enable_session_for_api_keys: true,
                    custom_api_key_getter: Some(Arc::new(|_context, request| {
                        let key = request
                            .headers()
                            .get(header::AUTHORIZATION)
                            .and_then(|value| value.to_str().ok())
                            .and_then(|value| value.strip_prefix("Bearer "))
                            .map(str::to_owned);
                        Box::pin(async move { Ok(key) })
                    })),
                    ..ApiKeyConfiguration::default()
                })
                .build()?,
        )?,
    )?;
    let user = sign_up(&router, "Bev", "bev-api@example.com").await?;
    let created = request_json(
        &router,
        Method::POST,
        "/api/auth/api-key/create",
        json!({"name":"session-key"}),
        Some(&user.cookie),
        None,
    )
    .await?;
    let key = created.body["key"].as_str().ok_or("missing api key")?;
    let bearer = format!("Bearer {key}");

    let session = request_json(
        &router,
        Method::GET,
        "/api/auth/get-session",
        Value::Null,
        None,
        Some(("authorization", &bearer)),
    )
    .await?;
    assert_eq!(session.status, StatusCode::OK);
    assert_eq!(session.body["user"]["id"], user.user_id);
    assert_eq!(session.body["session"]["token"], key);
    Ok(())
}

#[tokio::test]
async fn short_api_key_header_is_rejected_when_session_hook_matches(
) -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(MemoryAdapter::new());
    let router = test_router(
        adapter,
        api_key(
            ApiKeyOptions::builder()
                .configuration(ApiKeyConfiguration {
                    enable_session_for_api_keys: true,
                    ..ApiKeyConfiguration::default()
                })
                .build()?,
        )?,
    )?;

    let session = request_json(
        &router,
        Method::GET,
        "/api/auth/get-session",
        Value::Null,
        None,
        Some(("x-api-key", "short")),
    )
    .await?;
    assert_eq!(session.status, StatusCode::FORBIDDEN);
    assert_eq!(session.body["code"], INVALID_API_KEY);
    Ok(())
}

#[tokio::test]
async fn custom_validator_rejection_fails_api_key_session_mocking(
) -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(MemoryAdapter::new());
    let router = test_router(
        adapter,
        api_key(
            ApiKeyOptions::builder()
                .configuration(ApiKeyConfiguration {
                    enable_session_for_api_keys: true,
                    custom_api_key_validator: Some(Arc::new(|_context, _key| {
                        Box::pin(async move { Ok(false) })
                    })),
                    ..ApiKeyConfiguration::default()
                })
                .build()?,
        )?,
    )?;
    let user = sign_up(&router, "Val", "val-api@example.com").await?;
    let created = request_json(
        &router,
        Method::POST,
        "/api/auth/api-key/create",
        json!({"name":"blocked"}),
        Some(&user.cookie),
        None,
    )
    .await?;
    let key = created.body["key"].as_str().ok_or("missing api key")?;

    let session = request_json(
        &router,
        Method::GET,
        "/api/auth/get-session",
        Value::Null,
        None,
        Some(("x-api-key", key)),
    )
    .await?;

    assert_eq!(session.status, StatusCode::FORBIDDEN);
    assert_eq!(session.body["code"], INVALID_API_KEY);
    Ok(())
}

#[tokio::test]
async fn org_owned_key_cannot_mock_user_session() -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(MemoryAdapter::new());
    let raw_key = "A".repeat(64);
    let hashed_key = default_key_hasher(&raw_key);
    let now = OffsetDateTime::now_utc();
    adapter
        .create(
            Create::new(API_KEY_MODEL)
                .force_allow_id()
                .data("id", DbValue::String("org_key_1".to_owned()))
                .data("config_id", DbValue::String("default".to_owned()))
                .data("name", DbValue::String("org".to_owned()))
                .data("start", DbValue::String("AAAAAA".to_owned()))
                .data("prefix", DbValue::Null)
                .data("key", DbValue::String(hashed_key))
                .data("reference_id", DbValue::String("org_1".to_owned()))
                .data("refill_interval", DbValue::Null)
                .data("refill_amount", DbValue::Null)
                .data("last_refill_at", DbValue::Null)
                .data("enabled", DbValue::Boolean(true))
                .data("rate_limit_enabled", DbValue::Boolean(true))
                .data("rate_limit_time_window", DbValue::Number(86_400_000))
                .data("rate_limit_max", DbValue::Number(10))
                .data("request_count", DbValue::Number(0))
                .data("remaining", DbValue::Null)
                .data("last_request", DbValue::Null)
                .data("expires_at", DbValue::Null)
                .data("created_at", DbValue::Timestamp(now))
                .data("updated_at", DbValue::Timestamp(now))
                .data("metadata", DbValue::Null)
                .data("permissions", DbValue::Null),
        )
        .await?;
    let router = test_router(
        adapter,
        api_key(
            ApiKeyOptions::builder()
                .configuration(ApiKeyConfiguration {
                    enable_session_for_api_keys: true,
                    reference: ApiKeyReference::Organization,
                    ..ApiKeyConfiguration::default()
                })
                .build()?,
        )?,
    )?;

    let session = request_json(
        &router,
        Method::GET,
        "/api/auth/get-session",
        Value::Null,
        None,
        Some(("x-api-key", &raw_key)),
    )
    .await?;

    assert_eq!(session.status, StatusCode::UNAUTHORIZED);
    assert_eq!(session.body["code"], INVALID_REFERENCE_ID_FROM_API_KEY);
    Ok(())
}

#[tokio::test]
async fn api_key_session_hook_records_trusted_request_ip() -> Result<(), Box<dyn std::error::Error>>
{
    let adapter = Arc::new(MemoryAdapter::new());
    let router = test_router_with_options(
        adapter.clone(),
        RustAuthOptions {
            plugins: vec![api_key(
                ApiKeyOptions::builder()
                    .configuration(ApiKeyConfiguration {
                        enable_session_for_api_keys: true,
                        ..ApiKeyConfiguration::default()
                    })
                    .build()?,
            )?],
            base_url: Some("http://localhost:3000".to_owned()),
            secret: Some("test-secret-at-least-32-chars-long!".to_owned()),
            advanced: AdvancedOptions::default()
                .ip_address(IpAddressOptions::new().headers(["x-forwarded-for"])),
            ..RustAuthOptions::default()
        },
    )?;
    let user = sign_up(&router, "Ip", "ip-api@example.com").await?;
    let created = request_json(
        &router,
        Method::POST,
        "/api/auth/api-key/create",
        json!({"name":"ip-key"}),
        Some(&user.cookie),
        None,
    )
    .await?;
    let key = created.body["key"].as_str().ok_or("missing api key")?;

    let session = request_json_with_headers(
        &router,
        Method::GET,
        "/api/auth/get-session",
        Value::Null,
        None,
        &[("x-api-key", key), ("x-forwarded-for", "127.0.0.1")],
    )
    .await?;

    assert_eq!(session.status, StatusCode::OK);
    assert_eq!(session.body["session"]["ipAddress"], "127.0.0.1");
    Ok(())
}

#[tokio::test]
async fn api_key_session_hook_rejects_out_of_range_session_expiry(
) -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(MemoryAdapter::new());
    let setup_router = test_router(
        adapter.clone(),
        api_key(
            ApiKeyOptions::builder()
                .configuration(ApiKeyConfiguration {
                    enable_session_for_api_keys: true,
                    ..ApiKeyConfiguration::default()
                })
                .build()?,
        )?,
    )?;
    let user = sign_up(&setup_router, "Bo", "bo-api@example.com").await?;
    let created = request_json(
        &setup_router,
        Method::POST,
        "/api/auth/api-key/create",
        json!({"name":"session-key"}),
        Some(&user.cookie),
        None,
    )
    .await?;
    let key = created
        .body
        .get("key")
        .and_then(Value::as_str)
        .ok_or("missing api key")?;

    let router = test_router_with_options(
        adapter,
        RustAuthOptions {
            plugins: vec![api_key(
                ApiKeyOptions::builder()
                    .configuration(ApiKeyConfiguration {
                        enable_session_for_api_keys: true,
                        ..ApiKeyConfiguration::default()
                    })
                    .build()?,
            )?],
            base_url: Some("http://localhost:3000".to_owned()),
            secret: Some("test-secret-at-least-32-chars-long!".to_owned()),
            session: SessionOptions {
                expires_in: Some(time::Duration::seconds(i64::MAX)),
                ..SessionOptions::default()
            },
            on_api_error: rustauth_core::options::OnApiErrorOptions::default().throw(true),
            ..RustAuthOptions::default()
        },
    )?;

    let request = Request::builder()
        .method(Method::GET)
        .uri("http://localhost:3000/api/auth/get-session")
        .header("x-api-key", key)
        .body(Vec::new())?;
    let error = router
        .handle_async_server(request)
        .await
        .err()
        .ok_or("expected session expiry range error")?;

    assert!(matches!(
        error,
        RustAuthError::NumericOutOfRange {
            context: "session.expires_in"
        }
    ));
    Ok(())
}

fn test_router_with_options(
    adapter: Arc<MemoryAdapter>,
    options: RustAuthOptions,
) -> Result<AuthRouter, RustAuthError> {
    let adapter: Arc<dyn DbAdapter> = adapter;
    let context = create_auth_context_with_adapter(with_test_defaults(options), adapter.clone())?;
    AuthRouter::with_async_endpoints(context, Vec::new(), core_auth_async_endpoints())
}