better-auth-api 1.0.0-alpha.2

Plugin implementations for better-auth
Documentation
use super::*;
use crate::plugins::test_helpers;
use better_auth_core::AuthPlugin;
use better_auth_core::wire::{SessionView, UserView};
use better_auth_core::{CreateAccount, CreateUser};
use chrono::Duration;
use cookie::Cookie;

type TestSchema = better_auth_seaorm::store::__private_test_support::bundled_schema::BundledSchema;

async fn create_test_context_with_credential_user(
    email: &str,
    two_factor_enabled: bool,
) -> (AuthContext<TestSchema>, UserView, SessionView) {
    let ctx = test_helpers::create_test_context().await;
    let user = test_helpers::create_user(
        &ctx,
        CreateUser::new()
            .with_email(email)
            .with_name("Two Factor Tester"),
    )
    .await;

    let password_hash = better_auth_core::hash_password(None, "password123")
        .await
        .unwrap();
    _ = ctx
        .database
        .create_account(CreateAccount {
            user_id: user.id.clone(),
            account_id: user.id.clone(),
            provider_id: "credential".to_string(),
            access_token: None,
            refresh_token: None,
            id_token: None,
            access_token_expires_at: None,
            refresh_token_expires_at: None,
            scope: None,
            password: Some(password_hash),
        })
        .await
        .unwrap();

    let user = if two_factor_enabled {
        UserView::from(
            &ctx.database
                .update_user(
                    &user.id,
                    better_auth_core::UpdateUser {
                        two_factor_enabled: Some(true),
                        ..Default::default()
                    },
                )
                .await
                .unwrap(),
        )
    } else {
        user
    };

    let session = test_helpers::create_session(&ctx, user.id.clone(), Duration::hours(1)).await;
    (ctx, user, session)
}

fn cookie_value(header: &str) -> String {
    Cookie::parse(header)
        .expect("Set-Cookie header should parse")
        .value()
        .to_string()
}

#[test]
fn test_signed_cookie_round_trip_and_tamper_rejection() {
    let signed = sign_cookie_value("secret-value", "payload-value").unwrap();
    let verified = verify_signed_cookie_value("secret-value", &signed).unwrap();
    assert_eq!(verified.as_deref(), Some("payload-value"));

    let tampered = signed.replacen("payload-value", "other-value", 1);
    let tampered_verified = verify_signed_cookie_value("secret-value", &tampered).unwrap();
    assert!(tampered_verified.is_none());
}

#[tokio::test]
async fn test_begin_sign_in_challenge_sets_pending_cookie_and_remember_choice() {
    let (ctx, user, _session) =
        create_test_context_with_credential_user("challenge@example.com", true).await;

    let challenge = begin_sign_in_challenge(&user, Some(false), &ctx)
        .await
        .unwrap();
    assert!(challenge.response.two_factor_redirect);

    let two_factor_cookie = challenge
        .set_cookie_headers
        .iter()
        .find(|header| header.starts_with("better-auth.two_factor="))
        .cloned()
        .expect("challenge should set the two-factor cookie");
    let dont_remember_cookie = challenge
        .set_cookie_headers
        .iter()
        .find(|header| header.starts_with("better-auth.dont_remember="))
        .cloned()
        .expect("challenge should set the remember-choice cookie");

    let two_factor_req = test_helpers::create_auth_request_no_query(
        better_auth_core::HttpMethod::Post,
        "/two-factor/verify-otp",
        None,
        None,
    );
    let mut req = two_factor_req;
    req.headers.insert(
        "cookie".to_string(),
        format!(
            "better-auth.two_factor={}; better-auth.dont_remember={}",
            cookie_value(&two_factor_cookie),
            cookie_value(&dont_remember_cookie)
        ),
    );

    let identifier = read_signed_cookie(&req, TWO_FACTOR_COOKIE_SUFFIX, &ctx)
        .unwrap()
        .expect("signed cookie should verify");
    let verification = ctx
        .database
        .get_verification_by_identifier(&identifier)
        .await
        .unwrap()
        .expect("challenge should persist a pending verification");
    assert_eq!(verification.value(), user.id);
}

#[tokio::test]
async fn test_inspect_trusted_device_rotates_server_state() {
    let (ctx, user, _session) =
        create_test_context_with_credential_user("trusted@example.com", true).await;

    let trust_cookie = create_trust_device_cookie_header(&user, &ctx)
        .await
        .unwrap();
    let mut req = test_helpers::create_auth_request_no_query(
        better_auth_core::HttpMethod::Post,
        "/sign-in/email",
        None,
        None,
    );
    req.headers.insert(
        "cookie".to_string(),
        format!("better-auth.trust_device={}", cookie_value(&trust_cookie)),
    );

    let original_cookie = read_signed_cookie(&req, TRUST_DEVICE_COOKIE_SUFFIX, &ctx)
        .unwrap()
        .expect("trust cookie should verify");
    let original_identifier = original_cookie
        .split_once('!')
        .expect("trust cookie should include the identifier")
        .1
        .to_string();

    let result = inspect_trusted_device(&req, &user, &ctx).await.unwrap();
    assert!(result.trusted);
    assert_eq!(result.set_cookie_headers.len(), 1);

    let rotated_cookie = result.set_cookie_headers[0].clone();
    let mut rotated_req = test_helpers::create_auth_request_no_query(
        better_auth_core::HttpMethod::Post,
        "/sign-in/email",
        None,
        None,
    );
    rotated_req.headers.insert(
        "cookie".to_string(),
        format!("better-auth.trust_device={}", cookie_value(&rotated_cookie)),
    );
    let rotated_value = read_signed_cookie(&rotated_req, TRUST_DEVICE_COOKIE_SUFFIX, &ctx)
        .unwrap()
        .expect("rotated trust cookie should verify");
    let rotated_identifier = rotated_value
        .split_once('!')
        .expect("rotated cookie should include the identifier")
        .1
        .to_string();

    assert_ne!(original_identifier, rotated_identifier);
    assert!(
        ctx.database
            .get_verification_by_identifier(&original_identifier)
            .await
            .unwrap()
            .is_none(),
        "the previous trust-device record should be deleted during rotation",
    );
    assert!(
        ctx.database
            .get_verification_by_identifier(&rotated_identifier)
            .await
            .unwrap()
            .is_some(),
        "the rotated trust-device record should be persisted",
    );
}

#[tokio::test]
async fn test_verify_existing_session_factor_enables_two_factor_and_reissues_session() {
    let (ctx, user, session) =
        create_test_context_with_credential_user("reissue@example.com", false).await;

    let (response, set_cookie_headers) =
        verify_existing_session_factor(user.clone(), session.clone(), true, &ctx)
            .await
            .unwrap();

    assert!(!response.user.two_factor_enabled);
    assert_ne!(response.token, session.token);
    assert_eq!(set_cookie_headers.len(), 1);
    assert!(
        ctx.database
            .get_session(&session.token)
            .await
            .unwrap()
            .is_none(),
        "the original session should be deleted after re-issuing",
    );
    assert!(
        ctx.database
            .get_session(&response.token)
            .await
            .unwrap()
            .is_some(),
        "the new session token should be persisted",
    );
}

#[tokio::test]
async fn test_view_backup_codes_returns_decrypted_codes() {
    let plugin = TwoFactorPlugin::new();
    let (ctx, user, _session) =
        create_test_context_with_credential_user("view-codes@example.com", true).await;

    let expected_codes = vec!["ABCDE-12345".to_string(), "FGHIJ-67890".to_string()];
    let encrypted = encrypt_value(
        &ctx.config.secret,
        &serde_json::to_string(&expected_codes).unwrap(),
    )
    .unwrap();
    _ = ctx
        .database
        .create_two_factor(better_auth_core::CreateTwoFactor {
            user_id: user.id.clone(),
            secret: encrypt_value(&ctx.config.secret, "totp-secret").unwrap(),
            backup_codes: encrypted,
        })
        .await
        .unwrap();

    let backup_codes = plugin.view_backup_codes(&user.id, &ctx).await.unwrap();
    assert_eq!(backup_codes, expected_codes);
}

#[tokio::test]
async fn test_view_backup_codes_rejects_invalid_stored_json() {
    let plugin = TwoFactorPlugin::new();
    let (ctx, user, _session) =
        create_test_context_with_credential_user("invalid-view-codes@example.com", true).await;

    _ = ctx
        .database
        .create_two_factor(better_auth_core::CreateTwoFactor {
            user_id: user.id.clone(),
            secret: encrypt_value(&ctx.config.secret, "totp-secret").unwrap(),
            backup_codes: encrypt_value(&ctx.config.secret, "\"not-an-array\"").unwrap(),
        })
        .await
        .unwrap();

    let err = plugin.view_backup_codes(&user.id, &ctx).await.unwrap_err();
    assert_eq!(err.to_string(), "Invalid backup code");
}

#[test]
fn test_routes_do_not_expose_view_backup_codes() {
    let plugin = TwoFactorPlugin::new();
    assert!(
        <TwoFactorPlugin as AuthPlugin<TestSchema>>::routes(&plugin)
            .iter()
            .all(|route| route.path != "/two-factor/view-backup-codes"),
        "view-backup-codes must stay server-only",
    );
}