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.
This commit is contained in:
Jon Seager 2026-02-06 18:18:58 +00:00
parent a3cf7970d1
commit f2fcada799
No known key found for this signature in database
3 changed files with 46 additions and 42 deletions

View file

@ -190,31 +190,20 @@ pub(crate) async fn auth_start(
State(state): State<AppState>, State(state): State<AppState>,
Query(query): Query<AuthStartQuery>, Query(query): Query<AuthStartQuery>,
) -> Result<Json<AuthStartResponse>, StatusCode> { ) -> Result<Json<AuthStartResponse>, StatusCode> {
// Load all passkey credentials from all users // Load all passkey credentials in a single query
let users = state.user_repo.list_all().await.map_err(|err| { let credentials = state.passkey_repo.list_all().await.map_err(|err| {
error!(error = %err, "failed to list users for auth start"); error!(error = %err, "failed to list all passkey credentials");
StatusCode::INTERNAL_SERVER_ERROR StatusCode::INTERNAL_SERVER_ERROR
})?; })?;
let mut all_passkeys: Vec<Passkey> = Vec::new(); let mut all_passkeys: Vec<Passkey> = 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 { for cred in credentials {
let passkey: Passkey = serde_json::from_str(&cred.credential_json).map_err(|err| { let passkey: Passkey = serde_json::from_str(&cred.credential_json).map_err(|err| {
error!(error = %err, "failed to deserialize passkey credential"); error!(error = %err, credential_id = %cred.id, "failed to deserialize passkey credential");
StatusCode::INTERNAL_SERVER_ERROR StatusCode::INTERNAL_SERVER_ERROR
})?; })?;
all_passkeys.push(passkey); all_passkeys.push(passkey);
} }
}
if all_passkeys.is_empty() { if all_passkeys.is_empty() {
return Err(StatusCode::NOT_FOUND); return Err(StatusCode::NOT_FOUND);
@ -276,8 +265,8 @@ pub(crate) async fn auth_finish(
// Find the user who owns this credential // Find the user who owns this credential
let credential_id = auth_result.cred_id(); let credential_id = auth_result.cred_id();
let users = state.user_repo.list_all().await.map_err(|err| { let credentials = state.passkey_repo.list_all().await.map_err(|err| {
error!(error = %err, "failed to list users for credential lookup"); error!(error = %err, "failed to list passkey credentials for credential lookup");
StatusCode::INTERNAL_SERVER_ERROR StatusCode::INTERNAL_SERVER_ERROR
})?; })?;
@ -285,16 +274,6 @@ pub(crate) async fn auth_finish(
let mut found_cred_id = None; let mut found_cred_id = None;
let mut found_passkey: Option<Passkey> = None; let mut found_passkey: Option<Passkey> = None;
'outer: 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 { for cred in &credentials {
let passkey: Passkey = serde_json::from_str(&cred.credential_json) let passkey: Passkey = serde_json::from_str(&cred.credential_json)
.map_err(|err| { .map_err(|err| {
@ -302,11 +281,10 @@ pub(crate) async fn auth_finish(
StatusCode::INTERNAL_SERVER_ERROR StatusCode::INTERNAL_SERVER_ERROR
})?; })?;
if passkey.cred_id() == credential_id { if passkey.cred_id() == credential_id {
found_user_id = Some(user.id); found_user_id = Some(cred.user_id);
found_cred_id = Some(cred.id); found_cred_id = Some(cred.id);
found_passkey = Some(passkey); found_passkey = Some(passkey);
break 'outer; break;
}
} }
} }
@ -413,7 +391,13 @@ pub(crate) async fn passkey_add_start(
let exclude_credentials = existing let exclude_credentials = existing
.iter() .iter()
.filter_map(|c| serde_json::from_str::<Passkey>(&c.credential_json).ok()) .filter_map(|c| {
serde_json::from_str::<Passkey>(&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()) .map(|p| p.cred_id().clone())
.collect::<Vec<_>>(); .collect::<Vec<_>>();

View file

@ -236,6 +236,7 @@ pub trait PasskeyCredentialRepository: Send + Sync {
&self, &self,
user_id: UserId, user_id: UserId,
) -> Result<Vec<PasskeyCredential>, RepositoryError>; ) -> Result<Vec<PasskeyCredential>, RepositoryError>;
async fn list_all(&self) -> Result<Vec<PasskeyCredential>, RepositoryError>;
async fn update_credential_json( async fn update_credential_json(
&self, &self,
id: PasskeyCredentialId, id: PasskeyCredentialId,

View file

@ -107,6 +107,25 @@ impl PasskeyCredentialRepository for SqlPasskeyCredentialRepository {
Ok(records.into_iter().map(Self::to_domain).collect()) Ok(records.into_iter().map(Self::to_domain).collect())
} }
async fn list_all(&self) -> Result<Vec<PasskeyCredential>, 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( async fn update_credential_json(
&self, &self,
id: PasskeyCredentialId, id: PasskeyCredentialId,