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