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