passkeys.rs
⎇
Raw
1//! Self-service credentials: changing and removing the password, the
2//! passkey list, and the rules that keep an account reachable.
3//!
4//! No real authenticator exists here, so nothing verifies a signature — that
5//! is `webauthn-rs`' job and it has its own tests. What these tests cover is
6//! everything around it: which combinations the server accepts, what it
7//! refuses, and that a half-finished sign-in never becomes a session.
8
9use crate::common::*;
10use axum::http::{Method, StatusCode};
11use serde_json::json;
12
13/// A syntactically valid stored passkey.
14///
15/// The key is all zeroes, so it can never verify anything. Every test here
16/// either counts passkeys or checks that a ceremony is *refused*, and for
17/// both a credential that parses is enough.
18const 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"}}"#;
19
20/// Put a passkey on an account without a browser.
21///
22/// `label` only has to be unique; the stored id is four bytes derived from it.
23async fn give_passkey(env: &Env, user_id: i64, label: &[u8]) {
24 use std::hash::{DefaultHasher, Hash, Hasher};
25 let mut h = DefaultHasher::new();
26 label.hash(&mut h);
27 let cred_id = (h.finish() as u32).to_le_bytes();
28 env.state
29 .db
30 .add_passkey(user_id, &cred_id, STORED_PASSKEY, "Test key")
31 .await
32 .unwrap()
33 .expect("the account was already at the passkey limit");
34}
35
36// ---------------------------------------------------------------------------
37// Password
38// ---------------------------------------------------------------------------
39
40#[tokio::test]
41async fn a_short_new_password_is_refused() {
42 let env = Env::new().await;
43 let admin = env.admin().await;
44 let r = admin
45 .post_json("/api/auth/password", &json!({ "new_password": "short" }))
46 .await;
47 assert_eq!(r.status, StatusCode::BAD_REQUEST);
48}
49
50#[tokio::test]
51async fn changing_the_password_ends_every_other_session() {
52 let env = Env::new().await;
53 let admin = env.admin().await;
54 // A second sign-in, as if from another browser.
55 let other = login(&env, "admin", "admin1234").await;
56 assert_eq!(other.get("/api/auth/me").await.status, StatusCode::OK);
57
58 let r = admin
59 .post_json(
60 "/api/auth/password",
61 &json!({ "new_password": "brandnew1" }),
62 )
63 .await;
64 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
65
66 // The session that made the change survives; the other one does not.
67 assert_eq!(admin.get("/api/auth/me").await.status, StatusCode::OK);
68 assert_eq!(
69 other.get("/api/auth/me").await.status,
70 StatusCode::UNAUTHORIZED
71 );
72 let _ = login(&env, "admin", "brandnew1").await;
73}
74
75#[tokio::test]
76async fn the_password_cannot_go_while_it_is_the_only_credential() {
77 let env = Env::new().await;
78 let admin = env.admin().await;
79 let r = admin.delete("/api/auth/password").await;
80 assert_eq!(r.status, StatusCode::BAD_REQUEST, "{}", r.text());
81 assert_eq!(r.json()["code"], "err_password_last_credential");
82}
83
84#[tokio::test]
85async fn removing_the_password_leaves_a_passkey_only_account() {
86 let env = Env::new().await;
87 let admin = env.admin().await;
88 let id = user_id(&admin, "admin").await;
89 give_passkey(&env, id, b"cred-only").await;
90
91 let r = admin.delete("/api/auth/password").await;
92 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
93
94 // The session that did it keeps working, and now says so.
95 let me = admin.get("/api/auth/me").await.json();
96 assert_eq!(me["user"]["has_password"], false);
97
98 // The old password is not "still valid but unused" — it is gone.
99 let c = Client::new(env.app.clone());
100 let r = c
101 .post_json(
102 "/api/auth/login",
103 &json!({ "name": "admin", "password": "admin1234" }),
104 )
105 .await;
106 assert_eq!(r.status, StatusCode::UNAUTHORIZED);
107 assert!(session_cookie(&r).is_none());
108}
109
110#[tokio::test]
111async fn a_passkey_only_account_can_set_a_password_again() {
112 let env = Env::new().await;
113 let admin = env.admin().await;
114 let id = user_id(&admin, "admin").await;
115 give_passkey(&env, id, b"cred-again").await;
116 admin.delete("/api/auth/password").await;
117
118 // Setting a first password on a passkey-only account is the same call.
119 let r = admin
120 .post_json(
121 "/api/auth/password",
122 &json!({ "new_password": "secondgo1" }),
123 )
124 .await;
125 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
126 let _ = login(&env, "admin", "secondgo1").await;
127}
128
129// ---------------------------------------------------------------------------
130// Passkey list
131// ---------------------------------------------------------------------------
132
133#[tokio::test]
134async fn the_passkey_list_is_per_account_and_carries_no_key_material() {
135 let env = Env::new().await;
136 let admin = env.admin().await;
137 let admin_id = user_id(&admin, "admin").await;
138 create_user(&admin, "bob", "bobpass12", &[("docs", "rw")]).await;
139 let bob_id = user_id(&admin, "bob").await;
140 give_passkey(&env, admin_id, b"cred-admin").await;
141 give_passkey(&env, bob_id, b"cred-bob").await;
142
143 let j = admin.get("/api/auth/passkeys").await.json();
144 let list = j.as_array().unwrap();
145 assert_eq!(list.len(), 1, "admin must not see bob's passkey");
146 assert_eq!(list[0]["name"], "Test key");
147 assert!(list[0]["last_used_at"].is_null());
148 assert!(
149 !j.to_string().contains("cred_id"),
150 "the list leaked credential internals: {j}"
151 );
152
153 let bob = login(&env, "bob", "bobpass12").await;
154 assert_ne!(
155 bob.get("/api/auth/passkeys").await.json()[0]["id"],
156 list[0]["id"],
157 "bob sees the admin's passkey"
158 );
159}
160
161#[tokio::test]
162async fn the_last_passkey_cannot_go_while_there_is_no_password() {
163 let env = Env::new().await;
164 let admin = env.admin().await;
165 let id = user_id(&admin, "admin").await;
166 give_passkey(&env, id, b"cred-last").await;
167 admin.delete("/api/auth/password").await;
168
169 let pk_id = admin.get("/api/auth/passkeys").await.json()[0]["id"]
170 .as_i64()
171 .unwrap();
172 let r = admin.delete(&format!("/api/auth/passkeys/{pk_id}")).await;
173 assert_eq!(r.status, StatusCode::BAD_REQUEST, "{}", r.text());
174 assert_eq!(r.json()["code"], "err_passkey_last_credential");
175 assert_eq!(admin.get("/api/auth/passkeys").await.json()[0]["id"], pk_id);
176}
177
178#[tokio::test]
179async fn one_of_several_passkeys_can_always_go() {
180 let env = Env::new().await;
181 let admin = env.admin().await;
182 let id = user_id(&admin, "admin").await;
183 give_passkey(&env, id, b"cred-a").await;
184 give_passkey(&env, id, b"cred-b").await;
185
186 let pk_id = admin.get("/api/auth/passkeys").await.json()[0]["id"]
187 .as_i64()
188 .unwrap();
189 let r = admin.delete(&format!("/api/auth/passkeys/{pk_id}")).await;
190 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
191 assert_eq!(
192 admin
193 .get("/api/auth/passkeys")
194 .await
195 .json()
196 .as_array()
197 .unwrap()
198 .len(),
199 1
200 );
201}
202
203#[tokio::test]
204async fn another_accounts_passkey_is_out_of_reach() {
205 let env = Env::new().await;
206 let admin = env.admin().await;
207 create_user(&admin, "bob", "bobpass12", &[("docs", "rw")]).await;
208 let bob_id = user_id(&admin, "bob").await;
209 give_passkey(&env, bob_id, b"cred-bob2").await;
210 let bob = login(&env, "bob", "bobpass12").await;
211 let pk_id = bob.get("/api/auth/passkeys").await.json()[0]["id"]
212 .as_i64()
213 .unwrap();
214
215 // Even an admin cannot reach it here: this route is self-service only.
216 let r = admin.delete(&format!("/api/auth/passkeys/{pk_id}")).await;
217 assert_eq!(r.status, StatusCode::NOT_FOUND);
218 assert_eq!(
219 bob.get("/api/auth/passkeys")
220 .await
221 .json()
222 .as_array()
223 .unwrap()
224 .len(),
225 1
226 );
227}
228
229// ---------------------------------------------------------------------------
230// Sign-in requirement
231// ---------------------------------------------------------------------------
232
233#[tokio::test]
234async fn requiring_both_needs_both_to_exist() {
235 let env = Env::new().await;
236 let admin = env.admin().await;
237 let id = user_id(&admin, "admin").await;
238
239 // No passkey yet.
240 let r = admin
241 .put_json("/api/auth/mode", &json!({ "mode": "both" }))
242 .await;
243 assert_eq!(r.status, StatusCode::BAD_REQUEST);
244 assert_eq!(r.json()["code"], "err_mode_needs_passkey");
245
246 give_passkey(&env, id, b"cred-mode").await;
247 let r = admin
248 .put_json("/api/auth/mode", &json!({ "mode": "both" }))
249 .await;
250 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
251 assert_eq!(
252 admin.get("/api/auth/me").await.json()["user"]["auth_mode"],
253 "both"
254 );
255}
256
257#[tokio::test]
258async fn requiring_both_blocks_removing_either_half() {
259 let env = Env::new().await;
260 let admin = env.admin().await;
261 let id = user_id(&admin, "admin").await;
262 give_passkey(&env, id, b"cred-both").await;
263 admin
264 .put_json("/api/auth/mode", &json!({ "mode": "both" }))
265 .await;
266
267 let r = admin.delete("/api/auth/password").await;
268 assert_eq!(r.status, StatusCode::BAD_REQUEST);
269 assert_eq!(r.json()["code"], "err_required_by_mode");
270
271 let pk_id = admin.get("/api/auth/passkeys").await.json()[0]["id"]
272 .as_i64()
273 .unwrap();
274 let r = admin.delete(&format!("/api/auth/passkeys/{pk_id}")).await;
275 assert_eq!(r.status, StatusCode::BAD_REQUEST);
276 assert_eq!(r.json()["code"], "err_required_by_mode");
277}
278
279#[tokio::test]
280async fn the_password_alone_never_signs_in_an_account_that_requires_both() {
281 let env = Env::new().await;
282 let admin = env.admin().await;
283 let id = user_id(&admin, "admin").await;
284 give_passkey(&env, id, b"cred-2fa").await;
285 admin
286 .put_json("/api/auth/mode", &json!({ "mode": "both" }))
287 .await;
288
289 let c = Client::new(env.app.clone());
290 let r = c
291 .post_json(
292 "/api/auth/login",
293 &json!({ "name": "admin", "password": "admin1234" }),
294 )
295 .await;
296 // Right password, no session: a passkey challenge comes back instead.
297 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
298 assert!(
299 session_cookie(&r).is_none(),
300 "a session was handed out on the password alone"
301 );
302 let j = r.json();
303 assert_eq!(j["ok"], false);
304 assert!(j["passkey_challenge"]["state_id"].is_string());
305 assert!(
306 j["passkey_challenge"]["options"]
307 .as_str()
308 .unwrap()
309 .contains("challenge"),
310 "the challenge carries no options: {j}"
311 );
312}
313
314#[tokio::test]
315async fn a_wrong_password_reveals_nothing_about_the_second_factor() {
316 let env = Env::new().await;
317 let admin = env.admin().await;
318 let id = user_id(&admin, "admin").await;
319 give_passkey(&env, id, b"cred-2fa2").await;
320 admin
321 .put_json("/api/auth/mode", &json!({ "mode": "both" }))
322 .await;
323
324 let c = Client::new(env.app.clone());
325 let r = c
326 .post_json(
327 "/api/auth/login",
328 &json!({ "name": "admin", "password": "not-the-password" }),
329 )
330 .await;
331 assert_eq!(r.status, StatusCode::UNAUTHORIZED);
332 assert_eq!(r.json()["code"], "err_invalid_credentials");
333}
334
335// ---------------------------------------------------------------------------
336// WebDAV
337// ---------------------------------------------------------------------------
338
339#[tokio::test]
340async fn webdav_refuses_an_account_that_requires_a_passkey() {
341 let env = Env::new().await;
342 let admin = env.admin().await;
343 let id = user_id(&admin, "admin").await;
344
345 // Basic auth works before the switch.
346 let c = Client::new(env.app.clone());
347 let r = c
348 .raw(
349 Method::from_bytes(b"PROPFIND").unwrap(),
350 "/dav",
351 &[
352 ("depth", "1"),
353 ("authorization", &basic("admin", "admin1234")),
354 ],
355 Vec::new(),
356 )
357 .await;
358 assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
359
360 give_passkey(&env, id, b"cred-dav").await;
361 admin
362 .put_json("/api/auth/mode", &json!({ "mode": "both" }))
363 .await;
364
365 // Basic carries a password and nothing else, so it can no longer stand
366 // in for both factors.
367 let r = c
368 .raw(
369 Method::from_bytes(b"PROPFIND").unwrap(),
370 "/dav",
371 &[
372 ("depth", "1"),
373 ("authorization", &basic("admin", "admin1234")),
374 ],
375 Vec::new(),
376 )
377 .await;
378 assert_eq!(r.status, StatusCode::UNAUTHORIZED, "{}", r.text());
379}
380
381// ---------------------------------------------------------------------------
382// Passkey sign-in
383// ---------------------------------------------------------------------------
384
385#[tokio::test]
386async fn a_passkey_challenge_never_names_an_account() {
387 let env = Env::new().await;
388 let admin = env.admin().await;
389 let id = user_id(&admin, "admin").await;
390 give_passkey(&env, id, b"cred-probe").await;
391 let c = Client::new(env.app.clone());
392
393 // This route needs no session. A credential list would say which names
394 // exist, so a name in the body must change nothing.
395 for body in [json!({ "name": "admin" }), json!({})] {
396 let r = c.post_json("/api/auth/passkey/login", &body).await;
397 assert_eq!(r.status, StatusCode::OK, "{body}: {}", r.text());
398 let opts: serde_json::Value =
399 serde_json::from_str(r.json()["options"].as_str().unwrap()).unwrap();
400 let allow = &opts["publicKey"]["allowCredentials"];
401 assert!(
402 allow.is_null() || allow.as_array().is_some_and(Vec::is_empty),
403 "{body}: {opts}"
404 );
405 }
406}
407
408#[tokio::test]
409async fn an_account_cannot_hold_more_passkeys_than_the_limit() {
410 let env = Env::new().await;
411 let admin = env.admin().await;
412 let id = user_id(&admin, "admin").await;
413 for n in 0..server::db::PASSKEY_LIMIT {
414 give_passkey(&env, id, format!("cred-{n}").as_bytes()).await;
415 }
416 let over = env
417 .state
418 .db
419 .add_passkey(id, b"over", STORED_PASSKEY, "One too many")
420 .await
421 .unwrap();
422 assert!(over.is_none(), "the limit let an extra passkey through");
423 let r = admin
424 .post_json("/api/auth/passkeys/register", &json!({}))
425 .await;
426 assert_eq!(r.status, StatusCode::BAD_REQUEST, "{}", r.text());
427 assert_eq!(r.json()["code"], "err_passkey_limit");
428}
429
430#[tokio::test]
431async fn a_conditional_challenge_asks_for_autofill_mediation() {
432 let env = Env::new().await;
433 let _ = env.admin().await;
434 let c = Client::new(env.app.clone());
435 let r = c
436 .post_json("/api/auth/passkey/login", &json!({ "conditional": true }))
437 .await;
438 assert_eq!(r.status, StatusCode::OK);
439 assert!(
440 r.json()["options"]
441 .as_str()
442 .unwrap()
443 .contains("conditional")
444 );
445
446 // The button wants the modal picker, so the same route must not ask for
447 // mediation there.
448 let r = c.post_json("/api/auth/passkey/login", &json!({})).await;
449 assert!(
450 !r.json()["options"]
451 .as_str()
452 .unwrap()
453 .contains("conditional")
454 );
455}
456
457#[tokio::test]
458async fn a_handle_cannot_be_answered_twice_or_invented() {
459 let env = Env::new().await;
460 let _ = env.admin().await;
461 let c = Client::new(env.app.clone());
462
463 let r = c
464 .post_json(
465 "/api/auth/passkey/login/finish",
466 &json!({ "state_id": "never-issued", "credential": "{}" }),
467 )
468 .await;
469 assert_eq!(r.status, StatusCode::BAD_REQUEST);
470 assert_eq!(r.json()["code"], "err_challenge_expired");
471
472 // A real handle, then a garbage answer, then the same handle again.
473 let begin = c
474 .post_json("/api/auth/passkey/login", &json!({}))
475 .await
476 .json();
477 let state_id = begin["state_id"].as_str().unwrap().to_string();
478 let body = json!({ "state_id": state_id, "credential": "not json" });
479 let r = c.post_json("/api/auth/passkey/login/finish", &body).await;
480 assert_eq!(r.status, StatusCode::BAD_REQUEST);
481 let r = c.post_json("/api/auth/passkey/login/finish", &body).await;
482 assert_eq!(r.json()["code"], "err_challenge_expired");
483}
484
485#[tokio::test]
486async fn a_registration_handle_cannot_be_used_to_sign_in() {
487 let env = Env::new().await;
488 let admin = env.admin().await;
489 let begin = admin
490 .post_json("/api/auth/passkeys/register", &json!({}))
491 .await;
492 assert_eq!(begin.status, StatusCode::OK, "{}", begin.text());
493 let state_id = begin.json()["state_id"].as_str().unwrap().to_string();
494
495 let c = Client::new(env.app.clone());
496 let r = c
497 .post_json(
498 "/api/auth/passkey/login/finish",
499 &json!({ "state_id": state_id, "credential": STORED_PASSKEY }),
500 )
501 .await;
502 assert_eq!(r.status, StatusCode::BAD_REQUEST);
503 assert!(session_cookie(&r).is_none());
504}
505
506#[tokio::test]
507async fn registration_asks_the_authenticator_to_store_the_credential() {
508 let env = Env::new().await;
509 let admin = env.admin().await;
510 let r = admin
511 .post_json("/api/auth/passkeys/register", &json!({}))
512 .await;
513 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
514 let options = r.json()["options"].as_str().unwrap().to_string();
515 // `webauthn-rs` asks for "discouraged" by default. Sign-in only finds
516 // credentials the authenticator stores itself.
517 assert!(
518 options.contains(r#""residentKey":"required""#),
519 "registration must ask for a discoverable credential: {options}"
520 );
521 // Browsers that predate `residentKey` read only this flag.
522 assert!(
523 options.contains(r#""requireResidentKey":true"#),
524 "{options}"
525 );
526 // User verification too, so a passkey on its own is still two factors.
527 assert!(
528 options.contains(r#""userVerification":"required""#),
529 "{options}"
530 );
531}
532
533#[tokio::test]
534async fn every_credential_route_needs_a_session() {
535 let env = Env::new().await;
536 let _ = env.admin().await;
537 let c = Client::new(env.app.clone());
538 assert_eq!(
539 c.get("/api/auth/passkeys").await.status,
540 StatusCode::UNAUTHORIZED
541 );
542 assert_eq!(
543 c.post_json(
544 "/api/auth/password",
545 &json!({ "new_password": "whatever1" })
546 )
547 .await
548 .status,
549 StatusCode::UNAUTHORIZED
550 );
551 assert_eq!(
552 c.put_json("/api/auth/mode", &json!({ "mode": "either" }))
553 .await
554 .status,
555 StatusCode::UNAUTHORIZED
556 );
557 assert_eq!(
558 c.post_json("/api/auth/passkeys/register", &json!({}))
559 .await
560 .status,
561 StatusCode::UNAUTHORIZED
562 );
563 assert_eq!(
564 c.delete("/api/auth/password").await.status,
565 StatusCode::UNAUTHORIZED
566 );
567 assert_eq!(
568 c.delete("/api/auth/passkeys/1").await.status,
569 StatusCode::UNAUTHORIZED
570 );
571 assert_eq!(
572 c.post_json(
573 "/api/auth/passkeys/register/finish",
574 &json!({ "state_id": "x", "name": "k", "credential": "{}" })
575 )
576 .await
577 .status,
578 StatusCode::UNAUTHORIZED
579 );
580}
581
582// ---------------------------------------------------------------------------
583// Signing in with the passkey first
584// ---------------------------------------------------------------------------
585
586#[tokio::test]
587async fn the_passkey_first_order_signs_in_only_with_the_right_password() {
588 let env = Env::new().await;
589 let admin = env.admin().await;
590 let id = user_id(&admin, "admin").await;
591 give_passkey(&env, id, b"cred-first").await;
592 admin
593 .put_json("/api/auth/mode", &json!({ "mode": "both" }))
594 .await;
595 let c = Client::new(env.app.clone());
596
597 // Stand in for a passkey that already verified. This is the only branch
598 // that hands out a session without a passkey ceremony in the request, so
599 // it gets checked directly.
600 let handle = server::webauthn::put(server::webauthn::Pending::NeedsPassword { user_id: id });
601 let r = c
602 .post_json(
603 "/api/auth/login",
604 &json!({ "state_id": handle, "password": "wrong-one-1" }),
605 )
606 .await;
607 assert_eq!(r.status, StatusCode::UNAUTHORIZED, "{}", r.text());
608 assert!(session_cookie(&r).is_none(), "a wrong password signed in");
609
610 // The handle went with that attempt, so it cannot be tried again. That is
611 // what stops one verified passkey from becoming unlimited password tries.
612 let r = c
613 .post_json(
614 "/api/auth/login",
615 &json!({ "state_id": handle, "password": "admin1234" }),
616 )
617 .await;
618 assert_eq!(r.status, StatusCode::BAD_REQUEST, "{}", r.text());
619 assert_eq!(r.json()["code"], "err_challenge_expired");
620 assert!(session_cookie(&r).is_none());
621
622 // A fresh handle and the right password: now it is a session.
623 let handle = server::webauthn::put(server::webauthn::Pending::NeedsPassword { user_id: id });
624 let r = c
625 .post_json(
626 "/api/auth/login",
627 &json!({ "state_id": handle, "password": "admin1234" }),
628 )
629 .await;
630 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
631 assert!(
632 session_cookie(&r).is_some(),
633 "the right password got nothing"
634 );
635}
636
637#[tokio::test]
638async fn an_invented_login_handle_is_refused() {
639 let env = Env::new().await;
640 let admin = env.admin().await;
641 let c = Client::new(env.app.clone());
642 let r = c
643 .post_json(
644 "/api/auth/login",
645 &json!({ "state_id": "never-issued", "password": "admin1234" }),
646 )
647 .await;
648 assert_eq!(r.status, StatusCode::BAD_REQUEST, "{}", r.text());
649 assert!(session_cookie(&r).is_none());
650
651 // A registration handle names a user too, but it is not a passed factor.
652 let begin = admin
653 .post_json("/api/auth/passkeys/register", &json!({}))
654 .await;
655 let handle = begin.json()["state_id"].as_str().unwrap().to_string();
656 let r = c
657 .post_json(
658 "/api/auth/login",
659 &json!({ "state_id": handle, "password": "admin1234" }),
660 )
661 .await;
662 assert_eq!(r.status, StatusCode::BAD_REQUEST, "{}", r.text());
663 assert!(session_cookie(&r).is_none());
664}
665
666#[tokio::test]
667async fn signing_in_works_on_a_host_passkeys_cannot_use() {
668 let env = Env::new().await;
669 let _ = env.admin().await;
670 let c = Client::new(env.app.clone());
671 // WebAuthn needs a registrable domain, and a bare IP is not one. That must
672 // cost passkeys only, never the password sign-in every deployment uses.
673 // `--bind 127.0.0.1` is the default, so this is the normal case.
674 let r = c
675 .raw(
676 Method::POST,
677 "/api/auth/login",
678 &[
679 ("content-type", "application/json"),
680 ("host", "192.168.1.10:8080"),
681 ],
682 json!({ "name": "admin", "password": "admin1234" })
683 .to_string()
684 .into_bytes(),
685 )
686 .await;
687 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
688 assert!(session_cookie(&r).is_some());
689}
690
691// ---------------------------------------------------------------------------
692// Admin recovery
693// ---------------------------------------------------------------------------
694
695#[tokio::test]
696async fn an_admin_password_clears_the_passkeys_and_the_requirement() {
697 let env = Env::new().await;
698 let admin = env.admin().await;
699 create_user(&admin, "bob", "bobpass12", &[("docs", "rw")]).await;
700 let bob_id = user_id(&admin, "bob").await;
701 give_passkey(&env, bob_id, b"cred-locked").await;
702 env.state
703 .db
704 .set_user_auth_mode(bob_id, api_types::AuthMode::Both)
705 .await
706 .unwrap();
707
708 // An edit that sets no password leaves bob's credentials alone.
709 let r = admin
710 .put_json(
711 &format!("/api/admin/users/{bob_id}"),
712 &json!({ "active": true }),
713 )
714 .await;
715 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
716 assert_eq!(env.state.db.count_passkeys(bob_id).await.unwrap(), 1);
717
718 // Setting one is the recovery path, and it is the whole recovery: the
719 // admin cannot hand over a password and leave a second factor bob no
720 // longer has.
721 let r = admin
722 .put_json(
723 &format!("/api/admin/users/{bob_id}"),
724 &json!({ "password": "rescued12" }),
725 )
726 .await;
727 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
728
729 // Bob is back on a plain password sign-in, with no passkeys left.
730 let bob = login(&env, "bob", "rescued12").await;
731 let me = bob.get("/api/auth/me").await.json();
732 assert_eq!(me["user"]["auth_mode"], "either");
733 assert_eq!(me["user"]["has_password"], true);
734 assert_eq!(
735 bob.get("/api/auth/passkeys").await.json(),
736 json!([]),
737 "the passkeys survived the reset"
738 );
739}
740
741#[tokio::test]
742async fn deleting_an_account_takes_its_passkeys_with_it() {
743 let env = Env::new().await;
744 let admin = env.admin().await;
745 create_user(&admin, "bob", "bobpass12", &[("docs", "rw")]).await;
746 let bob_id = user_id(&admin, "bob").await;
747 give_passkey(&env, bob_id, b"cred-gone").await;
748
749 let r = admin.delete(&format!("/api/admin/users/{bob_id}")).await;
750 assert_eq!(r.status, StatusCode::OK);
751 assert_eq!(env.state.db.count_passkeys(bob_id).await.unwrap(), 0);
752}
753