openauth-plugins 0.0.5

Official OpenAuth plugin modules.
Documentation
use std::collections::BTreeMap;
use std::sync::Arc;

use http::{Method, StatusCode};
use openauth_core::api::{core_auth_async_endpoints, AuthRouter};
use openauth_core::context::create_auth_context_with_adapter;
use openauth_core::db::{DbAdapter, MemoryAdapter};
use openauth_core::options::{AdvancedOptions, BackgroundTaskRunner, OpenAuthOptions};
use openauth_plugins::api_key::{
    api_key, api_key_with_options, ApiKeyConfiguration, ApiKeyGeneratorInput, ApiKeyOptions,
    INVALID_API_KEY, KEY_NOT_FOUND, RATE_LIMIT_EXCEEDED,
};
use serde_json::json;

use super::helpers::{request_json, sign_up, CountingBackgroundRunner};

#[tokio::test]
async fn verification_decrements_remaining_and_blocks_exhausted_key(
) -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(MemoryAdapter::new());
    let router = super::helpers::test_router(adapter, api_key())?;
    let user = sign_up(&router, "Dee", "dee-api@example.com").await?;

    let created = request_json(
        &router,
        Method::POST,
        "/api/auth/api-key/create",
        json!({"name":"limited","userId": user.user_id, "remaining":1}),
        None,
        None,
    )
    .await?;
    assert_eq!(created.status, StatusCode::OK);
    let key = created.body["key"].as_str().ok_or("missing key")?;

    let first = request_json(
        &router,
        Method::POST,
        "/api/auth/api-key/verify",
        json!({"key": key}),
        None,
        None,
    )
    .await?;
    assert_eq!(first.body["valid"], true);
    assert_eq!(first.body["key"]["remaining"], 0);

    let second = request_json(
        &router,
        Method::POST,
        "/api/auth/api-key/verify",
        json!({"key": key}),
        None,
        None,
    )
    .await?;
    assert_eq!(second.body["valid"], false);
    assert_eq!(second.body["error"]["code"], "USAGE_EXCEEDED");
    Ok(())
}

#[tokio::test]
async fn verification_enforces_rate_limit_window() -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(MemoryAdapter::new());
    let router = super::helpers::test_router(adapter, api_key())?;
    let user = sign_up(&router, "Eon", "eon-api@example.com").await?;
    let created = request_json(
        &router,
        Method::POST,
        "/api/auth/api-key/create",
        json!({
            "name": "burst",
            "userId": user.user_id,
            "rateLimitMax": 1,
            "rateLimitTimeWindow": 60_000
        }),
        None,
        None,
    )
    .await?;
    let key = created.body["key"].as_str().ok_or("missing api key")?;

    let first = request_json(
        &router,
        Method::POST,
        "/api/auth/api-key/verify",
        json!({"key": key}),
        None,
        None,
    )
    .await?;
    assert_eq!(first.body["valid"], true);

    let second = request_json(
        &router,
        Method::POST,
        "/api/auth/api-key/verify",
        json!({"key": key}),
        None,
        None,
    )
    .await?;
    assert_eq!(second.body["valid"], false);
    assert_eq!(second.body["error"]["code"], RATE_LIMIT_EXCEEDED);
    assert!(second.body["error"]["tryAgainIn"]
        .as_i64()
        .is_some_and(|value| value > 0));
    Ok(())
}

#[tokio::test]
async fn verification_refills_remaining_after_interval() -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(MemoryAdapter::new());
    let router = super::helpers::test_router(adapter, api_key())?;
    let user = sign_up(&router, "Fin", "fin-api@example.com").await?;
    let created = request_json(
        &router,
        Method::POST,
        "/api/auth/api-key/create",
        json!({
            "name": "refill",
            "userId": user.user_id,
            "remaining": 1,
            "refillAmount": 2,
            "refillInterval": 1
        }),
        None,
        None,
    )
    .await?;
    let key = created.body["key"].as_str().ok_or("missing api key")?;

    let first = request_json(
        &router,
        Method::POST,
        "/api/auth/api-key/verify",
        json!({"key": key}),
        None,
        None,
    )
    .await?;
    assert_eq!(first.body["valid"], true);

    tokio::time::sleep(std::time::Duration::from_millis(2)).await;

    let second = request_json(
        &router,
        Method::POST,
        "/api/auth/api-key/verify",
        json!({"key": key}),
        None,
        None,
    )
    .await?;
    assert_eq!(second.body["valid"], true);
    assert_eq!(second.body["key"]["remaining"], 1);
    Ok(())
}

#[tokio::test]
async fn deferred_updates_use_background_runner_when_configured(
) -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(MemoryAdapter::new());
    let runner = Arc::new(CountingBackgroundRunner::default());
    let runner_for_options: Arc<dyn BackgroundTaskRunner> = runner.clone();
    let context = create_auth_context_with_adapter(
        OpenAuthOptions {
            plugins: vec![api_key_with_options(ApiKeyOptions {
                configuration: ApiKeyConfiguration {
                    defer_updates: true,
                    ..ApiKeyConfiguration::default()
                },
            })],
            advanced: AdvancedOptions::default().background_tasks(runner_for_options),
            base_url: Some("http://localhost:3000".to_owned()),
            secret: Some("secret-a-at-least-32-chars-long!!".to_owned()),
            ..OpenAuthOptions::default()
        },
        adapter.clone(),
    )?;
    let adapter_dyn: Arc<dyn DbAdapter> = adapter;
    let router = AuthRouter::with_async_endpoints(
        context,
        Vec::new(),
        core_auth_async_endpoints(adapter_dyn),
    )?;
    let user = sign_up(&router, "Gen", "gen-api@example.com").await?;
    let created = request_json(
        &router,
        Method::POST,
        "/api/auth/api-key/create",
        json!({"name":"deferred"}),
        Some(&user.cookie),
        None,
    )
    .await?;
    let key = created.body["key"].as_str().ok_or("missing api key")?;

    let verified = request_json(
        &router,
        Method::POST,
        "/api/auth/api-key/verify",
        json!({"key": key}),
        None,
        None,
    )
    .await?;
    assert_eq!(verified.body["valid"], true);
    assert_eq!(runner.calls(), 1);
    Ok(())
}

#[tokio::test]
async fn verification_enforces_permissions() -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(MemoryAdapter::new());
    let router = super::helpers::test_router(adapter, api_key())?;
    let user = sign_up(&router, "Han", "han-api@example.com").await?;
    let created = request_json(
        &router,
        Method::POST,
        "/api/auth/api-key/create",
        json!({
            "name":"scoped",
            "userId": user.user_id,
            "permissions": {"post": ["read"]}
        }),
        None,
        None,
    )
    .await?;
    let key = created.body["key"].as_str().ok_or("missing api key")?;

    let allowed = request_json(
        &router,
        Method::POST,
        "/api/auth/api-key/verify",
        json!({"key": key, "permissions": {"post": ["read"]}}),
        None,
        None,
    )
    .await?;
    assert_eq!(allowed.body["valid"], true);

    let denied = request_json(
        &router,
        Method::POST,
        "/api/auth/api-key/verify",
        json!({"key": key, "permissions": {"post": ["delete"]}}),
        None,
        None,
    )
    .await?;
    assert_eq!(denied.body["valid"], false);
    assert_eq!(denied.body["error"]["code"], KEY_NOT_FOUND);
    Ok(())
}

#[tokio::test]
async fn default_permissions_are_applied_on_create() -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(MemoryAdapter::new());
    let router = super::helpers::test_router(
        adapter,
        api_key_with_options(ApiKeyOptions {
            configuration: ApiKeyConfiguration {
                default_permissions: Some(BTreeMap::from([(
                    "post".to_owned(),
                    vec!["read".to_owned()],
                )])),
                ..ApiKeyConfiguration::default()
            },
        }),
    )?;
    let user = sign_up(&router, "Ian", "ian-api@example.com").await?;
    let created = request_json(
        &router,
        Method::POST,
        "/api/auth/api-key/create",
        json!({"name":"default-scope"}),
        Some(&user.cookie),
        None,
    )
    .await?;
    assert_eq!(created.status, StatusCode::OK);
    assert_eq!(created.body["permissions"]["post"][0], "read");
    let key = created.body["key"].as_str().ok_or("missing api key")?;

    let verified = request_json(
        &router,
        Method::POST,
        "/api/auth/api-key/verify",
        json!({"key": key, "permissions": {"post": ["read"]}}),
        None,
        None,
    )
    .await?;
    assert_eq!(verified.status, StatusCode::OK);
    assert_eq!(verified.body["valid"], true);
    Ok(())
}

#[tokio::test]
async fn custom_key_generator_and_validator_are_used() -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(MemoryAdapter::new());
    let router = super::helpers::test_router(
        adapter,
        api_key_with_options(ApiKeyOptions {
            configuration: ApiKeyConfiguration {
                custom_key_generator: Some(Arc::new(|input: ApiKeyGeneratorInput| {
                    Box::pin(
                        async move { Ok(format!("{}blocked", input.prefix.unwrap_or_default())) },
                    )
                })),
                custom_api_key_validator: Some(Arc::new(|_context, key| {
                    let key = key.to_owned();
                    Box::pin(async move { Ok(key != "blocked") })
                })),
                ..ApiKeyConfiguration::default()
            },
        }),
    )?;
    let user = sign_up(&router, "Ivy", "ivy-api@example.com").await?;

    let created = request_json(
        &router,
        Method::POST,
        "/api/auth/api-key/create",
        json!({"name":"custom"}),
        Some(&user.cookie),
        None,
    )
    .await?;
    assert_eq!(created.status, StatusCode::OK);
    assert_eq!(created.body["key"], "blocked");

    let verified = request_json(
        &router,
        Method::POST,
        "/api/auth/api-key/verify",
        json!({"key": "blocked"}),
        None,
        None,
    )
    .await?;
    assert_eq!(verified.status, StatusCode::OK);
    assert_eq!(verified.body["valid"], false);
    assert_eq!(verified.body["error"]["code"], INVALID_API_KEY);
    Ok(())
}