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::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 }
112}
113
114/// The stored credentials of one account, ready for `webauthn-rs`.
115///
116/// A row that will not deserialize is skipped rather than fatal. It can only
117/// come from a `webauthn-rs` format change, and one unreadable passkey must
118/// not lock an account out of the others.
119pub(crate) async fn load_passkeys(
120 state: &AppState,
121 user_id: i64,
122) -> Result<Vec<(i64, Passkey)>, ApiError> {
123 Ok(state
124 .db
125 .user_passkeys(user_id)
126 .await?
127 .into_iter()
128 .filter_map(|r| match serde_json::from_str::<Passkey>(&r.passkey) {
129 Ok(k) => Some((r.id, k)),
130 Err(e) => {
131 tracing::error!(passkey_id = r.id, error = %e, "stored passkey is unreadable");
132 None
133 }
134 })
135 .collect())
136}
137
138// ---------------------------------------------------------------------------
139// Password
140// ---------------------------------------------------------------------------
141
142/// POST `{AUTH_PASSWORD}` — set or change the password.
143pub async fn change_password(
144 State(state): State<Arc<AppState>>,
145 SessionUser { user, .. }: SessionUser,
146 headers: HeaderMap,
147 Json(body): Json<ChangePassword>,
148) -> Result<Json<OkResp>, ApiError> {
149 validate_password(&body.new_password)?;
150 let hash = hash_password(&body.new_password).await?;
151 state
152 .db
153 .set_password_keeping_sessions(user.id, &hash)
154 .await?;
155 invalidate_elsewhere(&state, user.id, &headers).await?;
156 Ok(Json(OkResp {}))
157}
158
159/// DELETE `{AUTH_PASSWORD}` — leave the account on passkeys alone.
160pub async fn delete_password(
161 State(state): State<Arc<AppState>>,
162 SessionUser { user, .. }: SessionUser,
163 headers: HeaderMap,
164) -> Result<Json<OkResp>, ApiError> {
165 if !user.has_password {
166 return Ok(Json(OkResp {}));
167 }
168 // The account must keep at least one way in, and `Both` needs a password
169 // by definition.
170 if state.db.count_passkeys(user.id).await? == 0 {
171 return Err(ApiError::localized(
172 StatusCode::BAD_REQUEST,
173 "add a passkey before removing your password",
174 "err_password_last_credential",
175 ));
176 }
177 if user.auth_mode == AuthMode::Both {
178 return Err(ApiError::localized(
179 StatusCode::BAD_REQUEST,
180 "this account requires a password and a passkey",
181 "err_required_by_mode",
182 ));
183 }
184 if !state.db.clear_user_password(user.id).await? {
185 return Err(locked_out());
186 }
187 invalidate_elsewhere(&state, user.id, &headers).await?;
188 Ok(Json(OkResp {}))
189}
190
191// ---------------------------------------------------------------------------
192// Sign-in requirement
193// ---------------------------------------------------------------------------
194
195/// PUT `{AUTH_MODE}`.
196pub async fn set_mode(
197 State(state): State<Arc<AppState>>,
198 SessionUser { user, .. }: SessionUser,
199 headers: HeaderMap,
200 Json(body): Json<SetAuthMode>,
201) -> Result<Json<OkResp>, ApiError> {
202 if body.mode == AuthMode::Both {
203 if !user.has_password {
204 return Err(ApiError::localized(
205 StatusCode::BAD_REQUEST,
206 "set a password before requiring both",
207 "err_mode_needs_password",
208 ));
209 }
210 if state.db.count_passkeys(user.id).await? == 0 {
211 return Err(ApiError::localized(
212 StatusCode::BAD_REQUEST,
213 "add a passkey before requiring both",
214 "err_mode_needs_passkey",
215 ));
216 }
217 }
218 if !state.db.set_user_auth_mode(user.id, body.mode).await? {
219 return Err(locked_out());
220 }
221 // WebDAV speaks HTTP Basic, which carries a password and nothing else. An
222 // account that requires both can no longer mount with its account
223 // password, so any cached Basic credential has to go. Its app passwords
224 // are unaffected and keep working.
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 require_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 let passkey = rp
338 .finish_passkey_registration(&cred, &reg)
339 .map_err(webauthn_failed)?;
340 let encoded = serde_json::to_string(&passkey).map_err(|e| {
341 ApiError::new(
342 StatusCode::INTERNAL_SERVER_ERROR,
343 format!("cannot store passkey: {e}"),
344 )
345 })?;
346 let Some(row) = state
347 .db
348 .add_passkey(
349 user.id,
350 passkey.cred_id().as_ref(),
351 &encoded,
352 &credential_label(&body.name, "Passkey"),
353 )
354 .await
355 .map_err(|e| match e {
356 // `passkeys.cred_id` is UNIQUE across the whole table, so this
357 // also fires when the credential belongs to another account.
358 rusqlite::Error::SqliteFailure(f, _)
359 if f.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE =>
360 {
361 ApiError::localized(
362 StatusCode::CONFLICT,
363 "that passkey is already registered",
364 "err_passkey_duplicate",
365 )
366 }
367 other => other.into(),
368 })?
369 else {
370 return Err(too_many_passkeys());
371 };
372 invalidate_elsewhere(&state, user.id, &headers).await?;
373 Ok(Json(info(&row)))
374}
375
376// ---------------------------------------------------------------------------
377// Signing in with a passkey
378// ---------------------------------------------------------------------------
379
380/// POST `{AUTH_PASSKEY_LOGIN}` — first leg of a passkey sign-in.
381///
382/// The challenge lists no credentials, so any passkey the browser holds for
383/// this site can answer it, and it says nothing about which accounts exist.
384pub async fn login_begin(
385 Rp(rp): Rp,
386 Json(body): Json<PasskeyLoginBegin>,
387) -> Result<Json<PasskeyChallenge>, ApiError> {
388 let (mut options, disc) = rp
389 .start_discoverable_authentication()
390 .map_err(webauthn_failed)?;
391 // `start_discoverable_authentication` always asks for conditional
392 // mediation, which parks the request in the autofill dropdown. The button
393 // wants the modal picker instead.
394 if !body.conditional {
395 options.mediation = None;
396 }
397 Ok(Json(challenge(
398 Pending::Discoverable(Box::new(disc)),
399 &options,
400 )?))
401}
402
403/// POST `{AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}`.
404///
405/// Either signs the user in, or — for an account that needs both factors and
406/// started with the passkey — asks for the password next.
407pub async fn login_finish(
408 State(state): State<Arc<AppState>>,
409 Rp(rp): Rp,
410 Json(body): Json<PasskeyLoginFinish>,
411) -> Result<Response, ApiError> {
412 let Some(pending) = crate::webauthn::take(&body.state_id) else {
413 return Err(challenge_expired());
414 };
415 let cred: PublicKeyCredential =
416 serde_json::from_str(&body.credential).map_err(|_| bad_credential())?;
417
418 let (user_id, second_factor, result) = match pending {
419 Pending::SecondFactor {
420 user_id,
421 state: auth_state,
422 } => {
423 let res = rp
424 .finish_passkey_authentication(&cred, &auth_state)
425 .map_err(webauthn_failed)?;
426 (user_id, true, res)
427 }
428 Pending::Discoverable(disc) => {
429 // The user handle comes from the credential, so it is only a
430 // claim until `finish_discoverable_authentication` checks the
431 // signature against that account's own keys below.
432 let (wid, _) = rp
433 .identify_discoverable_authentication(&cred)
434 .map_err(webauthn_failed)?;
435 let user = state
436 .db
437 .find_user_by_webauthn_id(&wid)
438 .await?
439 .filter(|u| u.active)
440 .ok_or_else(|| webauthn_failed(WebauthnError::CredentialNotFound))?;
441 let keys: Vec<DiscoverableKey> = load_passkeys(&state, user.id)
442 .await?
443 .iter()
444 .map(|(_, k)| k.into())
445 .collect();
446 let res = rp
447 .finish_discoverable_authentication(&cred, *disc, &keys)
448 .map_err(webauthn_failed)?;
449 (user.id, false, res)
450 }
451 // Any other handle names a different ceremony. Refusing keeps a
452 // registration challenge from being answered as a sign-in.
453 _ => return Err(challenge_expired()),
454 };
455
456 record_use(&state, user_id, &result).await?;
457
458 let user = state
459 .db
460 .find_user_by_id(user_id)
461 .await?
462 .filter(|u| u.active)
463 .ok_or_else(|| webauthn_failed(WebauthnError::CredentialNotFound))?;
464 if user.auth_mode == AuthMode::Both && !second_factor {
465 let state_id = crate::webauthn::put(Pending::NeedsPassword { user_id });
466 return Ok(Json(LoginResp {
467 ok: false,
468 password_required: Some(PasswordStep {
469 name: user.name,
470 state_id,
471 }),
472 ..Default::default()
473 })
474 .into_response());
475 }
476 sign_in(&state, user_id).await
477}
478
479/// Persist what the assertion changed: the signature counter and backup
480/// flags move, and the settings list shows when a passkey was last used.
481async fn record_use(
482 state: &AppState,
483 user_id: i64,
484 result: &AuthenticationResult,
485) -> Result<(), ApiError> {
486 let Some((id, mut key)) = load_passkeys(state, user_id)
487 .await?
488 .into_iter()
489 .find(|(_, k)| k.cred_id() == result.cred_id())
490 else {
491 return Ok(());
492 };
493 key.update_credential(result);
494 let encoded = serde_json::to_string(&key).unwrap_or_default();
495 if !encoded.is_empty() {
496 state.db.passkey_used(id, &encoded).await?;
497 }
498 Ok(())
499}
500
501/// Create the session and send its cookie.
502pub(crate) async fn sign_in(state: &AppState, user_id: i64) -> Result<Response, ApiError> {
503 let token = auth::random_token();
504 state.db.create_session(user_id, &token).await?;
505 Ok((
506 [(header::SET_COOKIE, session_cookie(&token, state.https()))],
507 Json(LoginResp {
508 ok: true,
509 ..Default::default()
510 }),
511 )
512 .into_response())
513}
514
515/// Require the authenticator to store the credential itself.
516///
517/// Sign-in only issues discoverable challenges, so a credential the
518/// authenticator does not store could never sign in. `start_passkey_registration`
519/// sends `residentKey: "discouraged"`, and the passkey API has no builder
520/// switch for it, hence the patch. With `required` the browser refuses an
521/// authenticator that cannot store the credential, such as a U2F-only key.
522fn require_discoverable(options: &mut CreationChallengeResponse) {
523 match options.public_key.authenticator_selection.as_mut() {
524 Some(sel) => {
525 sel.resident_key = Some(ResidentKeyRequirement::Required);
526 // Browsers that predate `residentKey` read only this flag.
527 sel.require_resident_key = true;
528 }
529 // `webauthn-rs` always sends this block today. If a future version
530 // stops, new passkeys may not be discoverable and could not sign in.
531 None => tracing::warn!("no authenticatorSelection to ask for a discoverable credential"),
532 }
533}
534
535/// Park a ceremony's state and pair its handle with the browser's options.
536pub(crate) fn challenge<T: serde::Serialize>(
537 pending: Pending,
538 options: &T,
539) -> Result<PasskeyChallenge, ApiError> {
540 let options = serde_json::to_string(options).map_err(|e| {
541 ApiError::new(
542 StatusCode::INTERNAL_SERVER_ERROR,
543 format!("cannot encode the challenge: {e}"),
544 )
545 })?;
546 Ok(PasskeyChallenge {
547 state_id: crate::webauthn::put(pending),
548 options,
549 })
550}
551