better-auth-api 1.0.0-alpha.4

Plugin implementations for better-auth
Documentation
use std::collections::HashMap;

use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::Duration;

use super::*;
use crate::plugins::test_helpers;
use better_auth_core::{CreatePasskey, CreateUser, HttpMethod};

fn passkey_plugin() -> PasskeyPlugin {
    PasskeyPlugin::new()
        .rp_id("localhost")
        .rp_name("Better Auth Test")
        .origin("http://localhost:3000")
}

fn cookie_header(response: &better_auth_core::AuthResponse) -> &str {
    response
        .headers
        .get("Set-Cookie")
        .expect("response should include a Set-Cookie header")
}

fn credential_id(label: &str) -> String {
    URL_SAFE_NO_PAD.encode(label.as_bytes())
}

#[tokio::test]
async fn test_generate_register_options_sets_cookie_and_uses_query_name() {
    let plugin = passkey_plugin();
    let (ctx, user, session) = test_helpers::create_test_context_with_user(
        CreateUser::new()
            .with_email("passkey-test@example.com")
            .with_name("Passkey Tester"),
        Duration::hours(1),
    )
    .await;

    ctx.database
        .create_passkey(CreatePasskey {
            additional_fields: Default::default(),
            user_id: user.id.typed().unwrap().clone(),
            name: Some("Existing Key".to_string()).into(),
            credential_id: credential_id("cred-existing"),
            public_key: "public-key".to_string(),
            counter: 0,
            device_type: "singleDevice".to_string(),
            backed_up: false,
            transports: Some("usb,nfc".to_string()),
            credential: "invalid-stored-passkey".into(),
            aaguid: Some("00000000-0000-0000-0000-000000000000".to_string()).into(),
        })
        .await
        .unwrap();

    let req = test_helpers::create_auth_request(
        HttpMethod::Get,
        "/passkey/generate-register-options",
        Some(&session.token),
        None,
        HashMap::from([
            ("name".to_string(), "Custom Account Label".to_string()),
            (
                "authenticatorAttachment".to_string(),
                "cross-platform".to_string(),
            ),
        ]),
    );

    let response = plugin
        .handle_generate_register_options(&req, &ctx)
        .await
        .unwrap();
    assert_eq!(response.status, 200);
    assert!(cookie_header(&response).contains("better-auth-passkey="));

    let body: serde_json::Value = serde_json::from_slice(&response.body.bytes().unwrap()).unwrap();
    assert!(body["challenge"].is_string());
    assert_eq!(body["user"]["name"], "Custom Account Label");
    assert_eq!(
        body["authenticatorSelection"]["authenticatorAttachment"],
        "cross-platform"
    );
    assert_eq!(
        body["excludeCredentials"][0]["id"],
        credential_id("cred-existing")
    );
    assert_eq!(body["excludeCredentials"][0]["transports"][0], "usb");
}

#[tokio::test]
async fn test_generate_authenticate_options_is_get_and_sets_cookie_without_auth() {
    let plugin = passkey_plugin();
    let ctx = test_helpers::create_test_context().await;
    let req = test_helpers::create_auth_request_no_query(
        HttpMethod::Get,
        "/passkey/generate-authenticate-options",
        None,
        None,
    );

    let response = plugin
        .handle_generate_authenticate_options(&req, &ctx)
        .await
        .unwrap();
    assert_eq!(response.status, 200);
    assert!(cookie_header(&response).contains("better-auth-passkey="));

    let body: serde_json::Value = serde_json::from_slice(&response.body.bytes().unwrap()).unwrap();
    assert!(body["challenge"].is_string());
    assert!(body.get("allowCredentials").is_none());
}

#[tokio::test]
async fn test_generate_authenticate_options_with_auth_lists_allow_credentials() {
    let plugin = passkey_plugin();
    let (ctx, user, session) = test_helpers::create_test_context_with_user(
        CreateUser::new()
            .with_email("passkey-test@example.com")
            .with_name("Passkey Tester"),
        Duration::hours(1),
    )
    .await;

    ctx.database
        .create_passkey(CreatePasskey {
            additional_fields: Default::default(),
            user_id: user.id.typed().unwrap().clone(),
            name: Some("Authenticator".to_string()).into(),
            credential_id: credential_id("cred-auth"),
            public_key: "public-key".to_string(),
            counter: 0,
            device_type: "singleDevice".to_string(),
            backed_up: false,
            transports: Some("internal".to_string()),
            credential: "invalid-stored-passkey".into(),
            aaguid: None.into(),
        })
        .await
        .unwrap();

    let req = test_helpers::create_auth_request_no_query(
        HttpMethod::Get,
        "/passkey/generate-authenticate-options",
        Some(&session.token),
        None,
    );

    let response = plugin
        .handle_generate_authenticate_options(&req, &ctx)
        .await
        .unwrap();
    assert_eq!(response.status, 200);

    let body: serde_json::Value = serde_json::from_slice(&response.body.bytes().unwrap()).unwrap();
    assert_eq!(
        body["allowCredentials"][0]["id"],
        credential_id("cred-auth")
    );
    assert_eq!(body["allowCredentials"][0]["transports"][0], "internal");
}

#[tokio::test]
async fn test_verify_registration_without_challenge_cookie_returns_challenge_not_found() {
    let plugin = passkey_plugin();
    let (ctx, _user, session) = test_helpers::create_test_context_with_user(
        CreateUser::new()
            .with_email("passkey-test@example.com")
            .with_name("Passkey Tester"),
        Duration::hours(1),
    )
    .await;

    let body = serde_json::json!({
        "response": {
            "id": "fake-credential-id",
            "rawId": "ZmFrZS1yYXctaWQ",
            "response": {
                "attestationObject": "ZmFrZS1hdHRlc3RhdGlvbg",
                "clientDataJSON": "ZmFrZS1jbGllbnQtZGF0YQ"
            },
            "type": "public-key"
        }
    });
    let mut req = test_helpers::create_auth_json_request_no_query(
        HttpMethod::Post,
        "/passkey/verify-registration",
        Some(&session.token),
        Some(body),
    );
    req.headers
        .insert("origin".to_string(), "http://localhost:3000".to_string());

    let response = plugin.handle_verify_registration(&req, &ctx).await.unwrap();
    assert_eq!(response.status, 400);

    let body: serde_json::Value = serde_json::from_slice(&response.body.bytes().unwrap()).unwrap();
    assert_eq!(body["message"], "Challenge not found");
}

#[tokio::test]
async fn test_verify_authentication_without_challenge_cookie_returns_challenge_not_found() {
    let plugin = passkey_plugin();
    let ctx = test_helpers::create_test_context().await;

    let body = serde_json::json!({
        "response": {
            "id": credential_id("cred-auth"),
            "rawId": credential_id("cred-auth"),
            "response": {
                "authenticatorData": "ZmFrZS1hdXRoLWRhdGE",
                "clientDataJSON": "ZmFrZS1jbGllbnQtZGF0YQ",
                "signature": "ZmFrZS1zaWduYXR1cmU"
            },
            "type": "public-key"
        }
    });
    let mut req = test_helpers::create_auth_json_request_no_query(
        HttpMethod::Post,
        "/passkey/verify-authentication",
        None,
        Some(body),
    );
    req.headers
        .insert("origin".to_string(), "http://localhost:3000".to_string());

    let response = plugin
        .handle_verify_authentication(&req, &ctx)
        .await
        .unwrap();
    assert_eq!(response.status, 400);

    let body: serde_json::Value = serde_json::from_slice(&response.body.bytes().unwrap()).unwrap();
    assert_eq!(body["message"], "Challenge not found");
}

#[tokio::test]
async fn test_list_user_passkeys_matches_upstream_optional_fields() {
    let plugin = passkey_plugin();
    let (ctx, user, session) = test_helpers::create_test_context_with_user(
        CreateUser::new()
            .with_email("passkey-test@example.com")
            .with_name("Passkey Tester"),
        Duration::hours(1),
    )
    .await;

    ctx.database
        .create_passkey(CreatePasskey {
            additional_fields: Default::default(),
            user_id: user.id.typed().unwrap().clone(),
            name: None.into(),
            credential_id: credential_id("cred-list"),
            public_key: "public-key".to_string(),
            counter: 0,
            device_type: "singleDevice".to_string(),
            backed_up: false,
            transports: None,
            credential: "invalid-stored-passkey".into(),
            aaguid: Some("00000000-0000-0000-0000-000000000000".to_string()).into(),
        })
        .await
        .unwrap();

    let req = test_helpers::create_auth_request_no_query(
        HttpMethod::Get,
        "/passkey/list-user-passkeys",
        Some(&session.token),
        None,
    );
    let response = plugin.handle_list_user_passkeys(&req, &ctx).await.unwrap();
    assert_eq!(response.status, 200);

    let body: serde_json::Value = serde_json::from_slice(&response.body.bytes().unwrap()).unwrap();
    assert!(body[0].get("updatedAt").is_none());
    assert_eq!(body[0]["aaguid"], "00000000-0000-0000-0000-000000000000");
    assert_eq!(body[0].get("name"), Some(&serde_json::Value::Null));
}

#[tokio::test]
async fn test_delete_passkey_non_owner_is_forbidden() {
    let plugin = passkey_plugin();
    let (ctx, _user, session) = test_helpers::create_test_context_with_user(
        CreateUser::new()
            .with_email("owner@example.com")
            .with_name("Owner"),
        Duration::hours(1),
    )
    .await;
    let other = test_helpers::create_user(
        &ctx,
        CreateUser::new()
            .with_email("other@example.com")
            .with_name("Other"),
    )
    .await;

    let passkey = ctx
        .database
        .create_passkey(CreatePasskey {
            additional_fields: Default::default(),
            user_id: other.id.typed().unwrap().clone(),
            name: Some("Other Key".to_string()).into(),
            credential_id: credential_id("cred-other-delete"),
            public_key: "public-key".to_string(),
            counter: 0,
            device_type: "singleDevice".to_string(),
            backed_up: false,
            transports: None,
            credential: "invalid-stored-passkey".into(),
            aaguid: None.into(),
        })
        .await
        .unwrap();

    let req = test_helpers::create_auth_json_request_no_query(
        HttpMethod::Post,
        "/passkey/delete-passkey",
        Some(&session.token),
        Some(serde_json::json!({ "id": passkey.id })),
    );

    let err = plugin.handle_delete_passkey(&req, &ctx).await.unwrap_err();
    assert_eq!(err.status_code(), 403);
    assert_eq!(err.to_string(), "Unauthorized");
}

#[tokio::test]
async fn test_update_passkey_non_owner_uses_ts_error_message() {
    let plugin = passkey_plugin();
    let (ctx, _user, session) = test_helpers::create_test_context_with_user(
        CreateUser::new()
            .with_email("owner@example.com")
            .with_name("Owner"),
        Duration::hours(1),
    )
    .await;
    let other = test_helpers::create_user(
        &ctx,
        CreateUser::new()
            .with_email("other@example.com")
            .with_name("Other"),
    )
    .await;

    let passkey = ctx
        .database
        .create_passkey(CreatePasskey {
            additional_fields: Default::default(),
            user_id: other.id.typed().unwrap().clone(),
            name: Some("Other Key".to_string()).into(),
            credential_id: credential_id("cred-other-update"),
            public_key: "public-key".to_string(),
            counter: 0,
            device_type: "singleDevice".to_string(),
            backed_up: false,
            transports: None,
            credential: "invalid-stored-passkey".into(),
            aaguid: None.into(),
        })
        .await
        .unwrap();

    let req = test_helpers::create_auth_json_request_no_query(
        HttpMethod::Post,
        "/passkey/update-passkey",
        Some(&session.token),
        Some(serde_json::json!({
            "id": passkey.id,
            "name": "Hijacked",
        })),
    );

    let err = plugin.handle_update_passkey(&req, &ctx).await.unwrap_err();
    assert_eq!(err.status_code(), 403);
    assert_eq!(
        err.to_string(),
        "You are not allowed to register this passkey"
    );
}