rustauth-passkey 0.3.0

Server-side passkey plugin for RustAuth.
Documentation
use http::{Method, StatusCode};
use rustauth_core::db::{DbAdapter, DbValue, FindOne, Where};
use rustauth_passkey::{PasskeyManagementOptions, PasskeyOptions};
use serde_json::Value;

use crate::support::{
    empty_request, json_request, seed_passkey, seed_user_two, seeded_router, session_cookie_for,
    session_cookie_for_created_at,
};

#[tokio::test]
async fn update_and_delete_require_passkey_ownership() -> Result<(), Box<dyn std::error::Error>> {
    let (adapter, router, _backend) = seeded_router(PasskeyOptions::default()).await?;
    seed_user_two(adapter.as_ref()).await?;
    seed_passkey(
        adapter.as_ref(),
        "passkey_1",
        "user_1",
        "original",
        "credential-id",
    )
    .await?;
    let user_two_cookie = session_cookie_for(&adapter, "user_2", "token_2").await?;

    let update = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/passkey/update-passkey",
            r#"{"id":"passkey_1","name":"hacked"}"#,
            Some(&user_two_cookie),
        )?)
        .await?;
    assert_eq!(update.status(), StatusCode::UNAUTHORIZED);
    let body: Value = serde_json::from_slice(update.body())?;
    assert_eq!(body["code"], "YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY");

    let delete = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/passkey/delete-passkey",
            r#"{"id":"passkey_1"}"#,
            Some(&user_two_cookie),
        )?)
        .await?;
    assert_eq!(delete.status(), StatusCode::UNAUTHORIZED);
    let body: Value = serde_json::from_slice(delete.body())?;
    assert_eq!(body["code"], "UNAUTHORIZED");

    let unchanged = adapter
        .find_one(
            FindOne::new("passkey")
                .where_clause(Where::new("id", DbValue::String("passkey_1".to_owned()))),
        )
        .await?
        .ok_or("missing passkey")?;
    assert_eq!(
        unchanged.get("name"),
        Some(&DbValue::String("original".to_owned()))
    );
    Ok(())
}

#[tokio::test]
async fn list_passkeys_serializes_upstream_credential_id_field(
) -> Result<(), Box<dyn std::error::Error>> {
    let (adapter, router, _backend) = seeded_router(PasskeyOptions::default()).await?;
    seed_passkey(
        adapter.as_ref(),
        "passkey_1",
        "user_1",
        "Laptop",
        "credential-id",
    )
    .await?;
    let session_cookie = session_cookie_for(&adapter, "user_1", "token_1").await?;

    let response = router
        .handle_async(empty_request(
            Method::GET,
            "/api/auth/passkey/list-user-passkeys",
            Some(&session_cookie),
        )?)
        .await?;

    assert_eq!(response.status(), StatusCode::OK);
    let body: Value = serde_json::from_slice(response.body())?;
    assert_eq!(body[0]["credentialID"], "credential-id");
    assert!(body[0].get("credentialId").is_none());
    Ok(())
}

#[tokio::test]
async fn update_and_delete_missing_passkey_return_not_found(
) -> Result<(), Box<dyn std::error::Error>> {
    let (adapter, router, _backend) = seeded_router(PasskeyOptions::default()).await?;
    let session_cookie = session_cookie_for(&adapter, "user_1", "token_1").await?;

    let update = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/passkey/update-passkey",
            r#"{"id":"missing","name":"new"}"#,
            Some(&session_cookie),
        )?)
        .await?;
    assert_eq!(update.status(), StatusCode::NOT_FOUND);
    let body: Value = serde_json::from_slice(update.body())?;
    assert_eq!(body["code"], "PASSKEY_NOT_FOUND");

    let delete = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/passkey/delete-passkey",
            r#"{"id":"missing"}"#,
            Some(&session_cookie),
        )?)
        .await?;
    assert_eq!(delete.status(), StatusCode::NOT_FOUND);
    let body: Value = serde_json::from_slice(delete.body())?;
    assert_eq!(body["code"], "PASSKEY_NOT_FOUND");
    Ok(())
}

#[tokio::test]
async fn delete_passkey_rejects_stale_session() -> Result<(), Box<dyn std::error::Error>> {
    let (adapter, router, _backend) = seeded_router(PasskeyOptions::default()).await?;
    seed_passkey(
        adapter.as_ref(),
        "passkey_1",
        "user_1",
        "Laptop",
        "credential-id",
    )
    .await?;
    let stale_cookie = session_cookie_for_created_at(
        adapter.as_ref(),
        "user_1",
        "stale-delete-token",
        time::OffsetDateTime::now_utc() - time::Duration::days(2),
    )
    .await?;

    let response = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/passkey/delete-passkey",
            r#"{"id":"passkey_1"}"#,
            Some(&stale_cookie),
        )?)
        .await?;

    assert_eq!(response.status(), StatusCode::FORBIDDEN);
    let body: Value = serde_json::from_slice(response.body())?;
    assert_eq!(body["code"], "SESSION_NOT_FRESH");
    assert_eq!(adapter.len("passkey").await, 1);
    Ok(())
}

#[tokio::test]
async fn update_passkey_rejects_stale_session() -> Result<(), Box<dyn std::error::Error>> {
    let (adapter, router, _backend) = seeded_router(PasskeyOptions::default()).await?;
    seed_passkey(
        adapter.as_ref(),
        "passkey_1",
        "user_1",
        "Laptop",
        "credential-id",
    )
    .await?;
    let stale_cookie = session_cookie_for_created_at(
        adapter.as_ref(),
        "user_1",
        "stale-update-token",
        time::OffsetDateTime::now_utc() - time::Duration::days(2),
    )
    .await?;

    let response = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/passkey/update-passkey",
            r#"{"id":"passkey_1","name":"Renamed"}"#,
            Some(&stale_cookie),
        )?)
        .await?;

    assert_eq!(response.status(), StatusCode::FORBIDDEN);
    let body: Value = serde_json::from_slice(response.body())?;
    assert_eq!(body["code"], "SESSION_NOT_FRESH");
    let unchanged = adapter
        .find_one(
            FindOne::new("passkey")
                .where_clause(Where::new("id", DbValue::String("passkey_1".to_owned()))),
        )
        .await?
        .ok_or("missing passkey")?;
    assert_eq!(
        unchanged.get("name"),
        Some(&DbValue::String("Laptop".to_owned()))
    );
    Ok(())
}

#[tokio::test]
async fn management_mutations_accept_fresh_session() -> Result<(), Box<dyn std::error::Error>> {
    let (adapter, router, _backend) = seeded_router(PasskeyOptions::default()).await?;
    seed_passkey(
        adapter.as_ref(),
        "passkey_1",
        "user_1",
        "Laptop",
        "credential-id",
    )
    .await?;
    let fresh_cookie = session_cookie_for_created_at(
        adapter.as_ref(),
        "user_1",
        "fresh-management-token",
        time::OffsetDateTime::now_utc(),
    )
    .await?;

    let update = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/passkey/update-passkey",
            r#"{"id":"passkey_1","name":"Work Laptop"}"#,
            Some(&fresh_cookie),
        )?)
        .await?;
    assert_eq!(update.status(), StatusCode::OK);
    let body: Value = serde_json::from_slice(update.body())?;
    assert_eq!(body["passkey"]["name"], "Work Laptop");

    let delete = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/passkey/delete-passkey",
            r#"{"id":"passkey_1"}"#,
            Some(&fresh_cookie),
        )?)
        .await?;
    assert_eq!(delete.status(), StatusCode::OK);
    assert_eq!(adapter.len("passkey").await, 0);
    Ok(())
}

#[tokio::test]
async fn management_mutations_skip_freshness_when_disabled(
) -> Result<(), Box<dyn std::error::Error>> {
    let options = PasskeyOptions::default()
        .management(PasskeyManagementOptions::new().require_fresh_session(false));
    let (adapter, router, _backend) = seeded_router(options).await?;
    seed_passkey(
        adapter.as_ref(),
        "passkey_1",
        "user_1",
        "Laptop",
        "credential-id",
    )
    .await?;
    let stale_cookie = session_cookie_for_created_at(
        adapter.as_ref(),
        "user_1",
        "stale-opt-out-token",
        time::OffsetDateTime::now_utc() - time::Duration::days(2),
    )
    .await?;

    let delete = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/passkey/delete-passkey",
            r#"{"id":"passkey_1"}"#,
            Some(&stale_cookie),
        )?)
        .await?;
    assert_eq!(delete.status(), StatusCode::OK);
    assert_eq!(adapter.len("passkey").await, 0);
    Ok(())
}