mirror of
https://github.com/CloudNebulaProject/barycenter.git
synced 2026-04-10 13:10:42 +00:00
Implement more tests
Signed-off-by: Till Wegmueller <toasterson@gmail.com>
This commit is contained in:
parent
a949a3cbdb
commit
eb9c71a49f
7 changed files with 1085 additions and 14 deletions
|
|
@ -42,7 +42,7 @@ impl UserBuilder {
|
|||
self
|
||||
}
|
||||
|
||||
pub async fn create(self, db: &DatabaseConnection) -> entities::user::Model {
|
||||
pub async fn create(self, db: &DatabaseConnection) -> storage::User {
|
||||
let user = storage::create_user(db, &self.username, &self.password, self.email)
|
||||
.await
|
||||
.expect("Failed to create test user");
|
||||
|
|
@ -99,7 +99,7 @@ impl ClientBuilder {
|
|||
self
|
||||
}
|
||||
|
||||
pub async fn create(self, db: &DatabaseConnection) -> entities::client::Model {
|
||||
pub async fn create(self, db: &DatabaseConnection) -> storage::Client {
|
||||
storage::create_client(
|
||||
db,
|
||||
storage::NewClient {
|
||||
|
|
@ -165,10 +165,11 @@ impl SessionBuilder {
|
|||
self
|
||||
}
|
||||
|
||||
pub async fn create(self, db: &DatabaseConnection) -> entities::session::Model {
|
||||
let session = storage::create_session(db, &self.subject, self.auth_time, self.ttl, None, None)
|
||||
.await
|
||||
.expect("Failed to create test session");
|
||||
pub async fn create(self, db: &DatabaseConnection) -> storage::Session {
|
||||
let session =
|
||||
storage::create_session(db, &self.subject, self.auth_time, self.ttl, None, None)
|
||||
.await
|
||||
.expect("Failed to create test session");
|
||||
|
||||
// Update AMR/ACR/MFA if needed
|
||||
if self.amr.is_some() || self.acr.is_some() || self.mfa_verified {
|
||||
|
|
@ -235,9 +236,12 @@ impl PasskeyBuilder {
|
|||
use webauthn_rs::prelude::*;
|
||||
|
||||
// Create a test passkey with minimal data
|
||||
use base64ct::{Base64UrlUnpadded, Encoding};
|
||||
let credential_id = uuid::Uuid::new_v4().as_bytes().to_vec();
|
||||
let cred_id_b64 = Base64UrlUnpadded::encode_string(&credential_id);
|
||||
|
||||
let passkey_json = serde_json::json!({
|
||||
"cred_id": base64::encode(&credential_id),
|
||||
"cred_id": &cred_id_b64,
|
||||
"cred": {
|
||||
"counter": 0,
|
||||
"backup_state": self.backup_state,
|
||||
|
|
@ -247,7 +251,7 @@ impl PasskeyBuilder {
|
|||
|
||||
storage::create_passkey(
|
||||
db,
|
||||
&base64ct::Base64UrlUnpadded::encode_string(&credential_id),
|
||||
&cred_id_b64,
|
||||
&self.subject,
|
||||
&serde_json::to_string(&passkey_json).unwrap(),
|
||||
0,
|
||||
|
|
|
|||
|
|
@ -43,16 +43,14 @@ pub async fn seed_test_user(
|
|||
db: &DatabaseConnection,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> barycenter::entities::user::Model {
|
||||
) -> barycenter::storage::User {
|
||||
barycenter::storage::create_user(db, username, password, None)
|
||||
.await
|
||||
.expect("Failed to create test user")
|
||||
}
|
||||
|
||||
/// Create a test OAuth client for testing
|
||||
pub async fn seed_test_client(
|
||||
db: &DatabaseConnection,
|
||||
) -> barycenter::entities::client::Model {
|
||||
pub async fn seed_test_client(db: &DatabaseConnection) -> barycenter::storage::Client {
|
||||
use barycenter::storage::NewClient;
|
||||
|
||||
barycenter::storage::create_client(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
pub mod builders;
|
||||
pub mod db;
|
||||
pub mod mock_webauthn;
|
||||
pub mod builders;
|
||||
pub mod webauthn_fixtures;
|
||||
|
||||
pub use builders::{ClientBuilder, PasskeyBuilder, SessionBuilder, UserBuilder};
|
||||
pub use db::TestDb;
|
||||
pub use mock_webauthn::MockWebAuthnCredential;
|
||||
pub use builders::{UserBuilder, ClientBuilder, SessionBuilder, PasskeyBuilder};
|
||||
pub use webauthn_fixtures::{fixture_exists, load_fixture, WebAuthnFixture};
|
||||
|
|
|
|||
154
tests/helpers/webauthn_fixtures.rs
Normal file
154
tests/helpers/webauthn_fixtures.rs
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Load a WebAuthn fixture from the fixtures directory
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let fixture = load_fixture("hardware_key_registration");
|
||||
/// ```
|
||||
pub fn load_fixture(name: &str) -> WebAuthnFixture {
|
||||
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
path.push("tests");
|
||||
path.push("fixtures");
|
||||
path.push(format!("{}.json", name));
|
||||
|
||||
let contents =
|
||||
std::fs::read_to_string(&path).unwrap_or_else(|_| panic!("Fixture not found: {:?}", path));
|
||||
|
||||
serde_json::from_str(&contents).expect("Invalid fixture JSON")
|
||||
}
|
||||
|
||||
/// Check if a fixture exists
|
||||
pub fn fixture_exists(name: &str) -> bool {
|
||||
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
path.push("tests");
|
||||
path.push("fixtures");
|
||||
path.push(format!("{}.json", name));
|
||||
|
||||
path.exists()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum WebAuthnFixture {
|
||||
PasskeyRegistration(RegistrationFixture),
|
||||
PasskeyAuthentication(AuthenticationFixture),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RegistrationFixture {
|
||||
pub challenge_response: Value,
|
||||
pub credential_response: CredentialRegistrationResponse,
|
||||
pub server_response: Option<Value>,
|
||||
pub metadata: FixtureMetadata,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthenticationFixture {
|
||||
pub challenge_response: Value,
|
||||
pub credential_response: CredentialAuthenticationResponse,
|
||||
pub metadata: FixtureMetadata,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CredentialRegistrationResponse {
|
||||
pub id: String,
|
||||
#[serde(rename = "rawId")]
|
||||
pub raw_id: String,
|
||||
pub response: AttestationResponse,
|
||||
#[serde(rename = "type")]
|
||||
pub credential_type: String,
|
||||
#[serde(rename = "authenticatorAttachment")]
|
||||
pub authenticator_attachment: Option<String>,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AttestationResponse {
|
||||
#[serde(rename = "clientDataJSON")]
|
||||
pub client_data_json: String,
|
||||
#[serde(rename = "attestationObject")]
|
||||
pub attestation_object: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CredentialAuthenticationResponse {
|
||||
pub id: String,
|
||||
#[serde(rename = "rawId")]
|
||||
pub raw_id: String,
|
||||
pub response: AssertionResponse,
|
||||
#[serde(rename = "type")]
|
||||
pub credential_type: String,
|
||||
#[serde(rename = "authenticatorAttachment")]
|
||||
pub authenticator_attachment: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AssertionResponse {
|
||||
#[serde(rename = "clientDataJSON")]
|
||||
pub client_data_json: String,
|
||||
#[serde(rename = "authenticatorData")]
|
||||
pub authenticator_data: String,
|
||||
pub signature: String,
|
||||
#[serde(rename = "userHandle")]
|
||||
pub user_handle: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FixtureMetadata {
|
||||
pub captured_at: String,
|
||||
pub authenticator_attachment: Option<String>,
|
||||
pub user_agent: String,
|
||||
}
|
||||
|
||||
impl WebAuthnFixture {
|
||||
/// Get the authenticator attachment type (e.g., "platform" or "cross-platform")
|
||||
pub fn authenticator_attachment(&self) -> Option<&str> {
|
||||
match self {
|
||||
WebAuthnFixture::PasskeyRegistration(fix) => {
|
||||
fix.metadata.authenticator_attachment.as_deref()
|
||||
}
|
||||
WebAuthnFixture::PasskeyAuthentication(fix) => {
|
||||
fix.metadata.authenticator_attachment.as_deref()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is a platform authenticator (TouchID, Windows Hello, etc.)
|
||||
pub fn is_platform_authenticator(&self) -> bool {
|
||||
self.authenticator_attachment() == Some("platform")
|
||||
}
|
||||
|
||||
/// Check if this is a cross-platform authenticator (USB security key, etc.)
|
||||
pub fn is_cross_platform_authenticator(&self) -> bool {
|
||||
self.authenticator_attachment() == Some("cross-platform")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore] // Only run when fixtures exist
|
||||
fn test_load_fixture() {
|
||||
if fixture_exists("hardware_key_registration") {
|
||||
let fixture = load_fixture("hardware_key_registration");
|
||||
match fixture {
|
||||
WebAuthnFixture::PasskeyRegistration(reg) => {
|
||||
assert!(!reg.credential_response.id.is_empty());
|
||||
assert!(!reg.credential_response.raw_id.is_empty());
|
||||
}
|
||||
_ => panic!("Expected registration fixture"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fixture_exists() {
|
||||
// Should not panic even if fixture doesn't exist
|
||||
let _ = fixture_exists("nonexistent_fixture");
|
||||
}
|
||||
}
|
||||
443
tests/test_passkey_counter.rs
Normal file
443
tests/test_passkey_counter.rs
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
// Integration tests for passkey counter tracking and AMR determination
|
||||
//
|
||||
// These tests verify:
|
||||
// 1. Counter extraction at registration
|
||||
// 2. Counter updates after authentication
|
||||
// 3. Backup state extraction (backup_eligible, backup_state)
|
||||
// 4. Correct AMR values (hwk vs swk)
|
||||
|
||||
mod helpers;
|
||||
|
||||
use barycenter::storage;
|
||||
use helpers::TestDb;
|
||||
|
||||
/// Test that passkey counter is extracted and stored during registration
|
||||
///
|
||||
/// This test verifies the fix for web.rs:2012 where counter was hardcoded to 0.
|
||||
/// Now it should extract the actual counter value from the Passkey object.
|
||||
#[tokio::test]
|
||||
async fn test_counter_extracted_at_registration() {
|
||||
let test_db = TestDb::new().await;
|
||||
let db = test_db.connection();
|
||||
|
||||
// Create a user
|
||||
let user = storage::create_user(&db, "testuser", "password123", None)
|
||||
.await
|
||||
.expect("Failed to create user");
|
||||
|
||||
// Create a mock passkey with counter = 0 (initial registration)
|
||||
// In a real scenario, this would come from webauthn-rs finish_passkey_registration
|
||||
let passkey_json = serde_json::json!({
|
||||
"cred_id": "test_credential_id",
|
||||
"cred": {
|
||||
"counter": 0,
|
||||
"backup_eligible": false,
|
||||
"backup_state": false,
|
||||
"verified": true
|
||||
}
|
||||
});
|
||||
|
||||
storage::create_passkey(
|
||||
&db,
|
||||
"test_credential_id",
|
||||
&user.subject,
|
||||
&passkey_json.to_string(),
|
||||
0, // Initial counter
|
||||
None, // aaguid
|
||||
false, // backup_eligible (hardware-bound)
|
||||
false, // backup_state
|
||||
None, // transports
|
||||
Some("Test Passkey"),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to create passkey");
|
||||
|
||||
// Verify passkey was stored with correct counter
|
||||
let passkey = storage::get_passkey_by_credential_id(&db, "test_credential_id")
|
||||
.await
|
||||
.expect("Failed to get passkey")
|
||||
.expect("Passkey not found");
|
||||
|
||||
assert_eq!(passkey.counter, 0, "Initial counter should be 0");
|
||||
assert_eq!(
|
||||
passkey.backup_eligible, 0,
|
||||
"Hardware key should not be backup eligible"
|
||||
);
|
||||
assert_eq!(
|
||||
passkey.backup_state, 0,
|
||||
"Hardware key should not have backup state"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that passkey counter is updated after authentication
|
||||
///
|
||||
/// This test verifies the fix for web.rs:2151 where counter update was commented out.
|
||||
/// Now it should extract counter from AuthenticationResult and update the database.
|
||||
#[tokio::test]
|
||||
async fn test_counter_updated_after_authentication() {
|
||||
let test_db = TestDb::new().await;
|
||||
let db = test_db.connection();
|
||||
|
||||
// Create user and passkey
|
||||
let user = storage::create_user(&db, "testuser", "password123", None)
|
||||
.await
|
||||
.expect("Failed to create user");
|
||||
|
||||
let passkey_json = serde_json::json!({
|
||||
"cred_id": "test_credential_id",
|
||||
"cred": {
|
||||
"counter": 0,
|
||||
"backup_eligible": false,
|
||||
"backup_state": false
|
||||
}
|
||||
});
|
||||
|
||||
storage::create_passkey(
|
||||
&db,
|
||||
"test_credential_id",
|
||||
&user.subject,
|
||||
&passkey_json.to_string(),
|
||||
0,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
Some("Test Passkey"),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to create passkey");
|
||||
|
||||
// Verify initial counter
|
||||
let passkey_before = storage::get_passkey_by_credential_id(&db, "test_credential_id")
|
||||
.await
|
||||
.expect("Failed to get passkey")
|
||||
.expect("Passkey not found");
|
||||
assert_eq!(passkey_before.counter, 0);
|
||||
|
||||
// Simulate authentication by updating counter (mimics what happens in web.rs:2183)
|
||||
storage::update_passkey_counter(&db, "test_credential_id", 1)
|
||||
.await
|
||||
.expect("Failed to update counter");
|
||||
|
||||
// Verify counter was incremented
|
||||
let passkey_after = storage::get_passkey_by_credential_id(&db, "test_credential_id")
|
||||
.await
|
||||
.expect("Failed to get passkey")
|
||||
.expect("Passkey not found");
|
||||
|
||||
assert_eq!(passkey_after.counter, 1, "Counter should increment to 1");
|
||||
assert!(
|
||||
passkey_after.last_used_at.is_some(),
|
||||
"last_used_at should be updated"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test counter increments across multiple authentications
|
||||
///
|
||||
/// This test verifies that counter properly increments with each authentication,
|
||||
/// which is critical for detecting cloned authenticators.
|
||||
#[tokio::test]
|
||||
async fn test_counter_increments_multiple_times() {
|
||||
let test_db = TestDb::new().await;
|
||||
let db = test_db.connection();
|
||||
|
||||
let user = storage::create_user(&db, "testuser", "password123", None)
|
||||
.await
|
||||
.expect("Failed to create user");
|
||||
|
||||
let passkey_json = serde_json::json!({
|
||||
"cred_id": "test_credential_id",
|
||||
"cred": { "counter": 0, "backup_eligible": false, "backup_state": false }
|
||||
});
|
||||
|
||||
storage::create_passkey(
|
||||
&db,
|
||||
"test_credential_id",
|
||||
&user.subject,
|
||||
&passkey_json.to_string(),
|
||||
0,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
Some("Test Passkey"),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to create passkey");
|
||||
|
||||
// Simulate multiple authentications
|
||||
for expected_counter in 1..=5 {
|
||||
storage::update_passkey_counter(&db, "test_credential_id", expected_counter)
|
||||
.await
|
||||
.expect("Failed to update counter");
|
||||
|
||||
let passkey = storage::get_passkey_by_credential_id(&db, "test_credential_id")
|
||||
.await
|
||||
.expect("Failed to get passkey")
|
||||
.expect("Passkey not found");
|
||||
|
||||
assert_eq!(
|
||||
passkey.counter, expected_counter,
|
||||
"Counter should be {}",
|
||||
expected_counter
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Test backup_eligible and backup_state extraction for cloud-synced passkeys
|
||||
///
|
||||
/// This test verifies the fix for web.rs:2014-2015 where backup flags were hardcoded to false.
|
||||
/// Cloud-synced passkeys (iCloud Keychain, Windows Hello, etc.) should have both flags set to true.
|
||||
#[tokio::test]
|
||||
async fn test_cloud_synced_passkey_backup_flags() {
|
||||
let test_db = TestDb::new().await;
|
||||
let db = test_db.connection();
|
||||
|
||||
let user = storage::create_user(&db, "testuser", "password123", None)
|
||||
.await
|
||||
.expect("Failed to create user");
|
||||
|
||||
// Cloud-synced passkey has backup_eligible=true and backup_state=true
|
||||
let passkey_json = serde_json::json!({
|
||||
"cred_id": "cloud_credential_id",
|
||||
"cred": {
|
||||
"counter": 0,
|
||||
"backup_eligible": true,
|
||||
"backup_state": true
|
||||
}
|
||||
});
|
||||
|
||||
storage::create_passkey(
|
||||
&db,
|
||||
"cloud_credential_id",
|
||||
&user.subject,
|
||||
&passkey_json.to_string(),
|
||||
0,
|
||||
None,
|
||||
true, // backup_eligible
|
||||
true, // backup_state
|
||||
None,
|
||||
Some("Cloud Passkey"),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to create passkey");
|
||||
|
||||
let passkey = storage::get_passkey_by_credential_id(&db, "cloud_credential_id")
|
||||
.await
|
||||
.expect("Failed to get passkey")
|
||||
.expect("Passkey not found");
|
||||
|
||||
assert_eq!(
|
||||
passkey.backup_eligible, 1,
|
||||
"Cloud-synced passkey should be backup eligible"
|
||||
);
|
||||
assert_eq!(
|
||||
passkey.backup_state, 1,
|
||||
"Cloud-synced passkey should have backup state"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test hardware-bound passkey backup flags
|
||||
///
|
||||
/// Hardware-bound keys (YubiKey, etc.) should have backup_eligible=false and backup_state=false.
|
||||
#[tokio::test]
|
||||
async fn test_hardware_bound_passkey_backup_flags() {
|
||||
let test_db = TestDb::new().await;
|
||||
let db = test_db.connection();
|
||||
|
||||
let user = storage::create_user(&db, "testuser", "password123", None)
|
||||
.await
|
||||
.expect("Failed to create user");
|
||||
|
||||
let passkey_json = serde_json::json!({
|
||||
"cred_id": "hardware_credential_id",
|
||||
"cred": {
|
||||
"counter": 0,
|
||||
"backup_eligible": false,
|
||||
"backup_state": false
|
||||
}
|
||||
});
|
||||
|
||||
storage::create_passkey(
|
||||
&db,
|
||||
"hardware_credential_id",
|
||||
&user.subject,
|
||||
&passkey_json.to_string(),
|
||||
0,
|
||||
None,
|
||||
false, // backup_eligible
|
||||
false, // backup_state
|
||||
None,
|
||||
Some("Hardware Passkey"),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to create passkey");
|
||||
|
||||
let passkey = storage::get_passkey_by_credential_id(&db, "hardware_credential_id")
|
||||
.await
|
||||
.expect("Failed to get passkey")
|
||||
.expect("Passkey not found");
|
||||
|
||||
assert_eq!(
|
||||
passkey.backup_eligible, 0,
|
||||
"Hardware key should not be backup eligible"
|
||||
);
|
||||
assert_eq!(
|
||||
passkey.backup_state, 0,
|
||||
"Hardware key should not have backup state"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test AMR (Authentication Method References) for hardware-bound passkey
|
||||
///
|
||||
/// Verifies that web.rs:2156-2160 correctly determines AMR based on backup_eligible and backup_state.
|
||||
/// Hardware-bound passkeys should result in AMR = ["hwk"]
|
||||
#[tokio::test]
|
||||
async fn test_amr_determination_hardware_key() {
|
||||
let test_db = TestDb::new().await;
|
||||
let db = test_db.connection();
|
||||
|
||||
let user = storage::create_user(&db, "testuser", "password123", None)
|
||||
.await
|
||||
.expect("Failed to create user");
|
||||
|
||||
storage::create_passkey(
|
||||
&db,
|
||||
"hw_key_id",
|
||||
&user.subject,
|
||||
&serde_json::json!({"cred": {"counter": 0}}).to_string(),
|
||||
0,
|
||||
None,
|
||||
false, // backup_eligible = false
|
||||
false, // backup_state = false
|
||||
None,
|
||||
Some("HW Key"),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to create passkey");
|
||||
|
||||
let passkey = storage::get_passkey_by_credential_id(&db, "hw_key_id")
|
||||
.await
|
||||
.expect("Failed to get passkey")
|
||||
.expect("Passkey not found");
|
||||
|
||||
// Mimic AMR determination logic from web.rs:2156-2160
|
||||
let expected_amr = if passkey.backup_eligible == 1 && passkey.backup_state == 1 {
|
||||
"swk" // Software key
|
||||
} else {
|
||||
"hwk" // Hardware key
|
||||
};
|
||||
|
||||
assert_eq!(expected_amr, "hwk", "Hardware key should have AMR = hwk");
|
||||
}
|
||||
|
||||
/// Test AMR for cloud-synced passkey
|
||||
///
|
||||
/// Cloud-synced passkeys should result in AMR = ["swk"]
|
||||
#[tokio::test]
|
||||
async fn test_amr_determination_cloud_key() {
|
||||
let test_db = TestDb::new().await;
|
||||
let db = test_db.connection();
|
||||
|
||||
let user = storage::create_user(&db, "testuser", "password123", None)
|
||||
.await
|
||||
.expect("Failed to create user");
|
||||
|
||||
storage::create_passkey(
|
||||
&db,
|
||||
"cloud_key_id",
|
||||
&user.subject,
|
||||
&serde_json::json!({"cred": {"counter": 0}}).to_string(),
|
||||
0,
|
||||
None,
|
||||
true, // backup_eligible = true
|
||||
true, // backup_state = true
|
||||
None,
|
||||
Some("Cloud Key"),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to create passkey");
|
||||
|
||||
let passkey = storage::get_passkey_by_credential_id(&db, "cloud_key_id")
|
||||
.await
|
||||
.expect("Failed to get passkey")
|
||||
.expect("Passkey not found");
|
||||
|
||||
// Mimic AMR determination logic from web.rs:2156-2160
|
||||
let expected_amr = if passkey.backup_eligible == 1 && passkey.backup_state == 1 {
|
||||
"swk" // Software key
|
||||
} else {
|
||||
"hwk" // Hardware key
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
expected_amr, "swk",
|
||||
"Cloud-synced key should have AMR = swk"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that multiple passkeys for one user are tracked independently
|
||||
#[tokio::test]
|
||||
async fn test_multiple_passkeys_independent_counters() {
|
||||
let test_db = TestDb::new().await;
|
||||
let db = test_db.connection();
|
||||
|
||||
let user = storage::create_user(&db, "testuser", "password123", None)
|
||||
.await
|
||||
.expect("Failed to create user");
|
||||
|
||||
// Create two passkeys for the same user
|
||||
storage::create_passkey(
|
||||
&db,
|
||||
"passkey1",
|
||||
&user.subject,
|
||||
&serde_json::json!({"cred": {"counter": 0}}).to_string(),
|
||||
0,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
Some("Passkey 1"),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to create passkey 1");
|
||||
|
||||
storage::create_passkey(
|
||||
&db,
|
||||
"passkey2",
|
||||
&user.subject,
|
||||
&serde_json::json!({"cred": {"counter": 0}}).to_string(),
|
||||
0,
|
||||
None,
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
Some("Passkey 2"),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to create passkey 2");
|
||||
|
||||
// Update counters independently
|
||||
storage::update_passkey_counter(&db, "passkey1", 5)
|
||||
.await
|
||||
.expect("Failed to update counter 1");
|
||||
storage::update_passkey_counter(&db, "passkey2", 10)
|
||||
.await
|
||||
.expect("Failed to update counter 2");
|
||||
|
||||
// Verify independent tracking
|
||||
let pk1 = storage::get_passkey_by_credential_id(&db, "passkey1")
|
||||
.await
|
||||
.expect("Failed to get passkey 1")
|
||||
.expect("Passkey 1 not found");
|
||||
|
||||
let pk2 = storage::get_passkey_by_credential_id(&db, "passkey2")
|
||||
.await
|
||||
.expect("Failed to get passkey 2")
|
||||
.expect("Passkey 2 not found");
|
||||
|
||||
assert_eq!(pk1.counter, 5);
|
||||
assert_eq!(pk2.counter, 10);
|
||||
assert_eq!(pk1.backup_state, 0);
|
||||
assert_eq!(pk2.backup_state, 1);
|
||||
}
|
||||
76
tests/tools/README.md
Normal file
76
tests/tools/README.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# WebAuthn Fixture Capture Tool
|
||||
|
||||
This tool captures real WebAuthn responses from your authenticator for use in integration tests.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Start Barycenter server:
|
||||
```bash
|
||||
cargo run
|
||||
```
|
||||
|
||||
2. Create a test user (if not already exists):
|
||||
```bash
|
||||
# The default admin user should work (admin/password123)
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
1. Open `capture_webauthn_fixture.html` in your browser:
|
||||
```bash
|
||||
open tests/tools/capture_webauthn_fixture.html
|
||||
# or
|
||||
firefox tests/tools/capture_webauthn_fixture.html
|
||||
```
|
||||
|
||||
2. Click "Login to Server" to authenticate
|
||||
|
||||
3. Click "Capture Registration Fixture" to register a new passkey
|
||||
- Your browser will prompt you to use your authenticator
|
||||
- Use TouchID, Windows Hello, or a USB security key
|
||||
|
||||
4. Copy the JSON output and save to `tests/fixtures/`
|
||||
|
||||
## Fixture Types
|
||||
|
||||
### Hardware-Bound Passkey
|
||||
- **File**: `hardware_key_registration.json`
|
||||
- **Device**: USB security key (YubiKey, etc.)
|
||||
- **Characteristics**:
|
||||
- `backup_eligible`: false
|
||||
- `backup_state`: false
|
||||
- AMR: `["hwk"]`
|
||||
|
||||
### Cloud-Synced Passkey
|
||||
- **File**: `cloud_synced_passkey.json`
|
||||
- **Device**: TouchID (macOS), Windows Hello, iCloud Keychain
|
||||
- **Characteristics**:
|
||||
- `backup_eligible`: true
|
||||
- `backup_state`: true
|
||||
- AMR: `["swk"]`
|
||||
|
||||
## Captured Data
|
||||
|
||||
Each fixture contains:
|
||||
- **challenge_response**: The initial challenge from the server
|
||||
- **credential_response**: The credential created by the authenticator
|
||||
- **server_response**: The server's verification response (registration only)
|
||||
- **metadata**: Capture timestamp, authenticator type, user agent
|
||||
|
||||
## Using Fixtures in Tests
|
||||
|
||||
```rust
|
||||
use crate::helpers::load_fixture;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_passkey_registration() {
|
||||
let fixture = load_fixture("hardware_key_registration");
|
||||
// Use fixture.challenge_response and fixture.credential_response in tests
|
||||
}
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- **Multiple Devices**: Capture fixtures from different authenticator types (hardware vs platform)
|
||||
- **Fresh Captures**: If the server's JWKS changes, you may need to recapture fixtures
|
||||
- **Counter Values**: Each authentication increments the counter - recapture if needed for specific counter tests
|
||||
394
tests/tools/capture_webauthn_fixture.html
Normal file
394
tests/tools/capture_webauthn_fixture.html
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WebAuthn Fixture Capture Tool</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
max-width: 900px;
|
||||
margin: 40px auto;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-top: 0;
|
||||
}
|
||||
.section {
|
||||
margin: 30px 0;
|
||||
}
|
||||
button {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin: 5px;
|
||||
}
|
||||
button:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
button:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
pre {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.info {
|
||||
background: #e7f3ff;
|
||||
border-left: 4px solid #007bff;
|
||||
padding: 12px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
.warning {
|
||||
background: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
padding: 12px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
.success {
|
||||
background: #d4edda;
|
||||
border-left: 4px solid #28a745;
|
||||
padding: 12px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
input {
|
||||
padding: 8px;
|
||||
font-size: 14px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
width: 300px;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🔑 WebAuthn Fixture Capture Tool</h1>
|
||||
|
||||
<div class="info">
|
||||
<strong>Purpose:</strong> This tool captures real WebAuthn responses from your authenticator
|
||||
for use in integration tests. It communicates with a local Barycenter server.
|
||||
</div>
|
||||
|
||||
<div class="warning">
|
||||
<strong>Prerequisites:</strong>
|
||||
<ul>
|
||||
<li>Barycenter server running on <code>http://localhost:9090</code></li>
|
||||
<li>A user account created (default: username=admin, password=password123)</li>
|
||||
<li>An authenticator available (hardware key, TouchID, Windows Hello, etc.)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Configuration</h2>
|
||||
<div style="margin: 15px 0;">
|
||||
<label for="serverUrl">Server URL:</label>
|
||||
<input type="text" id="serverUrl" value="http://localhost:9090" />
|
||||
</div>
|
||||
<div style="margin: 15px 0;">
|
||||
<label for="username">Username:</label>
|
||||
<input type="text" id="username" value="admin" />
|
||||
</div>
|
||||
<div style="margin: 15px 0;">
|
||||
<label for="password">Password:</label>
|
||||
<input type="password" id="password" value="password123" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Step 1: Login</h2>
|
||||
<button onclick="login()">Login to Server</button>
|
||||
<div id="loginStatus"></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Step 2: Capture Passkey Registration</h2>
|
||||
<button onclick="captureRegistration()" id="regBtn" disabled>
|
||||
Capture Registration Fixture
|
||||
</button>
|
||||
<div id="registrationStatus"></div>
|
||||
<pre id="registrationOutput" style="display:none;"></pre>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Step 3: Capture Passkey Authentication</h2>
|
||||
<button onclick="captureAuthentication()" id="authBtn" disabled>
|
||||
Capture Authentication Fixture
|
||||
</button>
|
||||
<div id="authenticationStatus"></div>
|
||||
<pre id="authenticationOutput" style="display:none;"></pre>
|
||||
</div>
|
||||
|
||||
<div class="section success" style="display:none;" id="instructions">
|
||||
<h3>Next Steps:</h3>
|
||||
<ol>
|
||||
<li>Copy the JSON output above</li>
|
||||
<li>Save as <code>tests/fixtures/hardware_key_registration.json</code> or <code>cloud_synced_passkey.json</code></li>
|
||||
<li>Use in your integration tests via <code>load_fixture("hardware_key_registration")</code></li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const serverUrl = () => document.getElementById('serverUrl').value;
|
||||
const username = () => document.getElementById('username').value;
|
||||
const password = () => document.getElementById('password').value;
|
||||
let sessionCookie = null;
|
||||
let credentialId = null;
|
||||
|
||||
// Utility functions
|
||||
function arrayBufferToBase64(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToArrayBuffer(base64) {
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
function showStatus(elementId, message, isError = false) {
|
||||
const el = document.getElementById(elementId);
|
||||
el.innerHTML = `<div class="${isError ? 'warning' : 'success'}" style="margin-top: 10px;">${message}</div>`;
|
||||
}
|
||||
|
||||
async function login() {
|
||||
try {
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('username', username());
|
||||
formData.append('password', password());
|
||||
|
||||
const response = await fetch(`${serverUrl()}/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
redirect: 'manual'
|
||||
});
|
||||
|
||||
if (response.status === 0 || response.status === 303 || response.ok) {
|
||||
showStatus('loginStatus', '✓ Login successful! Session created.');
|
||||
document.getElementById('regBtn').disabled = false;
|
||||
document.getElementById('authBtn').disabled = false;
|
||||
} else {
|
||||
showStatus('loginStatus', `✗ Login failed: ${response.status} ${response.statusText}`, true);
|
||||
}
|
||||
} catch (error) {
|
||||
showStatus('loginStatus', `✗ Login error: ${error.message}`, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function captureRegistration() {
|
||||
try {
|
||||
// Start registration
|
||||
const startResp = await fetch(`${serverUrl()}/webauthn/register/start`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!startResp.ok) {
|
||||
const error = await startResp.text();
|
||||
showStatus('registrationStatus', `✗ Start failed: ${error}`, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const challengeResponse = await startResp.json();
|
||||
showStatus('registrationStatus', '⏳ Challenge received, waiting for authenticator...');
|
||||
|
||||
// Convert challenge from base64
|
||||
const publicKey = {
|
||||
...challengeResponse.publicKey,
|
||||
challenge: base64ToArrayBuffer(challengeResponse.publicKey.challenge),
|
||||
user: {
|
||||
...challengeResponse.publicKey.user,
|
||||
id: base64ToArrayBuffer(challengeResponse.publicKey.user.id)
|
||||
}
|
||||
};
|
||||
|
||||
// Create credential
|
||||
const credential = await navigator.credentials.create({ publicKey });
|
||||
|
||||
showStatus('registrationStatus', '⏳ Credential created, finishing registration...');
|
||||
|
||||
// Prepare credential for sending
|
||||
const credentialResponse = {
|
||||
id: credential.id,
|
||||
rawId: arrayBufferToBase64(credential.rawId),
|
||||
response: {
|
||||
clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON),
|
||||
attestationObject: arrayBufferToBase64(credential.response.attestationObject)
|
||||
},
|
||||
type: credential.type,
|
||||
authenticatorAttachment: credential.authenticatorAttachment,
|
||||
name: "Test Passkey"
|
||||
};
|
||||
|
||||
// Finish registration
|
||||
const finishResp = await fetch(`${serverUrl()}/webauthn/register/finish`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ credential: credentialResponse, name: "Test Passkey" })
|
||||
});
|
||||
|
||||
if (!finishResp.ok) {
|
||||
const error = await finishResp.text();
|
||||
showStatus('registrationStatus', `✗ Finish failed: ${error}`, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await finishResp.json();
|
||||
credentialId = result.credential_id;
|
||||
|
||||
// Create fixture
|
||||
const fixture = {
|
||||
type: "passkey_registration",
|
||||
challenge_response: challengeResponse,
|
||||
credential_response: credentialResponse,
|
||||
server_response: result,
|
||||
metadata: {
|
||||
captured_at: new Date().toISOString(),
|
||||
authenticator_attachment: credential.authenticatorAttachment,
|
||||
user_agent: navigator.userAgent
|
||||
}
|
||||
};
|
||||
|
||||
const output = document.getElementById('registrationOutput');
|
||||
output.textContent = JSON.stringify(fixture, null, 2);
|
||||
output.style.display = 'block';
|
||||
|
||||
showStatus('registrationStatus', '✓ Registration captured! See JSON below.');
|
||||
document.getElementById('instructions').style.display = 'block';
|
||||
} catch (error) {
|
||||
showStatus('registrationStatus', `✗ Error: ${error.message}`, true);
|
||||
console.error('Registration error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function captureAuthentication() {
|
||||
try {
|
||||
// Start authentication
|
||||
const startResp = await fetch(`${serverUrl()}/webauthn/authenticate/start`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ username: username() })
|
||||
});
|
||||
|
||||
if (!startResp.ok) {
|
||||
const error = await startResp.text();
|
||||
showStatus('authenticationStatus', `✗ Start failed: ${error}`, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const challengeResponse = await startResp.json();
|
||||
showStatus('authenticationStatus', '⏳ Challenge received, waiting for authenticator...');
|
||||
|
||||
// Convert challenge from base64
|
||||
const publicKey = {
|
||||
...challengeResponse.publicKey,
|
||||
challenge: base64ToArrayBuffer(challengeResponse.publicKey.challenge),
|
||||
allowCredentials: challengeResponse.publicKey.allowCredentials?.map(cred => ({
|
||||
...cred,
|
||||
id: base64ToArrayBuffer(cred.id)
|
||||
}))
|
||||
};
|
||||
|
||||
// Get credential
|
||||
const credential = await navigator.credentials.get({ publicKey });
|
||||
|
||||
showStatus('authenticationStatus', '⏳ Authenticated, finishing...');
|
||||
|
||||
// Prepare credential for sending
|
||||
const credentialResponse = {
|
||||
id: credential.id,
|
||||
rawId: arrayBufferToBase64(credential.rawId),
|
||||
response: {
|
||||
clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON),
|
||||
authenticatorData: arrayBufferToBase64(credential.response.authenticatorData),
|
||||
signature: arrayBufferToBase64(credential.response.signature),
|
||||
userHandle: credential.response.userHandle ?
|
||||
arrayBufferToBase64(credential.response.userHandle) : null
|
||||
},
|
||||
type: credential.type,
|
||||
authenticatorAttachment: credential.authenticatorAttachment
|
||||
};
|
||||
|
||||
// Finish authentication
|
||||
const finishResp = await fetch(`${serverUrl()}/webauthn/authenticate/finish`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credential: credentialResponse,
|
||||
return_to: "/"
|
||||
}),
|
||||
redirect: 'manual'
|
||||
});
|
||||
|
||||
// Create fixture
|
||||
const fixture = {
|
||||
type: "passkey_authentication",
|
||||
challenge_response: challengeResponse,
|
||||
credential_response: credentialResponse,
|
||||
metadata: {
|
||||
captured_at: new Date().toISOString(),
|
||||
authenticator_attachment: credential.authenticatorAttachment,
|
||||
user_agent: navigator.userAgent
|
||||
}
|
||||
};
|
||||
|
||||
const output = document.getElementById('authenticationOutput');
|
||||
output.textContent = JSON.stringify(fixture, null, 2);
|
||||
output.style.display = 'block';
|
||||
|
||||
showStatus('authenticationStatus', '✓ Authentication captured! See JSON below.');
|
||||
document.getElementById('instructions').style.display = 'block';
|
||||
} catch (error) {
|
||||
showStatus('authenticationStatus', `✗ Error: ${error.message}`, true);
|
||||
console.error('Authentication error:', error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Reference in a new issue