openauth-plugins 0.0.4

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

use http::{header, Method, Request, StatusCode};
use openauth_core::api::{core_auth_async_endpoints, AuthRouter};
use openauth_core::context::create_auth_context_with_adapter;
use openauth_core::db::MemoryAdapter;
use openauth_core::error::OpenAuthError;
use openauth_core::options::{AdvancedOptions, OpenAuthOptions};
use openauth_plugins::anonymous::{anonymous, AnonymousOptions};
use openauth_plugins::email_otp::{email_otp, EmailOtpOptions};
use openauth_plugins::generic_oauth::{generic_oauth, GenericOAuthConfig, GenericOAuthOptions};
use openauth_plugins::jwt::jwt;
use openauth_plugins::magic_link::{magic_link, MagicLinkEmail, MagicLinkOptions};
use openauth_plugins::mcp::{mcp, McpOptions};
use openauth_plugins::multi_session::multi_session;
use openauth_plugins::oauth_proxy::oauth_proxy_default;
use openauth_plugins::one_tap::{one_tap, OneTapOptions};
use openauth_plugins::one_time_token::one_time_token;
use openauth_plugins::open_api::{open_api, OpenApiOptions};
use openauth_plugins::organization::{
    organization_with_options, DynamicAccessControlOptions, OrganizationOptions, TeamOptions,
};
use openauth_plugins::phone_number::{phone_number, PhoneNumberOptions};
use openauth_plugins::siwe::{siwe, SiweOptions};
use openauth_plugins::two_factor::{two_factor, TwoFactorOptions};
use openauth_plugins::username::username;
use serde_json::Value;

#[test]
fn exposes_open_api_plugin_builder() {
    let plugin = open_api(OpenApiOptions::default());

    assert_eq!(openauth_plugins::open_api::UPSTREAM_PLUGIN_ID, "open-api");
    assert_eq!(plugin.id, "open-api");
    assert!(plugin
        .endpoints
        .iter()
        .any(|endpoint| endpoint.path == "/open-api/generate-schema"));
    assert!(plugin
        .endpoints
        .iter()
        .any(|endpoint| endpoint.path == "/reference"));
}

#[tokio::test]
async fn generate_schema_endpoint_returns_core_and_plugin_paths(
) -> Result<(), Box<dyn std::error::Error>> {
    let router = router(vec![
        open_api(OpenApiOptions::default()),
        anonymous(AnonymousOptions::default()),
    ])?;

    let response = router
        .handle_async(request(Method::GET, "/api/auth/open-api/generate-schema")?)
        .await?;
    let body: Value = serde_json::from_slice(response.body())?;

    assert_eq!(response.status(), StatusCode::OK);
    assert_eq!(body["openapi"], "3.1.1");
    assert_eq!(
        body["paths"]["/sign-in/email"]["post"]["operationId"],
        "signInEmail"
    );
    assert_eq!(
        body["paths"]["/sign-in/anonymous"]["post"]["operationId"],
        "signInAnonymous"
    );
    assert_eq!(
        body["paths"]["/open-api/generate-schema"]["get"]["operationId"],
        "generateOpenAPISchema"
    );
    assert!(body["paths"]["/reference"].is_null());
    Ok(())
}

#[tokio::test]
async fn generated_schema_includes_detailed_plugin_metadata(
) -> Result<(), Box<dyn std::error::Error>> {
    let router = router(vec![
        open_api(OpenApiOptions::default()),
        anonymous(AnonymousOptions::default()),
    ])?;

    let response = router
        .handle_async(request(Method::GET, "/api/auth/open-api/generate-schema")?)
        .await?;
    let body: Value = serde_json::from_slice(response.body())?;
    let anonymous = &body["paths"]["/sign-in/anonymous"]["post"];

    assert_eq!(anonymous["summary"], "Sign in anonymous");
    assert_eq!(anonymous["tags"][0], "Anonymous");
    assert!(anonymous["description"]
        .as_str()
        .is_some_and(|value| !value.is_empty()));
    assert!(anonymous["responses"]["200"].is_object());
    Ok(())
}

#[tokio::test]
async fn generated_schema_audits_all_server_plugin_endpoints(
) -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(MemoryAdapter::new());
    let router = router(vec![
        open_api(OpenApiOptions::default()),
        anonymous(AnonymousOptions::default()),
        username(),
        multi_session(),
        one_time_token(),
        organization_with_options(
            OrganizationOptions::builder()
                .teams(TeamOptions {
                    enabled: true,
                    create_default_team: true,
                    maximum_teams: None,
                    maximum_members_per_team: None,
                    allow_removing_all_teams: false,
                })
                .dynamic_access_control(DynamicAccessControlOptions {
                    enabled: true,
                    maximum_roles_per_organization: None,
                })
                .build(),
        ),
        jwt()?,
        phone_number(adapter.clone(), PhoneNumberOptions::default()),
        email_otp(adapter.clone(), EmailOtpOptions::default()),
        mcp(McpOptions {
            login_page: "/login".to_owned(),
            ..McpOptions::default()
        })?
        .into_auth_plugin(),
        two_factor(TwoFactorOptions::default()),
        oauth_proxy_default(),
        one_tap(OneTapOptions::default()),
        magic_link(MagicLinkOptions::new(|_email: MagicLinkEmail| {
            Box::pin(async { Ok(()) })
        })),
        siwe(SiweOptions::new(
            "example.com",
            || async { Ok("nonce".to_owned()) },
            |_args| async { Ok(true) },
        ))?,
    ])?;

    let response = router
        .handle_async(request(Method::GET, "/api/auth/open-api/generate-schema")?)
        .await?;
    let body: Value = serde_json::from_slice(response.body())?;
    let paths = body["paths"].as_object().ok_or("missing paths")?;

    assert!(body["paths"]["/reference"].is_null());
    assert!(paths.len() > 80, "expected broad endpoint coverage");

    for (path, methods) in paths {
        let methods = methods
            .as_object()
            .ok_or_else(|| format!("path {path} is not an object"))?;
        for (method, operation) in methods {
            let context = format!("{method} {path}");
            assert_non_empty_string(operation, "operationId", &context);
            assert_non_empty_string(operation, "summary", &context);
            assert_non_empty_string(operation, "description", &context);
            assert!(
                operation["tags"]
                    .as_array()
                    .is_some_and(|tags| !tags.is_empty()),
                "{context} missing tags"
            );
            assert!(
                has_success_response(operation),
                "{context} missing explicit success or redirect response"
            );
            assert_path_parameters_documented(path, operation, &context);
        }
    }

    for (operation_id, required_property, expected_type) in [
        ("signInPhoneNumber", "phoneNumber", "string"),
        ("sendPhoneNumberOTP", "phoneNumber", "string"),
        ("verifyPhoneNumber", "phoneNumber", "string"),
        ("registerMcpClient", "redirect_uris", "array"),
        ("getSiweNonce", "walletAddress", "string"),
        ("verifySiweMessage", "message", "string"),
    ] {
        let operation = find_operation(paths, operation_id)
            .ok_or_else(|| format!("missing operation {operation_id}"))?;
        assert_eq!(
            operation["requestBody"]["content"]["application/json"]["schema"]["properties"]
                [required_property]["type"],
            expected_type,
            "{operation_id} missing documented request body property {required_property}"
        );
    }

    let generic_schema = plugin_only_openapi(generic_oauth(GenericOAuthOptions {
        config: vec![GenericOAuthConfig::new(
            "example",
            "client-id",
            Some("client-secret"),
            "https://oauth.example/authorize",
            "https://oauth.example/token",
        )],
    }))?;
    let generic_paths = generic_schema["paths"]
        .as_object()
        .ok_or("missing generic paths")?;
    for operation_id in ["signInWithOAuth2", "oAuth2LinkAccount"] {
        let operation = find_operation(generic_paths, operation_id)
            .ok_or_else(|| format!("missing operation {operation_id}"))?;
        assert_eq!(
            operation["requestBody"]["content"]["application/json"]["schema"]["properties"]
                ["providerId"]["type"],
            "string"
        );
    }

    Ok(())
}

#[tokio::test]
async fn reference_endpoint_serves_scalar_html() -> Result<(), Box<dyn std::error::Error>> {
    let router = router(vec![open_api(
        OpenApiOptions::default()
            .path("/docs")
            .theme("moon")
            .nonce("nonce-123"),
    )])?;

    let response = router
        .handle_async(request(Method::GET, "/api/auth/docs")?)
        .await?;
    let body = String::from_utf8(response.body().clone())?;

    assert_eq!(response.status(), StatusCode::OK);
    assert_eq!(
        response
            .headers()
            .get(header::CONTENT_TYPE)
            .and_then(|value| value.to_str().ok()),
        Some("text/html; charset=utf-8")
    );
    assert!(body.contains("@scalar/api-reference"));
    assert!(body.contains("theme: \"moon\""));
    assert!(body.contains("nonce=\"nonce-123\""));
    Ok(())
}

#[tokio::test]
async fn reference_endpoint_can_be_disabled() -> Result<(), Box<dyn std::error::Error>> {
    let router = router(vec![open_api(
        OpenApiOptions::default().disable_default_reference(true),
    )])?;

    let response = router
        .handle_async(request(Method::GET, "/api/auth/reference")?)
        .await?;

    assert_eq!(response.status(), StatusCode::NOT_FOUND);
    Ok(())
}

fn router(plugins: Vec<openauth_core::plugin::AuthPlugin>) -> Result<AuthRouter, OpenAuthError> {
    let adapter = Arc::new(MemoryAdapter::default());
    let context = create_auth_context_with_adapter(
        OpenAuthOptions {
            base_url: Some("http://localhost:3000".to_owned()),
            secret: Some("test-secret-123456789012345678901234".to_owned()),
            advanced: AdvancedOptions {
                disable_csrf_check: true,
                disable_origin_check: true,
                ..AdvancedOptions::default()
            },
            plugins,
            ..OpenAuthOptions::default()
        },
        adapter.clone(),
    )?;
    AuthRouter::with_async_endpoints(context, Vec::new(), core_auth_async_endpoints(adapter))
}

fn plugin_only_openapi(
    plugin: openauth_core::plugin::AuthPlugin,
) -> Result<Value, Box<dyn std::error::Error>> {
    let context = create_auth_context_with_adapter(
        OpenAuthOptions {
            base_url: Some("http://localhost:3000".to_owned()),
            secret: Some("test-secret-123456789012345678901234".to_owned()),
            advanced: AdvancedOptions {
                disable_csrf_check: true,
                disable_origin_check: true,
                ..AdvancedOptions::default()
            },
            plugins: vec![plugin],
            ..OpenAuthOptions::default()
        },
        Arc::new(MemoryAdapter::new()),
    )?;
    Ok(AuthRouter::try_new(context, Vec::new())?.openapi_schema())
}

fn request(method: Method, path: &str) -> Result<Request<Vec<u8>>, http::Error> {
    Request::builder()
        .method(method)
        .uri(format!("http://localhost:3000{path}"))
        .body(Vec::new())
}

fn assert_non_empty_string(operation: &Value, field: &str, context: &str) {
    assert!(
        operation[field]
            .as_str()
            .is_some_and(|value| !value.trim().is_empty()),
        "{context} missing {field}"
    );
}

fn has_success_response(operation: &Value) -> bool {
    operation["responses"].as_object().is_some_and(|responses| {
        responses
            .keys()
            .any(|status| status.starts_with('2') || status.starts_with('3'))
    })
}

fn assert_path_parameters_documented(path: &str, operation: &Value, context: &str) {
    for parameter in path
        .split('/')
        .filter_map(|part| part.strip_prefix('{')?.strip_suffix('}'))
    {
        let documented = operation["parameters"]
            .as_array()
            .is_some_and(|parameters| {
                parameters.iter().any(|entry| {
                    entry["name"] == parameter && entry["in"] == "path" && entry["required"] == true
                })
            });
        assert!(
            documented,
            "{context} missing path parameter documentation for {parameter}"
        );
    }
}

fn find_operation<'a>(
    paths: &'a serde_json::Map<String, Value>,
    operation_id: &str,
) -> Option<&'a Value> {
    paths
        .values()
        .filter_map(Value::as_object)
        .flat_map(|methods| methods.values())
        .find(|operation| operation["operationId"] == operation_id)
}