passkeys.rs
⎇
Raw
1//! Self-service credentials: the password, passkeys, and which of the two an
2//! account needs to sign in.
3//!
4//! Every route here except the two `login_*` ones needs a session. The two
5//! that do not are the passkey half of signing in, which by definition runs
6//! before there is one.
7
8use std::sync::Arc;
9
10use api_types::{
11 AuthMode, ChangePassword, LoginResp, OkResp, PasskeyChallenge, PasskeyInfo, PasskeyLoginBegin,
12 PasskeyLoginFinish, PasskeyRegisterFinish, PasswordStep, SetAuthMode,
13};
14use axum::Json;
15use axum::extract::{Path as AxumPath, State};
16use axum::http::{HeaderMap, StatusCode, header};
17use axum::response::{IntoResponse, Response};
18use webauthn_rs::prelude::*;
19use webauthn_rs_proto::{AllowCredentials, ResidentKeyRequirement};
20
21use crate::api::common::{SessionUser, hash_password, validate_password};
22use crate::auth::{self, parse_session_cookie, session_cookie};
23use crate::db::{PASSKEY_LIMIT, PasskeyDeleted, PasskeyRow};
24use crate::error::{ApiError, AppState};
25use crate::webauthn::{Pending, Rp};
26
27// ---------------------------------------------------------------------------
28// Errors
29// ---------------------------------------------------------------------------
30
31fn challenge_expired() -> ApiError {
32 ApiError::localized(
33 StatusCode::BAD_REQUEST,
34 "that took too long, please try again",
35 "err_challenge_expired",
36 )
37}
38
39/// One message for every way a WebAuthn ceremony can fail.
40///
41/// The detail goes to the log, never to the client: a bad signature, a
42/// mismatched origin and an unknown credential are all "it did not work" to
43/// the person at the keyboard, and telling them apart only helps an attacker.
44fn webauthn_failed(e: WebauthnError) -> ApiError {
45 tracing::warn!(error = ?e, "webauthn ceremony failed");
46 ApiError::localized(
47 StatusCode::UNAUTHORIZED,
48 "that passkey could not be used",
49 "err_passkey_failed",
50 )
51}
52
53/// The database refused a change that would have left the account with no way
54/// to sign in.
55///
56/// Each handler checks its own rule first and says which one, so this is only
57/// reached when two changes race: a count taken before the write was already
58/// stale. Rare enough that one message covers it.
59fn locked_out() -> ApiError {
60 ApiError::localized(
61 StatusCode::BAD_REQUEST,
62 "that would leave the account with no way to sign in",
63 "err_locked_out",
64 )
65}
66
67fn too_many_passkeys() -> ApiError {
68 ApiError::localized(
69 StatusCode::BAD_REQUEST,
70 "this account already holds as many passkeys as it may",
71 "err_passkey_limit",
72 )
73}
74
75fn bad_credential() -> ApiError {
76 ApiError::localized(
77 StatusCode::BAD_REQUEST,
78 "the browser sent an unreadable credential",
79 "err_passkey_malformed",
80 )
81}
82
83// ---------------------------------------------------------------------------
84// Shared checks
85// ---------------------------------------------------------------------------
86
87/// Apply the fallout of any credential change: every other session of this
88/// user is dropped, and cached WebDAV credentials are forgotten.
89///
90/// This carries more weight than it looks. Nothing on these routes asks for
91/// the current password — a passkey-only account has none — so the session is
92/// the only thing standing behind a credential change. Dropping the others
93/// keeps a session stolen before the change from outliving it.
94async fn invalidate_elsewhere(
95 state: &AppState,
96 user_id: i64,
97 headers: &HeaderMap,
98) -> Result<(), ApiError> {
99 let current = parse_session_cookie(headers).unwrap_or_default();
100 state.db.delete_other_sessions(user_id, &current).await?;
101 auth::forget_verified_for(user_id);
102 Ok(())
103}
104
105fn passkey_name(raw: &str) -> String {
106 let trimmed = raw.trim();
107 if trimmed.is_empty() {
108 return "Passkey".to_string();
109 }
110 trimmed.chars().take(64).collect()
111}
112
113fn info(row: &PasskeyRow) -> PasskeyInfo {
114 PasskeyInfo {
115 id: row.id,
116 name: row.name.clone(),
117 created_at: row.created_at.clone(),
118 last_used_at: row.last_used_at.clone(),
119 discoverable: row.discoverable,
120 }
121}
122
123/// The stored credentials of one account, ready for `webauthn-rs`.
124///
125/// A row that will not deserialize is skipped rather than fatal. It can only
126/// come from a `webauthn-rs` format change, and one unreadable passkey must
127/// not lock an account out of the others.
128pub(crate) async fn load_passkeys(
129 state: &AppState,
130 user_id: i64,
131) -> Result<Vec<(i64, Passkey)>, ApiError> {
132 Ok(state
133 .db
134 .user_passkeys(user_id)
135 .await?
136 .into_iter()
137 .filter_map(|r| match serde_json::from_str::<Passkey>(&r.passkey) {
138 Ok(k) => Some((r.id, k)),
139 Err(e) => {
140 tracing::error!(passkey_id = r.id, error = %e, "stored passkey is unreadable");
141 None
142 }
143 })
144 .collect())
145}
146
147// ---------------------------------------------------------------------------
148// Password
149// ---------------------------------------------------------------------------
150
151/// POST `{AUTH_PASSWORD}` — set or change the password.
152pub async fn change_password(
153 State(state): State<Arc<AppState>>,
154 SessionUser { user, .. }: SessionUser,
155 headers: HeaderMap,
156 Json(body): Json<ChangePassword>,
157) -> Result<Json<OkResp>, ApiError> {
158 validate_password(&body.new_password)?;
159 let hash = hash_password(&body.new_password).await?;
160 state
161 .db
162 .set_password_keeping_sessions(user.id, &hash)
163 .await?;
164 invalidate_elsewhere(&state, user.id, &headers).await?;
165 Ok(Json(OkResp {}))
166}
167
168/// DELETE `{AUTH_PASSWORD}` — leave the account on passkeys alone.
169pub async fn delete_password(
170 State(state): State<Arc<AppState>>,
171 SessionUser { user, .. }: SessionUser,
172 headers: HeaderMap,
173) -> Result<Json<OkResp>, ApiError> {
174 if !user.has_password {
175 return Ok(Json(OkResp {}));
176 }
177 // The account must keep at least one way in, and `Both` needs a password
178 // by definition.
179 if state.db.count_passkeys(user.id).await? == 0 {
180 return Err(ApiError::localized(
181 StatusCode::BAD_REQUEST,
182 "add a passkey before removing your password",
183 "err_password_last_credential",
184 ));
185 }
186 if user.auth_mode == AuthMode::Both {
187 return Err(ApiError::localized(
188 StatusCode::BAD_REQUEST,
189 "this account requires a password and a passkey",
190 "err_required_by_mode",
191 ));
192 }
193 if !state.db.clear_user_password(user.id).await? {
194 return Err(locked_out());
195 }
196 invalidate_elsewhere(&state, user.id, &headers).await?;
197 Ok(Json(OkResp {}))
198}
199
200// ---------------------------------------------------------------------------
201// Sign-in requirement
202// ---------------------------------------------------------------------------
203
204/// PUT `{AUTH_MODE}`.
205pub async fn set_mode(
206 State(state): State<Arc<AppState>>,
207 SessionUser { user, .. }: SessionUser,
208 headers: HeaderMap,
209 Json(body): Json<SetAuthMode>,
210) -> Result<Json<OkResp>, ApiError> {
211 if body.mode == AuthMode::Both {
212 if !user.has_password {
213 return Err(ApiError::localized(
214 StatusCode::BAD_REQUEST,
215 "set a password before requiring both",
216 "err_mode_needs_password",
217 ));
218 }
219 if state.db.count_passkeys(user.id).await? == 0 {
220 return Err(ApiError::localized(
221 StatusCode::BAD_REQUEST,
222 "add a passkey before requiring both",
223 "err_mode_needs_passkey",
224 ));
225 }
226 }
227 if !state.db.set_user_auth_mode(user.id, body.mode).await? {
228 return Err(locked_out());
229 }
230 // WebDAV speaks HTTP Basic, which carries a password and nothing else. An
231 // account that requires both can no longer authenticate a mount, so any
232 // cached Basic credential has to go.
233 invalidate_elsewhere(&state, user.id, &headers).await?;
234 Ok(Json(OkResp {}))
235}
236
237// ---------------------------------------------------------------------------
238// Managing passkeys
239// ---------------------------------------------------------------------------
240
241/// GET `{AUTH_PASSKEYS}`.
242pub async fn list(
243 State(state): State<Arc<AppState>>,
244 SessionUser { user, .. }: SessionUser,
245) -> Result<Json<Vec<PasskeyInfo>>, ApiError> {
246 let rows = state.db.user_passkeys(user.id).await?;
247 Ok(Json(rows.iter().map(info).collect()))
248}
249
250/// DELETE `{AUTH_PASSKEYS}/{id}`.
251pub async fn delete(
252 State(state): State<Arc<AppState>>,
253 SessionUser { user, .. }: SessionUser,
254 headers: HeaderMap,
255 AxumPath(id): AxumPath<i64>,
256) -> Result<Json<OkResp>, ApiError> {
257 // The database decides, inside one transaction, whether the account would
258 // still have a way in. Asking it first means an id that does not exist is
259 // a plain 404, not a complaint about a rule it never reached.
260 match state.db.delete_passkey(id, user.id).await? {
261 PasskeyDeleted::Gone => {}
262 PasskeyDeleted::NotFound => {
263 return Err(ApiError::localized(
264 StatusCode::NOT_FOUND,
265 "no such passkey",
266 "err_passkey_not_found",
267 ));
268 }
269 // Say which of the two rules stopped it.
270 PasskeyDeleted::LastCredential if user.auth_mode == AuthMode::Both => {
271 return Err(ApiError::localized(
272 StatusCode::BAD_REQUEST,
273 "this account requires a password and a passkey",
274 "err_required_by_mode",
275 ));
276 }
277 PasskeyDeleted::LastCredential => {
278 return Err(ApiError::localized(
279 StatusCode::BAD_REQUEST,
280 "set a password before removing your last passkey",
281 "err_passkey_last_credential",
282 ));
283 }
284 }
285 invalidate_elsewhere(&state, user.id, &headers).await?;
286 Ok(Json(OkResp {}))
287}
288
289/// POST `{AUTH_PASSKEYS_REGISTER}` — first leg of registration.
290pub async fn register_begin(
291 State(state): State<Arc<AppState>>,
292 SessionUser { user, .. }: SessionUser,
293 Rp(rp): Rp,
294) -> Result<Json<PasskeyChallenge>, ApiError> {
295 // Checked again inside `add_passkey`, which is where it actually holds.
296 // This one only spares the user a ceremony that could not be stored.
297 if state.db.count_passkeys(user.id).await? as usize >= PASSKEY_LIMIT {
298 return Err(too_many_passkeys());
299 }
300 let wid = state.db.user_webauthn_id(user.id).await?;
301 // Excluding what is already registered makes the authenticator refuse a
302 // second credential for this account, instead of silently creating one
303 // the user then has to tell apart from the first.
304 let existing: Vec<CredentialID> = load_passkeys(&state, user.id)
305 .await?
306 .iter()
307 .map(|(_, k)| k.cred_id().clone())
308 .collect();
309 let (mut options, reg) = rp
310 .start_passkey_registration(wid, &user.name, &user.name, Some(existing))
311 .map_err(webauthn_failed)?;
312 ask_for_discoverable(&mut options);
313 Ok(Json(challenge(
314 Pending::Register {
315 user_id: user.id,
316 state: Box::new(reg),
317 },
318 &options,
319 )?))
320}
321
322/// POST `{AUTH_PASSKEYS_REGISTER}{FINISH_SUFFIX}`.
323pub async fn register_finish(
324 State(state): State<Arc<AppState>>,
325 SessionUser { user, .. }: SessionUser,
326 Rp(rp): Rp,
327 headers: HeaderMap,
328 Json(body): Json<PasskeyRegisterFinish>,
329) -> Result<Json<PasskeyInfo>, ApiError> {
330 let Some(Pending::Register {
331 user_id,
332 state: reg,
333 }) = crate::webauthn::take(&body.state_id)
334 else {
335 return Err(challenge_expired());
336 };
337 // The handle is opaque and single-use, so this can only be a client that
338 // mixed two ceremonies up. Refuse rather than register to the wrong
339 // account.
340 if user_id != user.id {
341 return Err(challenge_expired());
342 }
343 let cred: RegisterPublicKeyCredential =
344 serde_json::from_str(&body.credential).map_err(|_| bad_credential())?;
345 // Whether the browser thinks it stored a discoverable credential. Unsigned
346 // and optional, so it is a UI hint only — never a security decision.
347 let discoverable = cred.extensions.cred_props.as_ref().and_then(|c| c.rk);
348 let passkey = rp
349 .finish_passkey_registration(&cred, &reg)
350 .map_err(webauthn_failed)?;
351 let encoded = serde_json::to_string(&passkey).map_err(|e| {
352 ApiError::new(
353 StatusCode::INTERNAL_SERVER_ERROR,
354 format!("cannot store passkey: {e}"),
355 )
356 })?;
357 let Some(row) = state
358 .db
359 .add_passkey(
360 user.id,
361 passkey.cred_id().as_ref(),
362 &encoded,
363 &passkey_name(&body.name),
364 discoverable,
365 )
366 .await
367 .map_err(|e| match e {
368 // `passkeys.cred_id` is UNIQUE across the whole table, so this
369 // also fires when the credential belongs to another account.
370 rusqlite::Error::SqliteFailure(f, _)
371 if f.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE =>
372 {
373 ApiError::localized(
374 StatusCode::CONFLICT,
375 "that passkey is already registered",
376 "err_passkey_duplicate",
377 )
378 }
379 other => other.into(),
380 })?
381 else {
382 return Err(too_many_passkeys());
383 };
384 invalidate_elsewhere(&state, user.id, &headers).await?;
385 Ok(Json(info(&row)))
386}
387
388// ---------------------------------------------------------------------------
389// Signing in with a passkey
390// ---------------------------------------------------------------------------
391
392/// Key material for one decoy, in counter mode so any id length is reachable.
393///
394/// `tag` separates the two things derived per decoy, its length and its bytes,
395/// so neither can be read off the other.
396fn decoy_bytes(secret: &str, name: &str, index: u32, tag: u8, len: usize) -> Vec<u8> {
397 use sha2::{Digest, Sha256};
398 let mut out = Vec::with_capacity(len + 32);
399 let mut block = 0u32;
400 while out.len() < len {
401 let mut h = Sha256::new();
402 h.update(secret.as_bytes());
403 h.update([tag]);
404 // Length-prefixed, so two names cannot run together into one input.
405 h.update((name.len() as u64).to_le_bytes());
406 h.update(name.as_bytes());
407 h.update(index.to_le_bytes());
408 h.update(block.to_le_bytes());
409 out.extend_from_slice(&h.finalize());
410 block += 1;
411 }
412 out.truncate(len);
413 out
414}
415
416/// One fake `allowCredentials` entry, stable across requests.
417///
418/// A real account lists the same credential ids every time. A decoy derived
419/// from a per-install secret does too, so probing one name twice gives an
420/// attacker nothing to compare.
421///
422/// The name is ASCII-folded first, because `users.name` is `COLLATE NOCASE`.
423/// Without that, "admin" and "ADMIN" would return the same real credential
424/// with different decoys around it, and comparing the two spellings would say
425/// which entries were real.
426fn decoy(secret: &str, name: &str, index: u32, lengths: &[usize]) -> AllowCredentials {
427 let name = &name.to_ascii_lowercase();
428 let pick = decoy_bytes(secret, name, index, 1, 1);
429 let len = lengths[usize::from(pick[0]) % lengths.len()];
430 AllowCredentials {
431 type_: "public-key".to_string(),
432 id: decoy_bytes(secret, name, index, 0, len).into(),
433 transports: None,
434 }
435}
436
437/// POST `{AUTH_PASSKEY_LOGIN}` — first leg of a passkey sign-in.
438///
439/// An empty name gets a discoverable challenge, which any passkey the browser
440/// holds for this site can answer. A name gets a challenge listing credentials,
441/// which is the only form a non-discoverable credential can answer.
442///
443/// This route needs no session, so a named challenge must not say whether the
444/// name exists. It does not: an unknown name gets a list of decoys, and the
445/// two starters' other differences are flattened below. The account behind a
446/// real name still decides nothing here, because the assertion has to verify
447/// before anyone is signed in.
448pub async fn login_begin(
449 State(state): State<Arc<AppState>>,
450 Rp(rp): Rp,
451 Json(body): Json<PasskeyLoginBegin>,
452) -> Result<Json<PasskeyChallenge>, ApiError> {
453 // Autofill carries no name and its challenge is the same for everyone, so
454 // it needs none of the padding below.
455 let name = match body
456 .name
457 .as_deref()
458 .map(str::trim)
459 .filter(|n| !n.is_empty())
460 {
461 Some(name) if !body.conditional => name,
462 _ => {
463 let (mut options, disc) = rp
464 .start_discoverable_authentication()
465 .map_err(webauthn_failed)?;
466 // `start_discoverable_authentication` always asks for conditional
467 // mediation, which parks the request in the autofill dropdown. The
468 // button wants the modal picker instead.
469 if !body.conditional {
470 options.mediation = None;
471 }
472 return Ok(Json(challenge(
473 Pending::Discoverable {
474 state: Box::new(disc),
475 decoy: false,
476 },
477 &options,
478 )?));
479 }
480 };
481
482 let found = match state.db.find_user_by_name(name).await?.filter(|u| u.active) {
483 Some(u) => {
484 let keys: Vec<Passkey> = load_passkeys(&state, u.id)
485 .await?
486 .into_iter()
487 .map(|(_, k)| k)
488 .collect();
489 (!keys.is_empty()).then_some((u.id, keys))
490 }
491 None => None,
492 };
493 // An unknown name still gets a working ceremony, not a fake one: a passkey
494 // the browser holds for this site can answer it. Only the credential list
495 // is invented.
496 let (mut options, pending) = match found {
497 Some((user_id, keys)) => {
498 let (options, auth) = rp
499 .start_passkey_authentication(&keys)
500 .map_err(webauthn_failed)?;
501 (
502 options,
503 Pending::Authenticate {
504 user_id,
505 state: Box::new(auth),
506 second_factor: false,
507 },
508 )
509 }
510 None => {
511 let (options, disc) = rp
512 .start_discoverable_authentication()
513 .map_err(webauthn_failed)?;
514 (
515 options,
516 Pending::Discoverable {
517 state: Box::new(disc),
518 decoy: true,
519 },
520 )
521 }
522 };
523
524 // The two starters disagree on more than the credential list.
525 // `start_discoverable_authentication` asks for the `uvm` extension and
526 // conditional mediation; `start_passkey_authentication` asks for neither.
527 // Left alone those two fields would answer the question the decoys are
528 // here to hide. Neither is checked when the assertion comes back, so
529 // clearing them costs nothing.
530 options.public_key.extensions = None;
531 options.mediation = None;
532 // Transports vary per authenticator and a decoy has none to copy, so they
533 // come off the real entries too. They are a hint to the browser about
534 // where to look, never a requirement.
535 for cred in &mut options.public_key.allow_credentials {
536 cred.transports = None;
537 }
538 let secret = state.db.decoy_secret().await?;
539 let mut lengths = state.db.cred_id_lengths().await?;
540 if lengths.is_empty() {
541 lengths.push(32);
542 }
543 // Always exactly `PASSKEY_LIMIT` entries. No account may hold more, so the
544 // list never has to grow past the padding and its length says nothing.
545 for index in options.public_key.allow_credentials.len()..PASSKEY_LIMIT {
546 options
547 .public_key
548 .allow_credentials
549 .push(decoy(&secret, name, index as u32, &lengths));
550 }
551
552 Ok(Json(challenge(pending, &options)?))
553}
554
555/// POST `{AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}`.
556///
557/// Either signs the user in, or — for an account that needs both factors and
558/// started with the passkey — asks for the password next.
559pub async fn login_finish(
560 State(state): State<Arc<AppState>>,
561 Rp(rp): Rp,
562 Json(body): Json<PasskeyLoginFinish>,
563) -> Result<Response, ApiError> {
564 let Some(pending) = crate::webauthn::take(&body.state_id) else {
565 return Err(challenge_expired());
566 };
567 let cred: PublicKeyCredential =
568 serde_json::from_str(&body.credential).map_err(|_| bad_credential())?;
569
570 let (user_id, second_factor, result) = match pending {
571 Pending::Authenticate {
572 user_id,
573 state: auth_state,
574 second_factor,
575 } => {
576 let res = rp
577 .finish_passkey_authentication(&cred, &auth_state)
578 .map_err(webauthn_failed)?;
579 (user_id, second_factor, res)
580 }
581 Pending::Discoverable { state: disc, decoy } => {
582 // The user handle comes from the credential, so it is only a
583 // claim until `finish_discoverable_authentication` checks the
584 // signature against that account's own keys below.
585 let (wid, _) = rp
586 .identify_discoverable_authentication(&cred)
587 .map_err(webauthn_failed)?;
588 let user = state
589 .db
590 .find_user_by_webauthn_id(&wid)
591 .await?
592 .filter(|u| u.active)
593 .ok_or_else(|| webauthn_failed(WebauthnError::CredentialNotFound))?;
594 let keys: Vec<DiscoverableKey> = load_passkeys(&state, user.id)
595 .await?
596 .iter()
597 .map(|(_, k)| k.into())
598 .collect();
599 let res = rp
600 .finish_discoverable_authentication(&cred, *disc, &keys)
601 .map_err(webauthn_failed)?;
602 // The name this challenge was issued for does not exist. The
603 // ceremony was real so that it could not be told apart from a
604 // real one, and it is verified before being refused for the same
605 // reason. Signing this passkey's owner in instead would answer
606 // the question the whole padding is there to swallow.
607 if decoy {
608 return Err(webauthn_failed(WebauthnError::CredentialNotFound));
609 }
610 (user.id, false, res)
611 }
612 // Any other handle names a different ceremony. Refusing keeps a
613 // registration challenge from being answered as a sign-in.
614 _ => return Err(challenge_expired()),
615 };
616
617 record_use(&state, user_id, &result).await?;
618
619 let user = state
620 .db
621 .find_user_by_id(user_id)
622 .await?
623 .filter(|u| u.active)
624 .ok_or_else(|| webauthn_failed(WebauthnError::CredentialNotFound))?;
625 if user.auth_mode == AuthMode::Both && !second_factor {
626 let state_id = crate::webauthn::put(Pending::NeedsPassword { user_id });
627 return Ok(Json(LoginResp {
628 ok: false,
629 password_required: Some(PasswordStep {
630 name: user.name,
631 state_id,
632 }),
633 ..Default::default()
634 })
635 .into_response());
636 }
637 sign_in(&state, user_id).await
638}
639
640/// Persist what the assertion changed: the signature counter and backup
641/// flags move, and the settings list shows when a passkey was last used.
642async fn record_use(
643 state: &AppState,
644 user_id: i64,
645 result: &AuthenticationResult,
646) -> Result<(), ApiError> {
647 let Some((id, mut key)) = load_passkeys(state, user_id)
648 .await?
649 .into_iter()
650 .find(|(_, k)| k.cred_id() == result.cred_id())
651 else {
652 return Ok(());
653 };
654 key.update_credential(result);
655 let encoded = serde_json::to_string(&key).unwrap_or_default();
656 if !encoded.is_empty() {
657 state.db.passkey_used(id, &encoded).await?;
658 }
659 Ok(())
660}
661
662/// Create the session and send its cookie.
663pub(crate) async fn sign_in(state: &AppState, user_id: i64) -> Result<Response, ApiError> {
664 let token = auth::random_token();
665 state.db.create_session(user_id, &token).await?;
666 let mut res = Json(LoginResp {
667 ok: true,
668 ..Default::default()
669 })
670 .into_response();
671 res.headers_mut().insert(
672 header::SET_COOKIE,
673 session_cookie(&token, state.https).parse().unwrap(),
674 );
675 Ok(res)
676}
677
678/// Ask the authenticator to store the credential itself.
679///
680/// `start_passkey_registration` sends `residentKey: "discouraged"`, which
681/// tells a password manager *not* to make a discoverable passkey — and they
682/// obey it, so every credential would then need the account name typed in to
683/// be found again. There is no builder switch for this on the passkey API,
684/// hence the patch.
685///
686/// Only the request changes, not what is accepted: an authenticator with no
687/// room for a resident key still registers, and the `credProps` extension
688/// reports what actually happened. Enforcing it would lock out the older
689/// security keys this server deliberately still supports.
690fn ask_for_discoverable(options: &mut CreationChallengeResponse) {
691 match options.public_key.authenticator_selection.as_mut() {
692 Some(sel) => {
693 sel.resident_key = Some(ResidentKeyRequirement::Required);
694 // `require_resident_key` is the CTAP1-era boolean, consulted only
695 // when `residentKey` is absent. Some older keys fail outright on
696 // it, so it stays false.
697 }
698 // `webauthn-rs` always sends this block today. If a future version
699 // stops, every new passkey silently goes back to needing a typed name.
700 None => tracing::warn!("no authenticatorSelection to ask for a discoverable credential"),
701 }
702}
703
704/// Park a ceremony's state and pair its handle with the browser's options.
705pub(crate) fn challenge<T: serde::Serialize>(
706 pending: Pending,
707 options: &T,
708) -> Result<PasskeyChallenge, ApiError> {
709 let options = serde_json::to_string(options).map_err(|e| {
710 ApiError::new(
711 StatusCode::INTERNAL_SERVER_ERROR,
712 format!("cannot encode the challenge: {e}"),
713 )
714 })?;
715 Ok(PasskeyChallenge {
716 state_id: crate::webauthn::put(pending),
717 options,
718 })
719}
720