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