From f2fcada799bb1468d46f8060d75af961257e272d Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Fri, 6 Feb 2026 18:18:58 +0000 Subject: [PATCH] perf(auth): fix N+1 queries in WebAuthn authentication Add list_all() to PasskeyCredentialRepository so auth_start and auth_finish fetch all credentials in a single query instead of looping through every user with a per-user query. --- src/application/routes/api/webauthn.rs | 68 +++++++------------ src/domain/repositories.rs | 1 + .../repositories/passkey_credentials.rs | 19 ++++++ 3 files changed, 46 insertions(+), 42 deletions(-) diff --git a/src/application/routes/api/webauthn.rs b/src/application/routes/api/webauthn.rs index eccb9ee..804d207 100644 --- a/src/application/routes/api/webauthn.rs +++ b/src/application/routes/api/webauthn.rs @@ -190,30 +190,19 @@ pub(crate) async fn auth_start( State(state): State, Query(query): Query, ) -> Result, StatusCode> { - // Load all passkey credentials from all users - let users = state.user_repo.list_all().await.map_err(|err| { - error!(error = %err, "failed to list users for auth start"); + // Load all passkey credentials in a single query + let credentials = state.passkey_repo.list_all().await.map_err(|err| { + error!(error = %err, "failed to list all passkey credentials"); StatusCode::INTERNAL_SERVER_ERROR })?; let mut all_passkeys: Vec = Vec::new(); - for user in &users { - let credentials = state - .passkey_repo - .list_by_user(user.id) - .await - .map_err(|err| { - error!(error = %err, user_id = %user.id, "failed to list passkeys for user"); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - for cred in credentials { - let passkey: Passkey = serde_json::from_str(&cred.credential_json).map_err(|err| { - error!(error = %err, "failed to deserialize passkey credential"); - StatusCode::INTERNAL_SERVER_ERROR - })?; - all_passkeys.push(passkey); - } + for cred in credentials { + let passkey: Passkey = serde_json::from_str(&cred.credential_json).map_err(|err| { + error!(error = %err, credential_id = %cred.id, "failed to deserialize passkey credential"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + all_passkeys.push(passkey); } if all_passkeys.is_empty() { @@ -276,8 +265,8 @@ pub(crate) async fn auth_finish( // Find the user who owns this credential let credential_id = auth_result.cred_id(); - let users = state.user_repo.list_all().await.map_err(|err| { - error!(error = %err, "failed to list users for credential lookup"); + let credentials = state.passkey_repo.list_all().await.map_err(|err| { + error!(error = %err, "failed to list passkey credentials for credential lookup"); StatusCode::INTERNAL_SERVER_ERROR })?; @@ -285,28 +274,17 @@ pub(crate) async fn auth_finish( let mut found_cred_id = None; let mut found_passkey: Option = None; - 'outer: for user in &users { - let credentials = state - .passkey_repo - .list_by_user(user.id) - .await + for cred in &credentials { + let passkey: Passkey = serde_json::from_str(&cred.credential_json) .map_err(|err| { - error!(error = %err, user_id = %user.id, "failed to list passkeys for user"); + error!(error = %err, credential_id = %cred.id, "failed to deserialize passkey credential"); StatusCode::INTERNAL_SERVER_ERROR })?; - - for cred in &credentials { - let passkey: Passkey = serde_json::from_str(&cred.credential_json) - .map_err(|err| { - error!(error = %err, credential_id = %cred.id, "failed to deserialize passkey credential"); - StatusCode::INTERNAL_SERVER_ERROR - })?; - if passkey.cred_id() == credential_id { - found_user_id = Some(user.id); - found_cred_id = Some(cred.id); - found_passkey = Some(passkey); - break 'outer; - } + if passkey.cred_id() == credential_id { + found_user_id = Some(cred.user_id); + found_cred_id = Some(cred.id); + found_passkey = Some(passkey); + break; } } @@ -413,7 +391,13 @@ pub(crate) async fn passkey_add_start( let exclude_credentials = existing .iter() - .filter_map(|c| serde_json::from_str::(&c.credential_json).ok()) + .filter_map(|c| { + serde_json::from_str::(&c.credential_json) + .map_err(|err| { + warn!(error = %err, credential_id = %c.id, "failed to deserialize passkey credential for exclude list"); + }) + .ok() + }) .map(|p| p.cred_id().clone()) .collect::>(); diff --git a/src/domain/repositories.rs b/src/domain/repositories.rs index 8e93499..5363576 100644 --- a/src/domain/repositories.rs +++ b/src/domain/repositories.rs @@ -236,6 +236,7 @@ pub trait PasskeyCredentialRepository: Send + Sync { &self, user_id: UserId, ) -> Result, RepositoryError>; + async fn list_all(&self) -> Result, RepositoryError>; async fn update_credential_json( &self, id: PasskeyCredentialId, diff --git a/src/infrastructure/repositories/passkey_credentials.rs b/src/infrastructure/repositories/passkey_credentials.rs index 0f8dbf4..a7e6870 100644 --- a/src/infrastructure/repositories/passkey_credentials.rs +++ b/src/infrastructure/repositories/passkey_credentials.rs @@ -107,6 +107,25 @@ impl PasskeyCredentialRepository for SqlPasskeyCredentialRepository { Ok(records.into_iter().map(Self::to_domain).collect()) } + async fn list_all(&self) -> Result, RepositoryError> { + let sql = r" + SELECT id, user_id, credential_json, name, created_at, last_used_at + FROM passkey_credentials + ORDER BY created_at ASC + "; + + let records = query_as::<_, PasskeyCredentialRecord>(sql) + .fetch_all(&self.pool) + .await + .map_err(|err| { + RepositoryError::unexpected(format!( + "failed to list all passkey credentials: {err}" + )) + })?; + + Ok(records.into_iter().map(Self::to_domain).collect()) + } + async fn update_credential_json( &self, id: PasskeyCredentialId,