refactor(logging): log errors and security events in auth routes

- Log all map_err(|_| StatusCode) patterns in webauthn, tokens, and account handlers
- Log fire-and-forget errors: registration token mark_used, passkey credential
  counter update, passkey update_last_used
- Add security event logging: token create/revoke, passkey delete, login/logout,
  CLI token creation via passkey auth
- Log session delete failures and auth check errors
This commit is contained in:
Jon Seager 2026-02-05 18:13:47 +00:00
parent 5ee89549a3
commit 4ab00dd630
No known key found for this signature in database
4 changed files with 163 additions and 84 deletions

View file

@ -6,6 +6,7 @@ use axum::response::{IntoResponse, Redirect, Response};
use chrono::{DateTime, Utc};
use serde::Serialize;
use tower_cookies::Cookies;
use tracing::{error, info, warn};
use crate::application::auth::AuthenticatedUser;
use crate::application::routes::render_html;
@ -98,7 +99,10 @@ pub(crate) async fn account_page(
.passkey_repo
.list_by_user(auth_user.id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.map_err(|err| {
error!(error = %err, "failed to list passkeys for account page");
StatusCode::INTERNAL_SERVER_ERROR
})?
.into_iter()
.map(|p| PasskeyView {
id: i64::from(p.id),
@ -112,7 +116,10 @@ pub(crate) async fn account_page(
.token_repo
.list_by_user(auth_user.id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.map_err(|err| {
error!(error = %err, "failed to list tokens for account page");
StatusCode::INTERNAL_SERVER_ERROR
})?
.into_iter()
.filter(crate::domain::tokens::Token::is_active)
.map(|t| TokenView {
@ -123,13 +130,14 @@ pub(crate) async fn account_page(
})
.collect();
let ai_usage = state
.ai_usage_repo
.summary_for_user(auth_user.id)
.await
.ok()
.filter(|s| s.total_calls > 0)
.map(|s| AiUsageView {
let ai_usage = match state.ai_usage_repo.summary_for_user(auth_user.id).await {
Ok(summary) => Some(summary),
Err(err) => {
warn!(error = %err, "failed to load AI usage summary");
None
}
};
let ai_usage = ai_usage.filter(|s| s.total_calls > 0).map(|s| AiUsageView {
total_calls: s.total_calls,
total_tokens: format_number(s.total_tokens),
total_cost: format_cost(s.total_cost),
@ -165,7 +173,10 @@ pub(crate) async fn list_passkeys(
.passkey_repo
.list_by_user(auth_user.0.id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.map_err(|err| {
error!(error = %err, "failed to list passkeys");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let responses: Vec<PasskeyResponse> = passkeys
.into_iter()
@ -186,11 +197,10 @@ pub(crate) async fn delete_passkey(
Path(passkey_id): Path<PasskeyCredentialId>,
) -> Result<StatusCode, StatusCode> {
// Verify the passkey belongs to the user
let passkey = state
.passkey_repo
.get(passkey_id)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
let passkey = state.passkey_repo.get(passkey_id).await.map_err(|err| {
error!(error = %err, %passkey_id, "failed to get passkey for deletion");
StatusCode::NOT_FOUND
})?;
if passkey.user_id != auth_user.0.id {
return Err(StatusCode::FORBIDDEN);
@ -201,17 +211,21 @@ pub(crate) async fn delete_passkey(
.passkey_repo
.list_by_user(auth_user.0.id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.map_err(|err| {
error!(error = %err, "failed to list passkeys for deletion check");
StatusCode::INTERNAL_SERVER_ERROR
})?;
if all_passkeys.len() <= 1 {
return Err(StatusCode::CONFLICT);
}
state
.passkey_repo
.delete(passkey_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
state.passkey_repo.delete(passkey_id).await.map_err(|err| {
error!(error = %err, %passkey_id, "failed to delete passkey");
StatusCode::INTERNAL_SERVER_ERROR
})?;
info!(%passkey_id, user_id = %auth_user.0.id, "passkey deleted");
Ok(StatusCode::NO_CONTENT)
}
@ -226,15 +240,27 @@ async fn extract_user_from_session(
let session_token = cookie.value();
let session_token_hash = crate::infrastructure::auth::hash_token(session_token);
let session = state
let session = match state
.session_repo
.get_by_token_hash(&session_token_hash)
.await
.ok()?;
{
Ok(s) => s,
Err(err) => {
warn!(error = %err, "session lookup failed on account page");
return None;
}
};
if session.is_expired() {
return None;
}
state.user_repo.get(session.user_id).await.ok()
match state.user_repo.get(session.user_id).await {
Ok(user) => Some(user),
Err(err) => {
warn!(error = %err, user_id = %session.user_id, "user lookup failed for valid session");
None
}
}
}

View file

@ -4,6 +4,7 @@ use axum::http::StatusCode;
use axum::response::{IntoResponse, Redirect, Response};
use serde::Deserialize;
use tower_cookies::{Cookie, Cookies};
use tracing::{info, warn};
use crate::application::routes::render_html;
use crate::application::server::AppState;
@ -55,11 +56,13 @@ pub(crate) async fn logout(State(state): State<AppState>, cookies: Cookies) -> R
.session_repo
.get_by_token_hash(&session_token_hash)
.await
&& let Err(err) = state.session_repo.delete(session.id).await
{
let _ = state.session_repo.delete(session.id).await;
warn!(error = %err, session_id = %session.id, "failed to delete session on logout");
}
}
info!("user logged out");
cookies.remove(Cookie::from(SESSION_COOKIE_NAME));
Redirect::to("/")
}
@ -79,6 +82,9 @@ pub async fn is_authenticated(state: &AppState, cookies: &Cookies) -> bool {
.await
{
Ok(session) => !session.is_expired(),
Err(_) => false,
Err(err) => {
warn!(error = %err, "session lookup failed during auth check");
false
}
}
}

View file

@ -3,6 +3,7 @@ use axum::extract::{Path, State};
use axum::http::StatusCode;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tracing::{error, info};
use crate::application::auth::AuthenticatedUser;
use crate::application::server::AppState;
@ -51,16 +52,20 @@ pub async fn create_token(
auth_user: AuthenticatedUser,
Json(payload): Json<CreateTokenRequest>,
) -> Result<Json<CreateTokenResponse>, StatusCode> {
let token_value = generate_token().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let token_value = generate_token().map_err(|err| {
error!(error = %err, "failed to generate token");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let token_hash_value = hash_token(&token_value);
let new_token = NewToken::new(auth_user.0.id, token_hash_value, payload.name.clone());
let stored_token = state
.token_repo
.insert(new_token)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let stored_token = state.token_repo.insert(new_token).await.map_err(|err| {
error!(error = %err, "failed to store token");
StatusCode::INTERNAL_SERVER_ERROR
})?;
info!(token_id = %stored_token.id, token_name = %stored_token.name, user_id = %auth_user.0.id, "API token created");
Ok(Json(CreateTokenResponse {
id: stored_token.id,
@ -78,7 +83,10 @@ pub async fn list_tokens(
.token_repo
.list_by_user(auth_user.0.id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.map_err(|err| {
error!(error = %err, "failed to list tokens");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let token_responses: Vec<TokenResponse> = tokens.into_iter().map(TokenResponse::from).collect();
@ -92,11 +100,10 @@ pub async fn revoke_token(
Path(token_id): Path<TokenId>,
) -> Result<Json<TokenResponse>, StatusCode> {
// Get the token to ensure it exists and belongs to the user
let token = state
.token_repo
.get(token_id)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
let token = state.token_repo.get(token_id).await.map_err(|err| {
error!(error = %err, %token_id, "failed to get token for revocation");
StatusCode::NOT_FOUND
})?;
// Ensure the token belongs to the authenticated user
if token.user_id != auth_user.0.id {
@ -104,11 +111,12 @@ pub async fn revoke_token(
}
// Revoke the token
let revoked_token = state
.token_repo
.revoke(token_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let revoked_token = state.token_repo.revoke(token_id).await.map_err(|err| {
error!(error = %err, %token_id, "failed to revoke token");
StatusCode::INTERNAL_SERVER_ERROR
})?;
info!(%token_id, user_id = %auth_user.0.id, "API token revoked");
Ok(Json(TokenResponse::from(revoked_token)))
}

View file

@ -138,7 +138,10 @@ pub(crate) async fn register_start(
.registration_token_repo
.get_by_token_hash(&token_hash)
.await
.map_err(|_| StatusCode::UNAUTHORIZED)?;
.map_err(|err| {
warn!(error = %err, "registration token lookup failed");
StatusCode::UNAUTHORIZED
})?;
if !reg_token.is_valid() {
return Err(StatusCode::GONE);
@ -153,14 +156,19 @@ pub(crate) async fn register_start(
})?;
// Mark registration token as used
let _ = state
if let Err(err) = state
.registration_token_repo
.mark_used(reg_token.id, user.id)
.await;
.await
{
warn!(error = %err, token_id = %reg_token.id, "failed to mark registration token as used");
}
// Start passkey registration ceremony
let webauthn_uuid =
Uuid::parse_str(&user_uuid).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let webauthn_uuid = Uuid::parse_str(&user_uuid).map_err(|err| {
error!(error = %err, "failed to parse user UUID");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let exclude_credentials = Vec::new();
let (ccr, reg_state) = state
@ -214,8 +222,10 @@ pub(crate) async fn register_finish(
})?;
// Store the credential
let credential_json =
serde_json::to_string(&passkey).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let credential_json = serde_json::to_string(&passkey).map_err(|err| {
error!(error = %err, "failed to serialize passkey credential");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let new_credential = NewPasskeyCredential::new(user_id, credential_json, payload.passkey_name);
state
.passkey_repo
@ -242,11 +252,10 @@ pub(crate) async fn auth_start(
Query(query): Query<AuthStartQuery>,
) -> Result<Json<AuthStartResponse>, StatusCode> {
// Load all passkey credentials from all users
let users = state
.user_repo
.list_all()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let users = state.user_repo.list_all().await.map_err(|err| {
error!(error = %err, "failed to list users for auth start");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let mut all_passkeys: Vec<Passkey> = Vec::new();
for user in &users {
@ -254,7 +263,10 @@ pub(crate) async fn auth_start(
.passkey_repo
.list_by_user(user.id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.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| {
@ -300,6 +312,7 @@ pub(crate) async fn auth_start(
// --- Authentication finish ---
#[allow(clippy::too_many_lines)]
#[tracing::instrument(skip(state, cookies, payload))]
pub(crate) async fn auth_finish(
State(state): State<AppState>,
@ -324,11 +337,10 @@ 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(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let users = state.user_repo.list_all().await.map_err(|err| {
error!(error = %err, "failed to list users for credential lookup");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let mut found_user_id = None;
let mut found_cred_id = None;
@ -339,11 +351,17 @@ pub(crate) async fn auth_finish(
.passkey_repo
.list_by_user(user.id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.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(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.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);
@ -361,31 +379,45 @@ pub(crate) async fn auth_finish(
&& let Some(mut passkey) = found_passkey
{
passkey.update_credential(&auth_result);
if let Ok(updated_json) = serde_json::to_string(&passkey) {
let _ = state
match serde_json::to_string(&passkey) {
Ok(updated_json) => {
if let Err(err) = state
.passkey_repo
.update_credential_json(cred_db_id, &updated_json)
.await;
.await
{
warn!(error = %err, credential_id = %cred_db_id, "failed to update passkey credential counter");
}
}
Err(err) => {
warn!(error = %err, "failed to serialize updated passkey credential");
}
}
}
// Update last used timestamp
let passkey_repo = state.passkey_repo.clone();
tokio::spawn(async move {
let _ = passkey_repo.update_last_used(cred_db_id).await;
if let Err(err) = passkey_repo.update_last_used(cred_db_id).await {
warn!(error = %err, credential_id = %cred_db_id, "failed to update passkey last_used");
}
});
// Handle CLI callback flow
if let Some(cli_info) = cli_callback {
// Generate a bearer token for the CLI
let token_value = generate_token().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let token_value = generate_token().map_err(|err| {
error!(error = %err, "failed to generate CLI bearer token");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let token_hash_value = hash_token(&token_value);
let new_token = NewToken::new(user_id, token_hash_value, cli_info.token_name);
state
.token_repo
.insert(new_token)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
state.token_repo.insert(new_token).await.map_err(|err| {
error!(error = %err, "failed to store CLI bearer token");
StatusCode::INTERNAL_SERVER_ERROR
})?;
info!(user_id = %user_id, "CLI token created via passkey auth");
let redirect_url = format!(
"{}?token={}&state={}",
@ -425,15 +457,20 @@ pub(crate) async fn passkey_add_start(
Json(payload): Json<PasskeyAddStartRequest>,
) -> Result<Json<ChallengeResponse<CreationChallengeResponse>>, StatusCode> {
let user = auth_user.0;
let webauthn_uuid =
Uuid::parse_str(&user.uuid).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let webauthn_uuid = Uuid::parse_str(&user.uuid).map_err(|err| {
error!(error = %err, "failed to parse user UUID for passkey add");
StatusCode::INTERNAL_SERVER_ERROR
})?;
// Load existing credentials to exclude (prevents re-registering same authenticator)
let existing = state
.passkey_repo
.list_by_user(user.id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.map_err(|err| {
error!(error = %err, user_id = %user.id, "failed to list existing passkeys");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let exclude_credentials = existing
.iter()
@ -485,8 +522,10 @@ pub(crate) async fn passkey_add_finish(
StatusCode::BAD_REQUEST
})?;
let credential_json =
serde_json::to_string(&passkey).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let credential_json = serde_json::to_string(&passkey).map_err(|err| {
error!(error = %err, "failed to serialize new passkey credential");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let new_credential = NewPasskeyCredential::new(user_id, credential_json, payload.name);
state
.passkey_repo