//! Self-service credentials: changing and removing the password, the //! passkey list, and the rules that keep an account reachable. //! //! No real authenticator exists here, so nothing verifies a signature — that //! is `webauthn-rs`' job and it has its own tests. What these tests cover is //! everything around it: which combinations the server accepts, what it //! refuses, and that a half-finished sign-in never becomes a session. mod common; use axum::http::{Method, StatusCode}; use common::*; use serde_json::json; /// A syntactically valid stored passkey. /// /// The key is all zeroes, so it can never verify anything. Every test here /// either counts passkeys or checks that a ceremony is *refused*, and for /// both a credential that parses is enough. const STORED_PASSKEY: &str = r#"{"cred":{"cred_id":"AQIDBA","cred":{"type_":"ES256","key":{"EC_EC2":{"curve":"SECP256R1","x":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","y":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}},"counter":0,"transports":null,"user_verified":true,"backup_eligible":false,"backup_state":false,"registration_policy":"required","extensions":{"cred_protect":"NotRequested","hmac_create_secret":"NotRequested","appid":"NotRequested","cred_props":"NotRequested"},"attestation":{"data":"None","metadata":"None"},"attestation_format":"none"}}"#; /// Put a passkey on an account without a browser. /// /// `label` only has to be unique; the stored id is four bytes derived from it. /// A real registration stores exactly the credential id that is inside the /// passkey JSON, and `STORED_PASSKEY` carries a four-byte one. The challenge /// padding reads its decoy lengths from the stored ids, so the two must agree. async fn give_passkey(env: &Env, user_id: i64, label: &[u8]) { use std::hash::{DefaultHasher, Hash, Hasher}; let mut h = DefaultHasher::new(); label.hash(&mut h); let cred_id = (h.finish() as u32).to_le_bytes(); env.state .db .add_passkey(user_id, &cred_id, STORED_PASSKEY, "Test key", Some(true)) .await .unwrap() .expect("the account was already at the passkey limit"); } // --------------------------------------------------------------------------- // Password // --------------------------------------------------------------------------- #[tokio::test] async fn a_short_new_password_is_refused() { let env = Env::new().await; let admin = env.admin().await; let r = admin .post_json("/api/auth/password", &json!({ "new_password": "short" })) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST); } #[tokio::test] async fn changing_the_password_ends_every_other_session() { let env = Env::new().await; let admin = env.admin().await; // A second sign-in, as if from another browser. let other = login(&env, "admin", "admin1234").await; assert_eq!(other.get("/api/auth/me").await.status, StatusCode::OK); let r = admin .post_json( "/api/auth/password", &json!({ "new_password": "brandnew1" }), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); // The session that made the change survives; the other one does not. assert_eq!(admin.get("/api/auth/me").await.status, StatusCode::OK); assert_eq!( other.get("/api/auth/me").await.status, StatusCode::UNAUTHORIZED ); let _ = login(&env, "admin", "brandnew1").await; } #[tokio::test] async fn the_password_cannot_go_while_it_is_the_only_credential() { let env = Env::new().await; let admin = env.admin().await; let r = admin.delete("/api/auth/password").await; assert_eq!(r.status, StatusCode::BAD_REQUEST, "{}", r.text()); assert_eq!(r.json()["code"], "err_password_last_credential"); } #[tokio::test] async fn removing_the_password_leaves_a_passkey_only_account() { let env = Env::new().await; let admin = env.admin().await; let id = user_id(&admin, "admin").await; give_passkey(&env, id, b"cred-only").await; let r = admin.delete("/api/auth/password").await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); // The session that did it keeps working, and now says so. let me = admin.get("/api/auth/me").await.json(); assert_eq!(me["user"]["has_password"], false); // The old password is not "still valid but unused" — it is gone. let c = Client::new(env.app.clone()); let r = c .post_json( "/api/auth/login", &json!({ "name": "admin", "password": "admin1234" }), ) .await; assert_eq!(r.status, StatusCode::UNAUTHORIZED); assert!(session_cookie(&r).is_none()); } #[tokio::test] async fn a_passkey_only_account_can_set_a_password_again() { let env = Env::new().await; let admin = env.admin().await; let id = user_id(&admin, "admin").await; give_passkey(&env, id, b"cred-again").await; admin.delete("/api/auth/password").await; // Setting a first password on a passkey-only account is the same call. let r = admin .post_json( "/api/auth/password", &json!({ "new_password": "secondgo1" }), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let _ = login(&env, "admin", "secondgo1").await; } // --------------------------------------------------------------------------- // Passkey list // --------------------------------------------------------------------------- #[tokio::test] async fn the_passkey_list_is_per_account_and_carries_no_key_material() { let env = Env::new().await; let admin = env.admin().await; let admin_id = user_id(&admin, "admin").await; create_user(&admin, "bob", "bobpass12", &[("docs", "rw")]).await; let bob_id = user_id(&admin, "bob").await; give_passkey(&env, admin_id, b"cred-admin").await; give_passkey(&env, bob_id, b"cred-bob").await; let j = admin.get("/api/auth/passkeys").await.json(); let list = j.as_array().unwrap(); assert_eq!(list.len(), 1, "admin must not see bob's passkey"); assert_eq!(list[0]["name"], "Test key"); assert_eq!(list[0]["discoverable"], true); assert!(list[0]["last_used_at"].is_null()); assert!( !j.to_string().contains("cred_id"), "the list leaked credential internals: {j}" ); let bob = login(&env, "bob", "bobpass12").await; assert_ne!( bob.get("/api/auth/passkeys").await.json()[0]["id"], list[0]["id"], "bob sees the admin's passkey" ); } #[tokio::test] async fn the_last_passkey_cannot_go_while_there_is_no_password() { let env = Env::new().await; let admin = env.admin().await; let id = user_id(&admin, "admin").await; give_passkey(&env, id, b"cred-last").await; admin.delete("/api/auth/password").await; let pk_id = admin.get("/api/auth/passkeys").await.json()[0]["id"] .as_i64() .unwrap(); let r = admin.delete(&format!("/api/auth/passkeys/{pk_id}")).await; assert_eq!(r.status, StatusCode::BAD_REQUEST, "{}", r.text()); assert_eq!(r.json()["code"], "err_passkey_last_credential"); assert_eq!(admin.get("/api/auth/passkeys").await.json()[0]["id"], pk_id); } #[tokio::test] async fn one_of_several_passkeys_can_always_go() { let env = Env::new().await; let admin = env.admin().await; let id = user_id(&admin, "admin").await; give_passkey(&env, id, b"cred-a").await; give_passkey(&env, id, b"cred-b").await; let pk_id = admin.get("/api/auth/passkeys").await.json()[0]["id"] .as_i64() .unwrap(); let r = admin.delete(&format!("/api/auth/passkeys/{pk_id}")).await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert_eq!( admin .get("/api/auth/passkeys") .await .json() .as_array() .unwrap() .len(), 1 ); } #[tokio::test] async fn another_accounts_passkey_is_out_of_reach() { let env = Env::new().await; let admin = env.admin().await; create_user(&admin, "bob", "bobpass12", &[("docs", "rw")]).await; let bob_id = user_id(&admin, "bob").await; give_passkey(&env, bob_id, b"cred-bob2").await; let bob = login(&env, "bob", "bobpass12").await; let pk_id = bob.get("/api/auth/passkeys").await.json()[0]["id"] .as_i64() .unwrap(); // Even an admin cannot reach it here: this route is self-service only. let r = admin.delete(&format!("/api/auth/passkeys/{pk_id}")).await; assert_eq!(r.status, StatusCode::NOT_FOUND); assert_eq!( bob.get("/api/auth/passkeys") .await .json() .as_array() .unwrap() .len(), 1 ); } // --------------------------------------------------------------------------- // Sign-in requirement // --------------------------------------------------------------------------- #[tokio::test] async fn requiring_both_needs_both_to_exist() { let env = Env::new().await; let admin = env.admin().await; let id = user_id(&admin, "admin").await; // No passkey yet. let r = admin .put_json("/api/auth/mode", &json!({ "mode": "both" })) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST); assert_eq!(r.json()["code"], "err_mode_needs_passkey"); give_passkey(&env, id, b"cred-mode").await; let r = admin .put_json("/api/auth/mode", &json!({ "mode": "both" })) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert_eq!( admin.get("/api/auth/me").await.json()["user"]["auth_mode"], "both" ); } #[tokio::test] async fn requiring_both_blocks_removing_either_half() { let env = Env::new().await; let admin = env.admin().await; let id = user_id(&admin, "admin").await; give_passkey(&env, id, b"cred-both").await; admin .put_json("/api/auth/mode", &json!({ "mode": "both" })) .await; let r = admin.delete("/api/auth/password").await; assert_eq!(r.status, StatusCode::BAD_REQUEST); assert_eq!(r.json()["code"], "err_required_by_mode"); let pk_id = admin.get("/api/auth/passkeys").await.json()[0]["id"] .as_i64() .unwrap(); let r = admin.delete(&format!("/api/auth/passkeys/{pk_id}")).await; assert_eq!(r.status, StatusCode::BAD_REQUEST); assert_eq!(r.json()["code"], "err_required_by_mode"); } #[tokio::test] async fn the_password_alone_never_signs_in_an_account_that_requires_both() { let env = Env::new().await; let admin = env.admin().await; let id = user_id(&admin, "admin").await; give_passkey(&env, id, b"cred-2fa").await; admin .put_json("/api/auth/mode", &json!({ "mode": "both" })) .await; let c = Client::new(env.app.clone()); let r = c .post_json( "/api/auth/login", &json!({ "name": "admin", "password": "admin1234" }), ) .await; // Right password, no session: a passkey challenge comes back instead. assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert!( session_cookie(&r).is_none(), "a session was handed out on the password alone" ); let j = r.json(); assert_eq!(j["ok"], false); assert!(j["passkey_challenge"]["state_id"].is_string()); assert!( j["passkey_challenge"]["options"] .as_str() .unwrap() .contains("challenge"), "the challenge carries no options: {j}" ); } #[tokio::test] async fn a_wrong_password_reveals_nothing_about_the_second_factor() { let env = Env::new().await; let admin = env.admin().await; let id = user_id(&admin, "admin").await; give_passkey(&env, id, b"cred-2fa2").await; admin .put_json("/api/auth/mode", &json!({ "mode": "both" })) .await; let c = Client::new(env.app.clone()); let r = c .post_json( "/api/auth/login", &json!({ "name": "admin", "password": "not-the-password" }), ) .await; assert_eq!(r.status, StatusCode::UNAUTHORIZED); assert_eq!(r.json()["code"], "err_invalid_credentials"); } // --------------------------------------------------------------------------- // WebDAV // --------------------------------------------------------------------------- #[tokio::test] async fn webdav_refuses_an_account_that_requires_a_passkey() { let env = Env::new().await; let admin = env.admin().await; let id = user_id(&admin, "admin").await; // Basic auth works before the switch. let c = Client::new(env.app.clone()); let r = c .raw( Method::from_bytes(b"PROPFIND").unwrap(), "/dav", &[ ("depth", "1"), ("authorization", &basic("admin", "admin1234")), ], Vec::new(), ) .await; assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text()); give_passkey(&env, id, b"cred-dav").await; admin .put_json("/api/auth/mode", &json!({ "mode": "both" })) .await; // Basic carries a password and nothing else, so it can no longer stand // in for both factors. let r = c .raw( Method::from_bytes(b"PROPFIND").unwrap(), "/dav", &[ ("depth", "1"), ("authorization", &basic("admin", "admin1234")), ], Vec::new(), ) .await; assert_eq!(r.status, StatusCode::UNAUTHORIZED, "{}", r.text()); } // --------------------------------------------------------------------------- // Passkey sign-in // --------------------------------------------------------------------------- #[tokio::test] async fn beginning_a_passkey_sign_in_never_says_whether_a_name_exists() { let env = Env::new().await; let admin = env.admin().await; let id = user_id(&admin, "admin").await; give_passkey(&env, id, b"cred-probe").await; let c = Client::new(env.app.clone()); // An unknown name, a real name without passkeys, and no name at all must // be indistinguishable: all three get a usable challenge. create_user(&admin, "bob", "bobpass12", &[("docs", "rw")]).await; for body in [ json!({ "name": "nobody-here" }), json!({ "name": "bob" }), json!({}), ] { let r = c.post_json("/api/auth/passkey/login", &body).await; assert_eq!(r.status, StatusCode::OK, "{body}: {}", r.text()); let j = r.json(); assert!(j["state_id"].is_string(), "{body}: {j}"); assert!(j["options"].as_str().unwrap().contains("challenge")); } // A name with passkeys gets exactly the same shape. Everything the client // can see must match, or the difference is the oracle. let real = begin_named(&c, "admin").await; let fake = begin_named(&c, "nobody-here").await; assert_eq!(real, fake, "a named challenge must not depend on the name"); } #[tokio::test] async fn a_second_spelling_of_a_name_gives_nothing_away() { let env = Env::new().await; let admin = env.admin().await; let id = user_id(&admin, "admin").await; give_passkey(&env, id, b"cred-case").await; let c = Client::new(env.app.clone()); // Account names are case-insensitive, so both spellings reach the same // account and repeat the same real credential. If the decoys around it // moved, comparing the two answers would show which entries were real. let lower = begin_named_raw(&c, "admin").await; let upper = begin_named_raw(&c, "ADMIN").await; assert_eq!(lower, upper, "a name's case changed its credential list"); // And an unknown name must not share entries with either spelling of it. let miss = begin_named_raw(&c, "nobody").await; let miss_upper = begin_named_raw(&c, "NOBODY").await; assert_eq!(miss, miss_upper); assert_ne!(miss, lower); } /// The credential ids of a named challenge, in order. async fn begin_named_raw(c: &Client, name: &str) -> Vec { let r = c .post_json("/api/auth/passkey/login", &json!({ "name": name })) .await; assert_eq!(r.status, StatusCode::OK, "{name}: {}", r.text()); let opts: serde_json::Value = serde_json::from_str(r.json()["options"].as_str().unwrap()).unwrap(); opts["publicKey"]["allowCredentials"] .as_array() .unwrap() .iter() .map(|c| c["id"].as_str().unwrap().to_string()) .collect() } /// The visible shape of a named challenge, with the parts that are random by /// design blanked out. What is left must not depend on whether the name exists. async fn begin_named(c: &Client, name: &str) -> serde_json::Value { let r = c .post_json("/api/auth/passkey/login", &json!({ "name": name })) .await; assert_eq!(r.status, StatusCode::OK, "{name}: {}", r.text()); let mut opts: serde_json::Value = serde_json::from_str(r.json()["options"].as_str().unwrap()).unwrap(); let key = &mut opts["publicKey"]; key["challenge"] = json!(""); // The ids differ between the two by construction. Their count and their // lengths are what a probe could read, so keep those. for cred in key["allowCredentials"].as_array_mut().unwrap() { let len = cred["id"].as_str().unwrap().len(); cred["id"] = json!(len); } opts } #[tokio::test] async fn a_challenge_for_an_unknown_name_can_never_sign_anyone_in() { let env = Env::new().await; let _ = env.admin().await; let c = Client::new(env.app.clone()); let r = c .post_json("/api/auth/passkey/login", &json!({ "name": "nobody-here" })) .await; let state_id = r.json()["state_id"].as_str().unwrap().to_string(); // The ceremony looks real on purpose. Finishing it must not: a visitor // holding any passkey for this site could otherwise answer it, get signed // in as themselves, and read the name's absence off the 200. let pending = server::webauthn::take(&state_id).expect("the handle was stored"); assert!( matches!( pending, server::webauthn::Pending::Discoverable { decoy: true, .. } ), "a challenge for an unknown name must be marked as a decoy" ); } #[tokio::test] async fn an_account_cannot_hold_more_passkeys_than_a_challenge_lists() { let env = Env::new().await; let admin = env.admin().await; let id = user_id(&admin, "admin").await; for n in 0..server::db::PASSKEY_LIMIT { give_passkey(&env, id, format!("cred-{n}").as_bytes()).await; } // One past the limit must not land. Otherwise this account's sign-in // challenge would be longer than everyone else's and say who it is. let over = env .state .db .add_passkey(id, b"over", STORED_PASSKEY, "One too many", Some(true)) .await .unwrap(); assert!(over.is_none(), "the limit let an extra passkey through"); let r = admin .post_json("/api/auth/passkeys/register", &json!({})) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST, "{}", r.text()); assert_eq!(r.json()["code"], "err_passkey_limit"); let listed = begin_named_raw(&Client::new(env.app.clone()), "admin").await; assert_eq!(listed.len(), server::db::PASSKEY_LIMIT); } #[tokio::test] async fn decoy_credentials_stay_the_same_between_requests() { let env = Env::new().await; let _ = env.admin().await; let c = Client::new(env.app.clone()); // A real account lists stable credential ids. A decoy must too, or two // probes of one name would tell an attacker it was never real. let first = c .post_json("/api/auth/passkey/login", &json!({ "name": "nobody-here" })) .await; let second = c .post_json("/api/auth/passkey/login", &json!({ "name": "nobody-here" })) .await; let ids = |r: &Resp| -> Vec { let opts: serde_json::Value = serde_json::from_str(r.json()["options"].as_str().unwrap()).unwrap(); opts["publicKey"]["allowCredentials"] .as_array() .unwrap() .iter() .map(|c| c["id"].as_str().unwrap().to_string()) .collect() }; let ids = (ids(&first), ids(&second)); assert_eq!(ids.0.len(), server::db::PASSKEY_LIMIT); assert_eq!(ids.0, ids.1, "decoys must not change between requests"); } #[tokio::test] async fn a_conditional_challenge_asks_for_autofill_mediation() { let env = Env::new().await; let _ = env.admin().await; let c = Client::new(env.app.clone()); let r = c .post_json("/api/auth/passkey/login", &json!({ "conditional": true })) .await; assert_eq!(r.status, StatusCode::OK); assert!( r.json()["options"] .as_str() .unwrap() .contains("conditional") ); // The button wants the modal picker, so the same route must not ask for // mediation there. let r = c.post_json("/api/auth/passkey/login", &json!({})).await; assert!( !r.json()["options"] .as_str() .unwrap() .contains("conditional") ); } #[tokio::test] async fn a_handle_cannot_be_answered_twice_or_invented() { let env = Env::new().await; let _ = env.admin().await; let c = Client::new(env.app.clone()); let r = c .post_json( "/api/auth/passkey/login/finish", &json!({ "state_id": "never-issued", "credential": "{}" }), ) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST); assert_eq!(r.json()["code"], "err_challenge_expired"); // A real handle, then a garbage answer, then the same handle again. let begin = c .post_json("/api/auth/passkey/login", &json!({})) .await .json(); let state_id = begin["state_id"].as_str().unwrap().to_string(); let body = json!({ "state_id": state_id, "credential": "not json" }); let r = c.post_json("/api/auth/passkey/login/finish", &body).await; assert_eq!(r.status, StatusCode::BAD_REQUEST); let r = c.post_json("/api/auth/passkey/login/finish", &body).await; assert_eq!(r.json()["code"], "err_challenge_expired"); } #[tokio::test] async fn a_registration_handle_cannot_be_used_to_sign_in() { let env = Env::new().await; let admin = env.admin().await; let begin = admin .post_json("/api/auth/passkeys/register", &json!({})) .await; assert_eq!(begin.status, StatusCode::OK, "{}", begin.text()); let state_id = begin.json()["state_id"].as_str().unwrap().to_string(); let c = Client::new(env.app.clone()); let r = c .post_json( "/api/auth/passkey/login/finish", &json!({ "state_id": state_id, "credential": STORED_PASSKEY }), ) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST); assert!(session_cookie(&r).is_none()); } #[tokio::test] async fn registration_asks_the_authenticator_to_store_the_credential() { let env = Env::new().await; let admin = env.admin().await; let r = admin .post_json("/api/auth/passkeys/register", &json!({})) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let options = r.json()["options"].as_str().unwrap().to_string(); // `webauthn-rs` asks for "discouraged" by default, and password managers // obey it: every credential would then need the account name typed in // before it could be found again. assert!( options.contains(r#""residentKey":"required""#), "registration must ask for a discoverable credential: {options}" ); // User verification too, so a passkey on its own is still two factors. assert!( options.contains(r#""userVerification":"required""#), "{options}" ); } #[tokio::test] async fn every_credential_route_needs_a_session() { let env = Env::new().await; let _ = env.admin().await; let c = Client::new(env.app.clone()); assert_eq!( c.get("/api/auth/passkeys").await.status, StatusCode::UNAUTHORIZED ); assert_eq!( c.post_json( "/api/auth/password", &json!({ "new_password": "whatever1" }) ) .await .status, StatusCode::UNAUTHORIZED ); assert_eq!( c.put_json("/api/auth/mode", &json!({ "mode": "either" })) .await .status, StatusCode::UNAUTHORIZED ); assert_eq!( c.post_json("/api/auth/passkeys/register", &json!({})) .await .status, StatusCode::UNAUTHORIZED ); assert_eq!( c.delete("/api/auth/password").await.status, StatusCode::UNAUTHORIZED ); assert_eq!( c.delete("/api/auth/passkeys/1").await.status, StatusCode::UNAUTHORIZED ); assert_eq!( c.post_json( "/api/auth/passkeys/register/finish", &json!({ "state_id": "x", "name": "k", "credential": "{}" }) ) .await .status, StatusCode::UNAUTHORIZED ); } // --------------------------------------------------------------------------- // Signing in with the passkey first // --------------------------------------------------------------------------- #[tokio::test] async fn the_passkey_first_order_signs_in_only_with_the_right_password() { let env = Env::new().await; let admin = env.admin().await; let id = user_id(&admin, "admin").await; give_passkey(&env, id, b"cred-first").await; admin .put_json("/api/auth/mode", &json!({ "mode": "both" })) .await; let c = Client::new(env.app.clone()); // Stand in for a passkey that already verified. This is the only branch // that hands out a session without a passkey ceremony in the request, so // it gets checked directly. let handle = server::webauthn::put(server::webauthn::Pending::NeedsPassword { user_id: id }); let r = c .post_json( "/api/auth/login", &json!({ "state_id": handle, "password": "wrong-one-1" }), ) .await; assert_eq!(r.status, StatusCode::UNAUTHORIZED, "{}", r.text()); assert!(session_cookie(&r).is_none(), "a wrong password signed in"); // The handle went with that attempt, so it cannot be tried again. That is // what stops one verified passkey from becoming unlimited password tries. let r = c .post_json( "/api/auth/login", &json!({ "state_id": handle, "password": "admin1234" }), ) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST, "{}", r.text()); assert_eq!(r.json()["code"], "err_challenge_expired"); assert!(session_cookie(&r).is_none()); // A fresh handle and the right password: now it is a session. let handle = server::webauthn::put(server::webauthn::Pending::NeedsPassword { user_id: id }); let r = c .post_json( "/api/auth/login", &json!({ "state_id": handle, "password": "admin1234" }), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert!( session_cookie(&r).is_some(), "the right password got nothing" ); } #[tokio::test] async fn an_invented_login_handle_is_refused() { let env = Env::new().await; let admin = env.admin().await; let c = Client::new(env.app.clone()); let r = c .post_json( "/api/auth/login", &json!({ "state_id": "never-issued", "password": "admin1234" }), ) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST, "{}", r.text()); assert!(session_cookie(&r).is_none()); // A registration handle names a user too, but it is not a passed factor. let begin = admin .post_json("/api/auth/passkeys/register", &json!({})) .await; let handle = begin.json()["state_id"].as_str().unwrap().to_string(); let r = c .post_json( "/api/auth/login", &json!({ "state_id": handle, "password": "admin1234" }), ) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST, "{}", r.text()); assert!(session_cookie(&r).is_none()); } #[tokio::test] async fn signing_in_works_on_a_host_passkeys_cannot_use() { let env = Env::new().await; let _ = env.admin().await; let c = Client::new(env.app.clone()); // WebAuthn needs a registrable domain, and a bare IP is not one. That must // cost passkeys only, never the password sign-in every deployment uses. // `--bind 127.0.0.1` is the default, so this is the normal case. let r = c .raw( Method::POST, "/api/auth/login", &[ ("content-type", "application/json"), ("host", "192.168.1.10:8080"), ], json!({ "name": "admin", "password": "admin1234" }) .to_string() .into_bytes(), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert!(session_cookie(&r).is_some()); } // --------------------------------------------------------------------------- // Admin recovery // --------------------------------------------------------------------------- #[tokio::test] async fn an_admin_password_clears_the_passkeys_and_the_requirement() { let env = Env::new().await; let admin = env.admin().await; create_user(&admin, "bob", "bobpass12", &[("docs", "rw")]).await; let bob_id = user_id(&admin, "bob").await; give_passkey(&env, bob_id, b"cred-locked").await; env.state .db .set_user_auth_mode(bob_id, api_types::AuthMode::Both) .await .unwrap(); // An edit that sets no password leaves bob's credentials alone. let r = admin .put_json( &format!("/api/admin/users/{bob_id}"), &json!({ "active": true }), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert_eq!(env.state.db.count_passkeys(bob_id).await.unwrap(), 1); // Setting one is the recovery path, and it is the whole recovery: the // admin cannot hand over a password and leave a second factor bob no // longer has. let r = admin .put_json( &format!("/api/admin/users/{bob_id}"), &json!({ "password": "rescued12" }), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); // Bob is back on a plain password sign-in, with no passkeys left. let bob = login(&env, "bob", "rescued12").await; let me = bob.get("/api/auth/me").await.json(); assert_eq!(me["user"]["auth_mode"], "either"); assert_eq!(me["user"]["has_password"], true); assert_eq!( bob.get("/api/auth/passkeys").await.json(), json!([]), "the passkeys survived the reset" ); } #[tokio::test] async fn deleting_an_account_takes_its_passkeys_with_it() { let env = Env::new().await; let admin = env.admin().await; create_user(&admin, "bob", "bobpass12", &[("docs", "rw")]).await; let bob_id = user_id(&admin, "bob").await; give_passkey(&env, bob_id, b"cred-gone").await; let r = admin.delete(&format!("/api/admin/users/{bob_id}")).await; assert_eq!(r.status, StatusCode::OK); assert_eq!(env.state.db.count_passkeys(bob_id).await.unwrap(), 0); }