openauth-plugins 0.0.4

Official OpenAuth plugin modules.
Documentation
mod helpers;

use helpers::*;
use http::{header, Method, StatusCode};
use openauth_plugins::one_time_token::{
    default_key_hasher, one_time_token, one_time_token_with_options, OneTimeTokenOptions,
    StoreToken,
};
use serde_json::Value;
use time::{Duration, OffsetDateTime};

#[tokio::test]
async fn registers_generate_and_verify_endpoints() -> Result<(), Box<dyn std::error::Error>> {
    let (_adapter, router) = router_with_plugin(one_time_token())?;
    let registry = router.endpoint_registry();

    assert!(registry.iter().any(|endpoint| {
        endpoint.path == "/one-time-token/generate" && endpoint.method == Method::GET
    }));
    assert!(registry.iter().any(|endpoint| {
        endpoint.path == "/one-time-token/verify" && endpoint.method == Method::POST
    }));
    Ok(())
}

#[tokio::test]
async fn endpoints_expose_openapi_metadata() -> Result<(), Box<dyn std::error::Error>> {
    let (_adapter, router) = router_with_plugin(one_time_token())?;
    let openapi = router.openapi_schema();

    assert_eq!(
        openapi["paths"]["/one-time-token/generate"]["get"]["operationId"],
        "generateOneTimeToken"
    );
    assert_eq!(
        openapi["paths"]["/one-time-token/generate"]["get"]["responses"]["200"]["content"]
            ["application/json"]["schema"]["properties"]["token"]["type"],
        "string"
    );
    assert_eq!(
        openapi["paths"]["/one-time-token/verify"]["post"]["operationId"],
        "verifyOneTimeToken"
    );
    assert_eq!(
        openapi["paths"]["/one-time-token/verify"]["post"]["requestBody"]["content"]
            ["application/json"]["schema"]["properties"]["token"]["type"],
        "string"
    );
    assert_eq!(
        openapi["paths"]["/one-time-token/verify"]["post"]["responses"]["200"]["content"]
            ["application/json"]["schema"]["properties"]["session"]["$ref"],
        "#/components/schemas/Session"
    );
    Ok(())
}

#[tokio::test]
async fn generated_token_verifies_once_and_sets_session_cookie(
) -> Result<(), Box<dyn std::error::Error>> {
    let (adapter, router) = router_with_plugin(one_time_token())?;
    let cookie = seed_authenticated_session(&adapter, default_session_expires_at()).await?;

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

    let verify = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/one-time-token/verify",
            &format!(r#"{{"token":"{token}"}}"#),
            None,
        )?)
        .await?;
    assert_eq!(verify.status(), StatusCode::OK);
    let verified: Value = serde_json::from_slice(verify.body())?;
    assert_eq!(verified["session"]["token"], "session-token");
    assert_eq!(verified["user"]["email"], "ada@example.com");
    assert!(set_cookie_values(&verify)
        .iter()
        .any(|cookie| cookie.starts_with("better-auth.session_token=")));

    let second = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/one-time-token/verify",
            &format!(r#"{{"token":"{token}"}}"#),
            None,
        )?)
        .await?;
    assert_eq!(second.status(), StatusCode::BAD_REQUEST);
    let second_body: Value = serde_json::from_slice(second.body())?;
    assert_eq!(second_body["message"], "Invalid token");
    Ok(())
}

#[tokio::test]
async fn expired_token_fails_and_is_consumed() -> Result<(), Box<dyn std::error::Error>> {
    let (adapter, router) = router_with_plugin(one_time_token())?;
    seed_user_and_session(&adapter, default_session_expires_at()).await?;
    seed_verification(
        &adapter,
        "one-time-token:expired-token",
        "session-token",
        OffsetDateTime::now_utc() - Duration::minutes(1),
    )
    .await?;

    let response = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/one-time-token/verify",
            r#"{"token":"expired-token"}"#,
            None,
        )?)
        .await?;

    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    let body: Value = serde_json::from_slice(response.body())?;
    assert_eq!(body["message"], "Token expired");
    assert!(
        verification_record(&adapter, "one-time-token:expired-token")
            .await?
            .is_none()
    );
    Ok(())
}

#[tokio::test]
async fn expired_session_fails_with_session_expired() -> Result<(), Box<dyn std::error::Error>> {
    let (adapter, router) = router_with_plugin(one_time_token_with_options(
        OneTimeTokenOptions::default().expires_in_minutes(10),
    ))?;
    seed_user_and_session(&adapter, OffsetDateTime::now_utc() - Duration::minutes(1)).await?;
    seed_verification(
        &adapter,
        "one-time-token:valid-token",
        "session-token",
        OffsetDateTime::now_utc() + Duration::minutes(5),
    )
    .await?;

    let response = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/one-time-token/verify",
            r#"{"token":"valid-token"}"#,
            None,
        )?)
        .await?;

    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    let body: Value = serde_json::from_slice(response.body())?;
    assert_eq!(body["message"], "Session expired");
    Ok(())
}

#[tokio::test]
async fn hashed_storage_uses_default_key_hasher() -> Result<(), Box<dyn std::error::Error>> {
    let options = OneTimeTokenOptions::default()
        .store_token(StoreToken::Hashed)
        .generate_token(|_, _| Ok("123456".to_owned()));
    let (adapter, router) = router_with_plugin(one_time_token_with_options(options))?;
    let cookie = seed_authenticated_session(&adapter, default_session_expires_at()).await?;

    let generate = router
        .handle_async(request(
            Method::GET,
            "/api/auth/one-time-token/generate",
            "",
            Some(&cookie),
        )?)
        .await?;

    assert_eq!(generate.status(), StatusCode::OK);
    let body: Value = serde_json::from_slice(generate.body())?;
    assert_eq!(body["token"], "123456");
    let hashed = default_key_hasher("123456");
    assert!(
        verification_record(&adapter, &format!("one-time-token:{hashed}"))
            .await?
            .is_some()
    );
    assert!(verification_record(&adapter, "one-time-token:123456")
        .await?
        .is_none());
    Ok(())
}

#[tokio::test]
async fn custom_storage_hasher_is_used() -> Result<(), Box<dyn std::error::Error>> {
    let options = OneTimeTokenOptions::default()
        .store_token(StoreToken::custom(|token| Ok(format!("{token}:hashed"))))
        .generate_token(|_, _| Ok("custom-token".to_owned()));
    let (adapter, router) = router_with_plugin(one_time_token_with_options(options))?;
    let cookie = seed_authenticated_session(&adapter, default_session_expires_at()).await?;

    let generate = router
        .handle_async(request(
            Method::GET,
            "/api/auth/one-time-token/generate",
            "",
            Some(&cookie),
        )?)
        .await?;

    assert_eq!(generate.status(), StatusCode::OK);
    assert!(
        verification_record(&adapter, "one-time-token:custom-token:hashed")
            .await?
            .is_some()
    );
    Ok(())
}

#[tokio::test]
async fn disable_set_session_cookie_omits_cookie() -> Result<(), Box<dyn std::error::Error>> {
    let options = OneTimeTokenOptions::default().disable_set_session_cookie(true);
    let (adapter, router) = router_with_plugin(one_time_token_with_options(options))?;
    seed_user_and_session(&adapter, default_session_expires_at()).await?;
    seed_verification(
        &adapter,
        "one-time-token:no-cookie-token",
        "session-token",
        OffsetDateTime::now_utc() + Duration::minutes(5),
    )
    .await?;

    let response = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/one-time-token/verify",
            r#"{"token":"no-cookie-token"}"#,
            None,
        )?)
        .await?;

    assert_eq!(response.status(), StatusCode::OK);
    assert!(set_cookie_values(&response).is_empty());
    Ok(())
}

#[tokio::test]
async fn disable_client_request_rejects_generate_endpoint() -> Result<(), Box<dyn std::error::Error>>
{
    let options = OneTimeTokenOptions::default().disable_client_request(true);
    let (adapter, router) = router_with_plugin(one_time_token_with_options(options))?;
    let cookie = seed_authenticated_session(&adapter, default_session_expires_at()).await?;

    let response = router
        .handle_async(request(
            Method::GET,
            "/api/auth/one-time-token/generate",
            "",
            Some(&cookie),
        )?)
        .await?;

    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    let body: Value = serde_json::from_slice(response.body())?;
    assert_eq!(body["message"], "Client requests are disabled");
    Ok(())
}

#[tokio::test]
async fn set_ott_header_on_new_sign_up_session() -> Result<(), Box<dyn std::error::Error>> {
    let options = OneTimeTokenOptions::default().set_ott_header_on_new_session(true);
    let (_adapter, router) = router_with_plugin(one_time_token_with_options(options))?;

    let response = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/sign-up/email",
            r#"{"name":"Ada","email":"ada@example.com","password":"secret123"}"#,
            None,
        )?)
        .await?;

    assert_eq!(response.status(), StatusCode::OK);
    let ott = response
        .headers()
        .get("set-ott")
        .and_then(|value| value.to_str().ok())
        .ok_or("missing set-ott header")?;
    assert_eq!(ott.len(), 32);
    assert!(response
        .headers()
        .get(header::ACCESS_CONTROL_EXPOSE_HEADERS)
        .and_then(|value| value.to_str().ok())
        .is_some_and(|value| value.split(',').any(|header| header.trim() == "set-ott")));
    Ok(())
}

#[tokio::test]
async fn set_ott_header_on_new_sign_in_session() -> Result<(), Box<dyn std::error::Error>> {
    let options = OneTimeTokenOptions::default().set_ott_header_on_new_session(true);
    let (adapter, router) = router_with_plugin(one_time_token_with_options(options))?;
    seed_user_and_credential_account(&adapter).await?;

    let response = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/sign-in/email",
            r#"{"email":"ada@example.com","password":"secret123"}"#,
            None,
        )?)
        .await?;

    assert_eq!(response.status(), StatusCode::OK);
    assert!(response.headers().get("set-ott").is_some());
    Ok(())
}