Skip to main content

max / makenotwork

Decompose AppState Phase 2: finish auth + retire spawn_email! Narrow the shared auth::maybe_send_login_notification helper to (&PgPool, &EmailClient, &BackgroundTx, &Config, ...) and inline its spawn_email!. Migrate the three remaining auth handlers: - login_handler: State<PgPool> + Config + EmailClient + BackgroundTx (lockout-notification spawn_email! also inlined) - passkey_auth_start: State<Arc<Webauthn>> - passkey_auth_finish: + Arc<Webauthn> auth.rs now has zero State<AppState> extractors. The spawn_email! macro read .email/.bg off AppState and is now unused crate-wide (all sites inlined during the migration) -- removed it. No behavior change; login 20 + lockout 4 + passkey 10 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-14 12:23 UTC
Commit: c8ed9778fd59fc6944d0a86418369624a05cccb8
Parent: ea707db
3 files changed, +67 insertions, -67 deletions
M server/src/auth.rs +24 -12
@@ -726,8 +726,12 @@ pub async fn track_session(
726 726 ///
727 727 /// Fire-and-forget — spawns a background task. Only sends if the user has opted in
728 728 /// and has more than one active session (meaning this is a new device).
729 + #[allow(clippy::too_many_arguments)]
729 730 pub async fn maybe_send_login_notification(
730 - state: &crate::AppState,
731 + db: &sqlx::PgPool,
732 + mailer: &crate::email::EmailClient,
733 + bg: &crate::background::BackgroundTx,
734 + config: &crate::config::Config,
731 735 user_id: UserId,
732 736 email: &str,
733 737 display_name: Option<&str>,
@@ -737,7 +741,7 @@ pub async fn maybe_send_login_notification(
737 741 if !enabled {
738 742 return;
739 743 }
740 - let session_count = match db::sessions::count_user_sessions(&state.db, user_id).await {
744 + let session_count = match db::sessions::count_user_sessions(db, user_id).await {
741 745 Ok(n) => n,
742 746 Err(e) => {
743 747 tracing::warn!("Failed to count sessions for login notification: {e}");
@@ -753,22 +757,30 @@ pub async fn maybe_send_login_notification(
753 757 .map(|s| s.chars().take(constants::USER_AGENT_MAX_LENGTH).collect::<String>());
754 758 let ip = crate::helpers::extract_client_ip(headers);
755 759 let unsub_url = crate::email::generate_unsubscribe_url(
756 - &state.config.host_url,
760 + &config.host_url,
757 761 user_id,
758 762 crate::email::UnsubscribeAction::Login,
759 763 &user_id.to_string(),
760 - &state.config.signing_secret,
764 + &config.signing_secret,
761 765 );
762 766 let email = email.to_string();
763 767 let display_name = display_name.map(String::from);
764 - crate::helpers::spawn_email!(state, "login notification", |email_client| {
765 - email_client.send_new_login_notification(
766 - &email,
767 - display_name.as_deref(),
768 - user_agent.as_deref(),
769 - ip.as_deref(),
770 - Some(&unsub_url),
771 - )
768 + // Inlined `spawn_email!`: the macro reads `.email`/`.bg` off AppState; this
769 + // helper now holds the EmailClient + BackgroundTx slices directly.
770 + let email_client = mailer.clone();
771 + bg.spawn("login notification", async move {
772 + if let Err(e) = email_client
773 + .send_new_login_notification(
774 + &email,
775 + display_name.as_deref(),
776 + user_agent.as_deref(),
777 + ip.as_deref(),
778 + Some(&unsub_url),
779 + )
780 + .await
781 + {
782 + tracing::error!(error = ?e, "failed to send login notification");
783 + }
772 784 });
773 785 }
774 786
@@ -41,28 +41,3 @@ pub use crate::rate_limit::{
41 41 rate_limiter_ms, rate_limiter_per_sec, synckit_app_rate_limiter_ms, CloudflareIpKeyExtractor,
42 42 SyncAppKeyExtractor,
43 43 };
44 -
45 - /// Fire-and-forget email send via the bounded background-task queue. The
46 - /// caller passes `state` (anything that exposes `.email` and `.bg`) so the
47 - /// task is bound by the global background concurrency cap rather than
48 - /// `tokio::spawn`'d unbounded — Run #8 surfaced webhook bursts that could
49 - /// otherwise spawn hundreds of detached email tasks competing with request
50 - /// handlers for the DB pool.
51 - ///
52 - /// Usage:
53 - /// ```ignore
54 - /// spawn_email!(state, "lockout notification", |email| {
55 - /// email.send_lockout_notification(&to, name.as_deref(), Some(&url))
56 - /// });
57 - /// ```
58 - macro_rules! spawn_email {
59 - ($state:expr, $context:literal, |$e:ident| $body:expr) => {{
60 - let $e = $state.email.clone();
61 - $state.bg.spawn($context, async move {
62 - if let Err(e) = $body.await {
63 - tracing::error!(error = ?e, concat!("failed to send ", $context));
64 - }
65 - });
66 - }};
67 - }
68 - pub(crate) use spawn_email;
@@ -14,12 +14,13 @@ use tower_sessions::{Expiry, Session};
14 14
15 15 use crate::{
16 16 auth::{login_user, logout_user, track_session, verify_password_async, AuthUser, SessionUser, SESSION_TRACKING_KEY},
17 + config::Config,
17 18 csrf::{post_csrf, with_csrf, with_csrf_manual, with_csrf_skip, CsrfRouter},
18 19 constants::{self, MAX_LOGIN_ATTEMPTS, LOCKOUT_MINUTES},
19 20 db::{self, UserSessionId, Username},
20 21 email,
21 22 error::{AppError, Result, ResultExt},
22 - helpers::{is_htmx_request, rate_limiter_ms, rate_limiter_per_sec, spawn_email},
23 + helpers::{is_htmx_request, rate_limiter_ms, rate_limiter_per_sec},
23 24 templates::*,
24 25 AppCaches, AppState,
25 26 };
@@ -81,7 +82,10 @@ pub struct LoginForm {
81 82 /// Authenticate a user via username/email and password with lockout protection.
82 83 #[tracing::instrument(skip_all, name = "auth::login")]
83 84 async fn login_handler(
84 - State(state): State<AppState>,
85 + State(db): State<PgPool>,
86 + State(config): State<Config>,
87 + State(mailer): State<crate::email::EmailClient>,
88 + State(bg): State<crate::background::BackgroundTx>,
85 89 headers: HeaderMap,
86 90 session: Session,
87 91 Form(form): Form<LoginForm>,
@@ -106,7 +110,7 @@ async fn login_handler(
106 110 crate::helpers::get_csrf_token(&session).await
107 111 };
108 112
109 - let sso_enabled = state.config.sso.is_some();
113 + let sso_enabled = config.sso.is_some();
110 114 let return_error = |msg: &str| -> Result<Response> {
111 115 if is_htmx {
112 116 Ok(Html(LoginErrorTemplate {
@@ -138,7 +142,7 @@ async fn login_handler(
138 142 let _ = verify_password_async("dummy".to_string(), DUMMY_HASH.clone()).await;
139 143 return return_error("Invalid username or password");
140 144 };
141 - db::users::get_user_by_email(&state.db, &email)
145 + db::users::get_user_by_email(&db, &email)
142 146 .await
143 147 .context("lookup user by email for login")?
144 148 } else {
@@ -151,7 +155,7 @@ async fn login_handler(
151 155 return return_error("Invalid username/email or password");
152 156 }
153 157 };
154 - db::users::get_user_by_username(&state.db, &username)
158 + db::users::get_user_by_username(&db, &username)
155 159 .await
156 160 .context("lookup user by username for login")?
157 161 };
@@ -188,7 +192,7 @@ async fn login_handler(
188 192 if !verify_password_async(form.password.clone(), user.password_hash.clone()).await? {
189 193 // Atomically increment failed attempts and lock if threshold reached
190 194 let result = db::auth::increment_failed_login(
191 - &state.db, user.id, MAX_LOGIN_ATTEMPTS, LOCKOUT_MINUTES,
195 + &db, user.id, MAX_LOGIN_ATTEMPTS, LOCKOUT_MINUTES,
192 196 )
193 197 .await
194 198 .context("increment failed login attempts")?;
@@ -200,16 +204,23 @@ async fn login_handler(
200 204 // Generate and send one-time login link
201 205 let (token, token_hash) = email::generate_login_token();
202 206 let expires_at = chrono::Utc::now() + chrono::Duration::minutes(LOCKOUT_MINUTES);
203 - db::auth::create_login_token(&state.db, user.id, &token_hash, expires_at)
207 + db::auth::create_login_token(&db, user.id, &token_hash, expires_at)
204 208 .await
205 209 .context("create login token after lockout")?;
206 210
207 - let login_url = email::generate_login_link_url(&state.config.host_url, &token);
211 + let login_url = email::generate_login_link_url(&config.host_url, &token);
208 212 // Send lockout notification with login link
209 213 let user_email = user.email.clone();
210 214 let user_display_name = user.display_name.clone();
211 - spawn_email!(state, "lockout notification", |email| {
212 - email.send_lockout_notification(&user_email, user_display_name.as_deref(), Some(&login_url))
215 + // Inlined `spawn_email!` (the macro reads .email/.bg off AppState).
216 + let email_client = mailer.clone();
217 + bg.spawn("lockout notification", async move {
218 + if let Err(e) = email_client
219 + .send_lockout_notification(&user_email, user_display_name.as_deref(), Some(&login_url))
220 + .await
221 + {
222 + tracing::error!(error = ?e, "failed to send lockout notification");
223 + }
213 224 });
214 225
215 226 return return_error(&format!(
@@ -221,7 +232,7 @@ async fn login_handler(
221 232 return return_error("Invalid username/email or password");
222 233 }
223 234
224 - db::auth::reset_failed_login(&state.db, user.id)
235 + db::auth::reset_failed_login(&db, user.id)
225 236 .await
226 237 .context("reset failed login attempts")?;
227 238
@@ -247,7 +258,7 @@ async fn login_handler(
247 258 .map(|s| s.chars().take(constants::USER_AGENT_MAX_LENGTH).collect::<String>());
248 259 let ip = crate::helpers::extract_client_ip(&headers);
249 260 let tracking_id = db::sessions::create_pending_2fa_session(
250 - &state.db, user.id, ua.as_deref(), ip.as_deref(),
261 + &db, user.id, ua.as_deref(), ip.as_deref(),
251 262 ).await?;
252 263 session.insert("pending_2fa_tracking_id", tracking_id).await.context("session insert")?;
253 264
@@ -265,17 +276,17 @@ async fn login_handler(
265 276 let notify_name = user.display_name.clone();
266 277 let notify_enabled = user.login_notification_enabled;
267 278
268 - let session_user = SessionUser::from_db_user(user, &state.db, state.config.admin_user_id).await;
279 + let session_user = SessionUser::from_db_user(user, &db, config.admin_user_id).await;
269 280
270 281 login_user(&session, session_user).await?;
271 282 if !remember {
272 283 session.set_expiry(Some(Expiry::OnSessionEnd));
273 284 }
274 - track_session(&session, &state.db, user_id, &headers).await?;
285 + track_session(&session, &db, user_id, &headers).await?;
275 286 tracing::info!(user_id = %user_id, event = "login_success", "User logged in");
276 287
277 288 crate::auth::maybe_send_login_notification(
278 - &state, user_id, &notify_email, notify_name.as_deref(), notify_enabled, &headers,
289 + &db, &mailer, &bg, &config, user_id, &notify_email, notify_name.as_deref(), notify_enabled, &headers,
279 290 ).await;
280 291
281 292 // For HTMX requests, return redirect header
@@ -396,11 +407,10 @@ const PASSKEY_AUTH_STATE_KEY: &str = "passkey_auth_state";
396 407 /// Start passkey authentication: discoverable flow (no username needed).
397 408 #[tracing::instrument(skip_all, name = "auth::passkey_start")]
398 409 async fn passkey_auth_start(
399 - State(state): State<AppState>,
410 + State(webauthn): State<std::sync::Arc<webauthn_rs::Webauthn>>,
400 411 session: Session,
401 412 ) -> Result<Response> {
402 - let (rcr, auth_state) = state
403 - .webauthn
413 + let (rcr, auth_state) = webauthn
404 414 .start_discoverable_authentication()
405 415 .context("webauthn auth start")?;
406 416
@@ -414,8 +424,13 @@ async fn passkey_auth_start(
414 424
415 425 /// Finish passkey authentication: verify assertion, create session.
416 426 #[tracing::instrument(skip_all, name = "auth::passkey_finish")]
427 + #[allow(clippy::too_many_arguments)]
417 428 async fn passkey_auth_finish(
418 - State(state): State<AppState>,
429 + State(db): State<PgPool>,
430 + State(config): State<Config>,
431 + State(mailer): State<crate::email::EmailClient>,
432 + State(bg): State<crate::background::BackgroundTx>,
433 + State(webauthn): State<std::sync::Arc<webauthn_rs::Webauthn>>,
419 434 headers: HeaderMap,
420 435 session: Session,
421 436 axum::Json(auth): axum::Json<PublicKeyCredential>,
@@ -433,14 +448,13 @@ async fn passkey_auth_finish(
433 448 .ok();
434 449
435 450 // Identify which credential responded (extracts user UUID + credential ID)
436 - let (_user_uuid, cred_id_ref) = state
437 - .webauthn
451 + let (_user_uuid, cred_id_ref) = webauthn
438 452 .identify_discoverable_authentication(&auth)
439 453 .map_err(|e| AppError::BadRequest(format!("Passkey identification failed: {}", e)))?;
440 454 let cred_id_bytes = cred_id_ref.to_vec();
441 455
442 456 // Look up user by credential ID
443 - let (user_id, cred_json) = db::passkeys::find_user_by_credential_id(&state.db, &cred_id_bytes)
457 + let (user_id, cred_json) = db::passkeys::find_user_by_credential_id(&db, &cred_id_bytes)
444 458 .await
445 459 .context("lookup user by passkey credential")?
446 460 .ok_or_else(|| AppError::BadRequest("Unknown credential".to_string()))?;
@@ -454,8 +468,7 @@ async fn passkey_auth_finish(
454 468 // signature-counter regression (CredentialPossibleCompromise) here, before
455 469 // we ever persist — surface that as a distinct, auditable security event
456 470 // rather than a generic verification failure (SEC-S2, Run #23).
457 - let auth_result = state
458 - .webauthn
471 + let auth_result = webauthn
459 472 .finish_discoverable_authentication(&auth, auth_state, &[discoverable_key])
460 473 .map_err(|e| {
461 474 if matches!(e, WebauthnError::CredentialPossibleCompromise) {
@@ -474,12 +487,12 @@ async fn passkey_auth_finish(
474 487 passkey.update_credential(&auth_result);
475 488 let updated_json = serde_json::to_value(&passkey)
476 489 .context("serialize passkey credential")?;
477 - db::passkeys::update_passkey_after_auth(&state.db, &cred_id_bytes, &updated_json)
490 + db::passkeys::update_passkey_after_auth(&db, &cred_id_bytes, &updated_json)
478 491 .await
479 492 .context("update passkey counter after auth")?;
480 493
481 494 // Load full user data for session
482 - let user = db::users::get_user_by_id(&state.db, user_id)
495 + let user = db::users::get_user_by_id(&db, user_id)
483 496 .await
484 497 .with_context(|| format!("fetch user {user_id} for passkey session"))?
485 498 .ok_or(AppError::Unauthorized)?;
@@ -491,7 +504,7 @@ async fn passkey_auth_finish(
491 504 }
492 505
493 506 // Reset failed login attempts (successful passkey auth)
494 - db::auth::reset_failed_login(&state.db, user.id)
507 + db::auth::reset_failed_login(&db, user.id)
495 508 .await
496 509 .context("reset failed login after passkey auth")?;
497 510
@@ -500,14 +513,14 @@ async fn passkey_auth_finish(
500 513 let notify_email = user.email.clone();
501 514 let notify_name = user.display_name.clone();
502 515 let notify_enabled = user.login_notification_enabled;
503 - let session_user = SessionUser::from_db_user(user, &state.db, state.config.admin_user_id).await;
516 + let session_user = SessionUser::from_db_user(user, &db, config.admin_user_id).await;
504 517
505 518 login_user(&session, session_user).await?;
506 - track_session(&session, &state.db, passkey_user_id, &headers).await?;
519 + track_session(&session, &db, passkey_user_id, &headers).await?;
507 520 tracing::info!(user_id = %passkey_user_id, event = "login_passkey_success", "User logged in via passkey");
508 521
509 522 crate::auth::maybe_send_login_notification(
510 - &state, passkey_user_id, &notify_email, notify_name.as_deref(), notify_enabled, &headers,
523 + &db, &mailer, &bg, &config, passkey_user_id, &notify_email, notify_name.as_deref(), notify_enabled, &headers,
511 524 ).await;
512 525
513 526 Ok(axum::Json(serde_json::json!({"redirect": "/dashboard"})).into_response())