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