//! Passkey / WebAuthn workflow tests: register, login, rename, delete. use crate::harness::TestHarness; use makenotwork::db::UserId; use url::Url; use webauthn_authenticator_rs::{softpasskey::SoftPasskey, WebauthnAuthenticator}; use webauthn_rs_proto::{CreationChallengeResponse, RequestChallengeResponse}; const ORIGIN: &str = "http://localhost:3000"; /// Register a passkey for the currently logged-in user. /// Returns (authenticator, credential_id_base64url) — the credential ID is needed /// because SoftPasskey doesn't support discoverable credentials (empty allowCredentials), /// so we inject it into the authentication challenge. async fn register_passkey(h: &mut TestHarness) -> (WebauthnAuthenticator, String) { register_passkey_with_password(h, "Password1!").await } async fn register_passkey_with_password(h: &mut TestHarness, password: &str) -> (WebauthnAuthenticator, String) { let mut wa = WebauthnAuthenticator::new(SoftPasskey::new(true)); // Start registration (requires password confirmation) let resp = h .client .post_form("/api/users/me/passkeys/register/start", &format!("password={password}")) .await; assert_eq!(resp.status.as_u16(), 200, "register/start failed: {}", resp.text); let ccr: CreationChallengeResponse = resp.json(); // Complete registration with the software authenticator let origin = Url::parse(ORIGIN).unwrap(); let reg = wa .do_registration(origin, ccr) .expect("SoftPasskey registration failed"); // Capture the credential ID (base64url-encoded) before sending to server let cred_id = reg.id.clone(); let resp = h .client .post_json( "/api/users/me/passkeys/register/finish", &serde_json::to_string(®).unwrap(), ) .await; assert_eq!(resp.status.as_u16(), 200, "register/finish failed: {}", resp.text); (wa, cred_id) } /// Authenticate via passkey. SoftPasskey doesn't support discoverable credentials, /// so we work around two limitations: /// 1. Inject the credential ID into `allowCredentials` so SoftPasskey can find it /// 2. Inject the user's UUID as `userHandle` in the response (SoftPasskey omits it) /// /// The server's discoverable verification accepts this because: /// - `start_discoverable_authentication` places no restriction on which credential responds /// - `identify_discoverable_authentication` uses the userHandle to find the user /// - `finish_discoverable_authentication` verifies the cryptographic signature async fn passkey_login( h: &mut TestHarness, wa: &mut WebauthnAuthenticator, cred_id: &str, user_id: UserId, ) { // Load a page to establish a fresh session (needed after logout clears cookies) h.client.get("/login").await; // Get authentication challenge from server let resp = h.client.post_json("/auth/passkey/start", "").await; assert_eq!(resp.status.as_u16(), 200, "passkey/start failed: {}", resp.text); // Inject credential ID into allowCredentials so SoftPasskey can find it let mut rcr_json: serde_json::Value = serde_json::from_str(&resp.text) .expect("Failed to parse passkey/start response as JSON"); rcr_json["publicKey"]["allowCredentials"] = serde_json::json!([{ "type": "public-key", "id": cred_id, }]); let rcr: RequestChallengeResponse = serde_json::from_value(rcr_json) .expect("Failed to deserialize modified RequestChallengeResponse"); // Get the signed response from SoftPasskey let origin = Url::parse(ORIGIN).unwrap(); let auth = wa .do_authentication(origin, rcr) .expect("SoftPasskey authentication failed"); // SoftPasskey doesn't set userHandle (it's not a discoverable authenticator). // The server's identify_discoverable_authentication needs it to find the user. // Inject the user's UUID as base64url-encoded userHandle. use base64::Engine; let user_handle_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(user_id.as_uuid().as_bytes()); let mut auth_json = serde_json::to_value(&auth).unwrap(); auth_json["response"]["userHandle"] = serde_json::Value::String(user_handle_b64); let resp = h .client .post_json("/auth/passkey/finish", &auth_json.to_string()) .await; assert_eq!(resp.status.as_u16(), 200, "passkey/finish failed: {}", resp.text); } // ── Tests ── #[tokio::test] async fn passkey_register_and_list() { let mut h = TestHarness::new().await; h.signup("pkuser", "pkuser@test.com", "Password1!").await; // Register a passkey let (_wa, _cred_id) = register_passkey(&mut h).await; // List — should show 1 passkey let resp = h.client.get("/api/users/me/passkeys").await; assert_eq!(resp.status.as_u16(), 200); assert!(resp.text.contains("Passkey"), "Expected passkey in list: {}", resp.text); } #[tokio::test] async fn passkey_full_registration_and_login() { let mut h = TestHarness::new().await; let user_id = h.signup("pklogin", "pklogin@test.com", "Password1!").await; let (mut wa, cred_id) = register_passkey(&mut h).await; // Logout h.client.post_form("/logout", "").await; // Confirm logged out via /auth/me let resp = h.client.get("/auth/me").await; assert_eq!(resp.status.as_u16(), 401, "Should be unauthorized after logout"); // Login via passkey passkey_login(&mut h, &mut wa, &cred_id, user_id).await; // Verify we're logged in let resp = h.client.get("/dashboard").await; assert_eq!(resp.status.as_u16(), 200, "Dashboard should be accessible after passkey login"); } #[tokio::test] async fn passkey_rename() { let mut h = TestHarness::new().await; h.signup("pkrename", "pkrename@test.com", "Password1!").await; let (_wa, _cred_id) = register_passkey(&mut h).await; // List to find the passkey ID let resp = h.client.get("/api/users/me/passkeys").await; assert_eq!(resp.status.as_u16(), 200); // Extract passkey ID from the HTML (data-id="..." attribute) let id = extract_passkey_id(&resp.text).expect("Could not find passkey ID in list HTML"); // Rename it let resp = h .client .put_form( &format!("/api/users/me/passkeys/{}", id), "name=My+YubiKey", ) .await; assert_eq!(resp.status.as_u16(), 200, "Rename failed: {}", resp.text); // List again — should show new name let resp = h.client.get("/api/users/me/passkeys").await; assert!( resp.text.contains("My YubiKey"), "Renamed passkey not found in list: {}", resp.text ); } #[tokio::test] async fn passkey_delete_requires_password() { let mut h = TestHarness::new().await; h.signup("pkdel", "pkdel@test.com", "Password1!").await; let (_wa, _cred_id) = register_passkey(&mut h).await; let resp = h.client.get("/api/users/me/passkeys").await; let id = extract_passkey_id(&resp.text).expect("Could not find passkey ID"); // Delete with wrong password — should fail let resp = h .client .delete_form(&format!("/api/users/me/passkeys/{}", id), "password=wrong") .await; assert_eq!(resp.status.as_u16(), 400, "Delete with wrong password should be 400"); // Delete with correct password — should succeed let resp = h .client .delete_form( &format!("/api/users/me/passkeys/{}", id), "password=Password1%21", ) .await; assert_eq!(resp.status.as_u16(), 200, "Delete with correct password failed: {}", resp.text); // List — should be empty let resp = h.client.get("/api/users/me/passkeys").await; assert_eq!(resp.status.as_u16(), 200); assert!( extract_passkey_id(&resp.text).is_none(), "Passkey list should be empty after deletion" ); } #[tokio::test] async fn passkey_login_skips_totp() { let mut h = TestHarness::new().await; let user_id = h.signup("pk2fa", "pk2fa@test.com", "Password1!").await; // Enable TOTP let resp = h.client.post_form("/api/users/me/totp/setup", "").await; assert_eq!(resp.status.as_u16(), 200); let secret = extract_totp_secret(&resp.text); let code = generate_totp_code(&secret, "pk2fa@test.com"); let resp = h .client .post_form("/api/users/me/totp/confirm", &format!("code={}", code)) .await; assert_eq!(resp.status.as_u16(), 200, "TOTP confirm failed: {}", resp.text); // Register a passkey let (mut wa, cred_id) = register_passkey(&mut h).await; // Logout h.client.post_form("/logout", "").await; // Login via passkey — should skip TOTP entirely passkey_login(&mut h, &mut wa, &cred_id, user_id).await; // Should land on dashboard directly (no /auth/2fa redirect) let resp = h.client.get("/dashboard").await; assert_eq!( resp.status.as_u16(), 200, "Passkey login should skip TOTP and land on dashboard" ); } // ── Helpers ── /// Extract the first passkey ID from list HTML. The template uses `data-id="..."` attributes. fn extract_passkey_id(html: &str) -> Option { let marker = "data-id=\""; let start = html.find(marker)? + marker.len(); let rest = &html[start..]; let end = rest.find('"')?; let id = &rest[..end]; if id.is_empty() { None } else { Some(id.to_string()) } } /// Extract the TOTP secret from setup HTML (inside `
` > ``). fn extract_totp_secret(html: &str) -> String { let details_start = html.find(" in TOTP setup HTML"); let details_html = &html[details_start..]; let code_start = details_html.find(" in details"); let after_tag = &details_html[code_start..]; let content_start = after_tag.find('>').expect("No > after ") .expect("No "); after_tag[content_start..content_start + content_end].to_string() } /// Generate a valid TOTP code for the given base32 secret. fn generate_totp_code(secret_base32: &str, email: &str) -> String { let bytes = totp_rs::Secret::Encoded(secret_base32.to_string()) .to_bytes() .expect("Invalid TOTP secret"); let totp = totp_rs::TOTP::new( totp_rs::Algorithm::SHA1, 6, 1, 30, bytes, Some("Makenotwork".into()), email.into(), ) .expect("TOTP creation failed"); totp.generate_current().expect("TOTP generation failed") }