//! 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. use crate::common::*; use axum::http::{Method, StatusCode}; 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. 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") .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!(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 a_passkey_challenge_never_names_an_account() { 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()); // This route needs no session. A credential list would say which names // exist, so a name in the body must change nothing. for body in [json!({ "name": "admin" }), json!({})] { let r = c.post_json("/api/auth/passkey/login", &body).await; assert_eq!(r.status, StatusCode::OK, "{body}: {}", r.text()); let opts: serde_json::Value = serde_json::from_str(r.json()["options"].as_str().unwrap()).unwrap(); let allow = &opts["publicKey"]["allowCredentials"]; assert!( allow.is_null() || allow.as_array().is_some_and(Vec::is_empty), "{body}: {opts}" ); } } #[tokio::test] async fn an_account_cannot_hold_more_passkeys_than_the_limit() { 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; } let over = env .state .db .add_passkey(id, b"over", STORED_PASSKEY, "One too many") .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"); } #[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. Sign-in only finds // credentials the authenticator stores itself. assert!( options.contains(r#""residentKey":"required""#), "registration must ask for a discoverable credential: {options}" ); // Browsers that predate `residentKey` read only this flag. assert!( options.contains(r#""requireResidentKey":true"#), "{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); }