mirror of
https://github.com/CloudNebulaProject/barycenter.git
synced 2026-04-10 21:20:41 +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
|
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)
|
let user = storage::create_user(db, &self.username, &self.password, self.email)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to create test user");
|
.expect("Failed to create test user");
|
||||||
|
|
@ -99,7 +99,7 @@ impl ClientBuilder {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create(self, db: &DatabaseConnection) -> entities::client::Model {
|
pub async fn create(self, db: &DatabaseConnection) -> storage::Client {
|
||||||
storage::create_client(
|
storage::create_client(
|
||||||
db,
|
db,
|
||||||
storage::NewClient {
|
storage::NewClient {
|
||||||
|
|
@ -165,10 +165,11 @@ impl SessionBuilder {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create(self, db: &DatabaseConnection) -> entities::session::Model {
|
pub async fn create(self, db: &DatabaseConnection) -> storage::Session {
|
||||||
let session = storage::create_session(db, &self.subject, self.auth_time, self.ttl, None, None)
|
let session =
|
||||||
.await
|
storage::create_session(db, &self.subject, self.auth_time, self.ttl, None, None)
|
||||||
.expect("Failed to create test session");
|
.await
|
||||||
|
.expect("Failed to create test session");
|
||||||
|
|
||||||
// Update AMR/ACR/MFA if needed
|
// Update AMR/ACR/MFA if needed
|
||||||
if self.amr.is_some() || self.acr.is_some() || self.mfa_verified {
|
if self.amr.is_some() || self.acr.is_some() || self.mfa_verified {
|
||||||
|
|
@ -235,9 +236,12 @@ impl PasskeyBuilder {
|
||||||
use webauthn_rs::prelude::*;
|
use webauthn_rs::prelude::*;
|
||||||
|
|
||||||
// Create a test passkey with minimal data
|
// Create a test passkey with minimal data
|
||||||
|
use base64ct::{Base64UrlUnpadded, Encoding};
|
||||||
let credential_id = uuid::Uuid::new_v4().as_bytes().to_vec();
|
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!({
|
let passkey_json = serde_json::json!({
|
||||||
"cred_id": base64::encode(&credential_id),
|
"cred_id": &cred_id_b64,
|
||||||
"cred": {
|
"cred": {
|
||||||
"counter": 0,
|
"counter": 0,
|
||||||
"backup_state": self.backup_state,
|
"backup_state": self.backup_state,
|
||||||
|
|
@ -247,7 +251,7 @@ impl PasskeyBuilder {
|
||||||
|
|
||||||
storage::create_passkey(
|
storage::create_passkey(
|
||||||
db,
|
db,
|
||||||
&base64ct::Base64UrlUnpadded::encode_string(&credential_id),
|
&cred_id_b64,
|
||||||
&self.subject,
|
&self.subject,
|
||||||
&serde_json::to_string(&passkey_json).unwrap(),
|
&serde_json::to_string(&passkey_json).unwrap(),
|
||||||
0,
|
0,
|
||||||
|
|
|
||||||
|
|
@ -43,16 +43,14 @@ pub async fn seed_test_user(
|
||||||
db: &DatabaseConnection,
|
db: &DatabaseConnection,
|
||||||
username: &str,
|
username: &str,
|
||||||
password: &str,
|
password: &str,
|
||||||
) -> barycenter::entities::user::Model {
|
) -> barycenter::storage::User {
|
||||||
barycenter::storage::create_user(db, username, password, None)
|
barycenter::storage::create_user(db, username, password, None)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to create test user")
|
.expect("Failed to create test user")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a test OAuth client for testing
|
/// Create a test OAuth client for testing
|
||||||
pub async fn seed_test_client(
|
pub async fn seed_test_client(db: &DatabaseConnection) -> barycenter::storage::Client {
|
||||||
db: &DatabaseConnection,
|
|
||||||
) -> barycenter::entities::client::Model {
|
|
||||||
use barycenter::storage::NewClient;
|
use barycenter::storage::NewClient;
|
||||||
|
|
||||||
barycenter::storage::create_client(
|
barycenter::storage::create_client(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
|
pub mod builders;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod mock_webauthn;
|
pub mod mock_webauthn;
|
||||||
pub mod builders;
|
pub mod webauthn_fixtures;
|
||||||
|
|
||||||
|
pub use builders::{ClientBuilder, PasskeyBuilder, SessionBuilder, UserBuilder};
|
||||||
pub use db::TestDb;
|
pub use db::TestDb;
|
||||||
pub use mock_webauthn::MockWebAuthnCredential;
|
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