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