rustauth-plugins 0.2.0

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

use http::{header, Method, Response, StatusCode};
use rustauth_core::db::MemoryAdapter;
use rustauth_core::plugin::{AuthPlugin, PluginAfterHookAction};
use rustauth_plugins::jwt::{
    jwt, verify_jwt, verify_jwt_with_options, JwkAlgorithm, JwtJwksOptions, JwtOptions,
    JwtSigningOptions,
};
use serde_json::Value;

use super::helpers::*;

#[tokio::test]
async fn sign_and_verify_endpoints_are_server_only() -> Result<(), Box<dyn std::error::Error>> {
    let router = router_with_plugin(Arc::new(MemoryAdapter::new()), jwt(JwtOptions::default())?)?;

    let sign = router
        .handle_async(request(
            Method::POST,
            "/api/auth/sign-jwt",
            r#"{"payload":{"sub":"user_1"}}"#,
            None,
        )?)
        .await?;
    let verify = router
        .handle_async(request(
            Method::POST,
            "/api/auth/verify-jwt",
            r#"{"token":"malformed"}"#,
            None,
        )?)
        .await?;

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

#[tokio::test]
async fn sign_and_verify_endpoints_are_reachable_through_handle_async_server(
) -> Result<(), Box<dyn std::error::Error>> {
    let router = router_with_plugin(Arc::new(MemoryAdapter::new()), jwt(JwtOptions::default())?)?;

    let sign = router
        .handle_async_server(request(
            Method::POST,
            "/api/auth/sign-jwt",
            r#"{"payload":{"sub":"user_1"}}"#,
            None,
        )?)
        .await?;
    assert_eq!(sign.status(), StatusCode::OK);
    let token = serde_json::from_slice::<Value>(sign.body())?["token"]
        .as_str()
        .ok_or("missing token")?
        .to_owned();

    let verify = router
        .handle_async_server(request(
            Method::POST,
            "/api/auth/verify-jwt",
            &format!(r#"{{"token":"{token}"}}"#),
            None,
        )?)
        .await?;
    assert_eq!(verify.status(), StatusCode::OK);
    let payload = serde_json::from_slice::<Value>(verify.body())?;
    assert_eq!(payload["payload"]["sub"], "user_1");
    Ok(())
}

#[tokio::test]
async fn sign_jwt_endpoint_accepts_serializable_override_options(
) -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(MemoryAdapter::new());
    let plugin = jwt(JwtOptions::default())?;
    let endpoint = plugin
        .endpoints
        .iter()
        .find(|endpoint| endpoint.path == "/sign-jwt")
        .ok_or("missing sign-jwt endpoint")?
        .clone();
    let context = rustauth_core::context::create_auth_context_with_adapter(
        options_with_plugin(plugin),
        adapter,
    )?;

    let response = (endpoint.handler)(
        &context,
        request(
            Method::POST,
            "/api/auth/sign-jwt",
            r#"{"payload":{"sub":"user_1"},"overrideOptions":{"jwt":{"issuer":"https://issuer.example","audience":["https://api.example"],"expirationTime":"1h"}}}"#,
            None,
        )?,
    )
    .await?;
    let body: Value = serde_json::from_slice(response.body())?;
    let token = body["token"].as_str().ok_or("missing token")?;

    assert_eq!(response.status(), StatusCode::OK);
    assert!(verify_jwt(&context, token, None).await?.is_none());
    let claims = verify_jwt_with_options(
        &context,
        token,
        &JwtOptions {
            jwt: JwtSigningOptions {
                audience: Some(vec!["https://api.example".to_owned()]),
                ..JwtSigningOptions::default()
            },
            ..JwtOptions::default()
        },
        Some("https://issuer.example"),
    )
    .await?
    .ok_or("missing verified claims")?;
    assert_eq!(claims["sub"], "user_1");
    assert_eq!(claims["iss"], "https://issuer.example");
    assert_eq!(claims["aud"], "https://api.example");
    Ok(())
}

#[tokio::test]
async fn get_session_merges_exposed_headers() -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(MemoryAdapter::new());
    seed_user_session(adapter.as_ref()).await?;
    let preexisting = AuthPlugin::new("preexisting-expose-header").with_after_hook(
        "/get-session",
        |_context, _request, response| {
            let (mut parts, body) = response.into_parts();
            parts.headers.insert(
                header::ACCESS_CONTROL_EXPOSE_HEADERS,
                header::HeaderValue::from_static("x-existing"),
            );
            Ok(PluginAfterHookAction::Continue(Response::from_parts(
                parts, body,
            )))
        },
    );
    let context = rustauth_core::context::create_auth_context_with_adapter(
        rustauth_core::options::RustAuthOptions {
            base_url: Some(TEST_BASE_URL.to_owned()),
            secret: Some("test-secret-123456789012345678901234".to_owned()),
            plugins: vec![preexisting, jwt(JwtOptions::default())?],
            advanced: rustauth_core::options::AdvancedOptions {
                disable_csrf_check: true,
                disable_origin_check: true,
                ..rustauth_core::options::AdvancedOptions::default()
            },
            ..rustauth_core::options::RustAuthOptions::default()
        },
        adapter.clone(),
    )?;
    let router = rustauth_core::api::AuthRouter::with_async_endpoints(
        context,
        Vec::new(),
        rustauth_core::api::core_auth_async_endpoints(),
    )?;
    let cookie = signed_session_cookie("token_1")?;

    let response = router
        .handle_async(request(
            Method::GET,
            "/api/auth/get-session",
            "",
            Some(&cookie),
        )?)
        .await?;
    let expose = response
        .headers()
        .get(header::ACCESS_CONTROL_EXPOSE_HEADERS)
        .and_then(|value| value.to_str().ok())
        .ok_or("missing expose header")?;

    assert!(expose.contains("x-existing"));
    assert!(expose.contains("set-auth-jwt"));
    Ok(())
}

#[tokio::test]
async fn jwks_drops_keys_expired_beyond_grace() -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(MemoryAdapter::new());
    let options = JwtOptions {
        jwks: JwtJwksOptions {
            rotation_interval: Some(-120),
            grace_period: 1,
            disable_private_key_encryption: true,
            ..JwtJwksOptions::default()
        },
        ..JwtOptions::default()
    };
    let context = rustauth_core::context::create_auth_context_with_adapter(
        options_with_plugin(jwt(options.clone())?),
        adapter.clone(),
    )?;
    let mut claims = rustauth_plugins::jwt::JwtClaims::new();
    claims.insert("sub".to_owned(), serde_json::json!("user_1"));
    rustauth_plugins::jwt::sign_jwt(&context, claims, Some(options.clone())).await?;
    let router = router_with_plugin(adapter, jwt(options)?)?;

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

    assert_eq!(body["keys"].as_array().ok_or("missing keys")?.len(), 0);
    Ok(())
}

#[test]
fn remote_url_accepts_plain_strings_and_query_params() {
    let result = jwt(JwtOptions {
        jwks: JwtJwksOptions {
            remote_url: Some("not a url ?x=1".to_owned()),
            ..JwtJwksOptions::default()
        },
        jwt: JwtSigningOptions {
            sign: Some(Arc::new(|_claims| {
                Box::pin(async move { Ok("remote.jwt.signature".to_owned()) })
            })),
            ..JwtSigningOptions::default()
        },
        ..JwtOptions::default()
    });

    assert!(result.is_ok());
}

#[tokio::test]
async fn token_from_http_validates_against_jwks_kid_and_claims(
) -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(MemoryAdapter::new());
    seed_user_session(adapter.as_ref()).await?;
    let context = rustauth_core::context::create_auth_context_with_adapter(
        options_with_plugin(jwt(JwtOptions::default())?),
        adapter.clone(),
    )?;
    let router = router_with_plugin(adapter, jwt(JwtOptions::default())?)?;
    let cookie = signed_session_cookie("token_1")?;

    let token_response = router
        .handle_async(request(Method::GET, "/api/auth/token", "", Some(&cookie))?)
        .await?;
    assert_eq!(token_response.status(), StatusCode::OK);
    let token = serde_json::from_slice::<Value>(token_response.body())?["token"]
        .as_str()
        .ok_or("missing token")?
        .to_owned();

    let jwks_response = router
        .handle_async(request(Method::GET, "/api/auth/jwks", "", None)?)
        .await?;
    assert_eq!(jwks_response.status(), StatusCode::OK);
    let jwks: Value = serde_json::from_slice(jwks_response.body())?;
    let kid = jwt_kid(&token)?;
    let keys = jwks["keys"].as_array().ok_or("missing keys")?;
    assert!(keys.iter().any(|key| key["kid"] == kid));

    let claims = verify_jwt(&context, &token, None)
        .await?
        .ok_or("token should verify against stored JWKS")?;
    assert_eq!(claims["sub"], "user_1");
    Ok(())
}

#[tokio::test]
async fn remote_url_still_allows_local_signing_without_custom_signer_for_supported_algorithms(
) -> Result<(), Box<dyn std::error::Error>> {
    for algorithm in [
        JwkAlgorithm::EdDsa,
        JwkAlgorithm::Es256,
        JwkAlgorithm::Es512,
        JwkAlgorithm::Rs256,
        JwkAlgorithm::Ps256,
    ] {
        let adapter = Arc::new(MemoryAdapter::new());
        seed_user_session(adapter.as_ref()).await?;
        let options = JwtOptions {
            jwks: JwtJwksOptions {
                remote_url: Some("https://example.com/.well-known/jwks.json".to_owned()),
                key_pair_algorithm: Some(algorithm),
                disable_private_key_encryption: true,
                ..JwtJwksOptions::default()
            },
            ..JwtOptions::default()
        };
        let context = rustauth_core::context::create_auth_context_with_adapter(
            options_with_plugin(jwt(options.clone())?),
            adapter.clone(),
        )?;
        let router = router_with_plugin(adapter, jwt(options)?)?;
        let cookie = signed_session_cookie("token_1")?;

        let response = router
            .handle_async(request(Method::GET, "/api/auth/token", "", Some(&cookie))?)
            .await?;
        let body: Value = serde_json::from_slice(response.body())?;

        assert_eq!(response.status(), StatusCode::OK);
        assert!(verify_jwt(
            &context,
            body["token"].as_str().ok_or("missing token")?,
            None
        )
        .await?
        .is_some());
    }
    Ok(())
}

#[test]
fn rsa_modulus_length_must_be_at_least_2048() {
    let result = jwt(JwtOptions {
        jwks: JwtJwksOptions {
            key_pair_algorithm: Some(JwkAlgorithm::Rs256),
            rsa_modulus_length: Some(1024),
            ..JwtJwksOptions::default()
        },
        ..JwtOptions::default()
    });

    assert!(result.is_err());
}

#[tokio::test]
async fn rsa_modulus_length_can_be_configured() -> Result<(), Box<dyn std::error::Error>> {
    let router = router_with_plugin(
        Arc::new(MemoryAdapter::new()),
        jwt(JwtOptions {
            jwks: JwtJwksOptions {
                key_pair_algorithm: Some(JwkAlgorithm::Rs256),
                rsa_modulus_length: Some(2048),
                ..JwtJwksOptions::default()
            },
            ..JwtOptions::default()
        })?,
    )?;

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

    assert_eq!(body["keys"][0]["alg"], "RS256");
    assert!(body["keys"][0]["n"].as_str().is_some());
    Ok(())
}