better-auth-api 1.0.0-alpha.4

Plugin implementations for better-auth
Documentation
use super::*;
use crate::plugins::test_helpers::{create_test_context_with_user, finalize_response};
use better_auth_core::{CreateUser, HttpMethod};

fn verify_request(token: &str) -> AuthRequest {
    let mut req = AuthRequest::new(HttpMethod::Post, "/one-time-token/verify");
    let _ = req
        .headers
        .insert("content-type".into(), "application/json".into());
    req.body = Some(serde_json::to_vec(&serde_json::json!({ "token": token })).unwrap());
    req
}

#[tokio::test]
async fn concurrent_redemption_issues_one_cookie_and_expired_proofs_cannot_authenticate() {
    let (ctx, user, session) = create_test_context_with_user(
        CreateUser::new()
            .with_email("transfer@example.com")
            .with_name("Fixture"),
        Duration::hours(1),
    )
    .await;
    let plugin = OneTimeTokenPlugin::new().store_token(TokenStorage::Hashed);
    let token = plugin
        .generate(&ctx, session.clone(), user.clone())
        .await
        .unwrap();
    assert!(
        ctx.database
            .get_verification_by_identifier(&format!("one-time-token:{token}"))
            .await
            .unwrap()
            .is_none()
    );
    let first_request = verify_request(&token);
    let second_request = verify_request(&token);
    let (first, second) = tokio::join!(
        plugin.handle_verify(&first_request, &ctx),
        plugin.handle_verify(&second_request, &ctx)
    );
    let responses = [
        finalize_response(&ctx, &first_request, first.unwrap()),
        finalize_response(&ctx, &second_request, second.unwrap()),
    ];
    assert_eq!(
        responses
            .iter()
            .filter(|response| response.status == 200)
            .count(),
        1
    );
    assert_eq!(
        responses
            .iter()
            .filter(|response| response.headers.contains_key("set-cookie"))
            .count(),
        1
    );
    assert_eq!(
        responses
            .iter()
            .filter(|response| response.status == 400)
            .count(),
        1
    );
    let expired = OneTimeTokenPlugin::new().expires_in(Duration::seconds(-1));
    let token = expired.generate(&ctx, session, user).await.unwrap();
    let request = verify_request(&token);
    let response = expired.handle_verify(&request, &ctx).await.unwrap();
    let response = finalize_response(&ctx, &request, response);
    assert_eq!(response.status, 400);
    assert!(!response.headers.contains_key("set-cookie"));
    assert!(
        ctx.database
            .get_verification_by_identifier(&format!("one-time-token:{token}"))
            .await
            .unwrap()
            .is_none()
    );
}

#[tokio::test]
async fn custom_hashing_does_not_extend_token_lifetime() {
    let (ctx, user, session) = create_test_context_with_user(
        CreateUser::new()
            .with_email("slow-hash@example.com")
            .with_name("Slow Hash"),
        Duration::hours(1),
    )
    .await;
    let lifetime = Duration::milliseconds(50);
    let hash_times = Arc::new(std::sync::Mutex::new(Vec::new()));
    let callback_times = hash_times.clone();
    let plugin = OneTimeTokenPlugin::new()
        .expires_in(lifetime)
        .store_token(TokenStorage::Custom(Arc::new(move |token| {
            let times = callback_times.clone();
            Box::pin(async move {
                let entered = Utc::now();
                let first = times.lock().unwrap().is_empty();
                if first {
                    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
                }
                times.lock().unwrap().push((entered, Utc::now()));
                Ok(format!("hashed:{token}"))
            })
        })));
    let token = plugin.generate(&ctx, session, user).await.unwrap();
    let identifier = format!("one-time-token:hashed:{token}");
    let proof = ctx
        .database
        .get_verification_including_expired(&identifier)
        .await
        .unwrap()
        .unwrap();
    let (entered, completed) = hash_times.lock().unwrap().first().copied().unwrap();
    assert!(completed - entered > lifetime);
    let expires_at = proof.expires_at.date_milliseconds().unwrap();
    assert!(
        expires_at <= (entered + lifetime).timestamp_millis() as f64,
        "the token deadline must be computed before the custom hasher starts"
    );
    assert!(expires_at < completed.timestamp_millis() as f64);
    for attempt in ["verification", "replay"] {
        let request = verify_request(&token);
        let response = plugin.handle_verify(&request, &ctx).await.unwrap();
        let response = finalize_response(&ctx, &request, response);
        assert_eq!(response.status, 400, "{attempt}");
        assert_eq!(
            serde_json::from_slice::<serde_json::Value>(&response.body.bytes().unwrap()).unwrap(),
            serde_json::json!({ "message": "Invalid token" }),
            "{attempt}"
        );
        assert!(!response.headers.contains_key("set-cookie"), "{attempt}");
        assert!(
            ctx.database
                .get_verification_including_expired(&identifier)
                .await
                .unwrap()
                .is_none(),
            "{attempt} must leave the proof consumed"
        );
    }
}