axess_core/authn/error.rs
1//! Error type for authentication service operations.
2
3use crate::authn::types::EntityState;
4
5/// Errors that can occur during authentication flows.
6///
7/// Parameterised over the backend error type `E` so that storage errors are
8/// propagated with their original type rather than being erased.
9///
10/// # Suggested HTTP status code mapping
11///
12/// | Variant | HTTP Status | Rationale |
13/// |---|---|---|
14/// | `Store` | `500 Internal Server Error` | Backend / database failure; not the client's fault. |
15/// | `NoFlow` | `409 Conflict` | No active authentication flow in the session; client must start one first. |
16/// | `NotActive` | `403 Forbidden` | Account exists but is disabled, suspended, or otherwise ineligible. |
17/// | `Locked` | `423 Locked` | Account locked due to too many failed attempts; retry after cooldown. |
18/// | `InvalidAssertion` | `401 Unauthorized` | FIDO2 cryptographic validation failed; credential rejected. |
19/// | `ExternalService` | `502 Bad Gateway` | Upstream provider (LDAP, OAuth IdP) returned an error or timed out. |
20///
21/// These are suggestions for handler authors. The library does not convert
22/// errors to HTTP responses automatically; that mapping belongs in your
23/// application's error-handling layer.
24#[derive(Debug, thiserror::Error)]
25pub enum AuthnError<E: std::error::Error + Send + Sync + 'static> {
26 /// An error from the underlying identity or factor store.
27 #[error("store error: {0}")]
28 Store(#[source] E),
29
30 /// No active authentication flow found in the session.
31 ///
32 /// Returned by `verify_factor` when the session is not in `Authenticating` state.
33 #[error("no active authentication flow")]
34 NoFlow,
35
36 /// The account exists but is not in a state that permits login.
37 #[error("account not active: {0:?}")]
38 NotActive(EntityState),
39
40 /// The account is locked (suspension or accumulated failed attempts).
41 ///
42 /// Returned by:
43 /// - `prepare_factor` when [`account_status`](crate::authn::IdentityLookup::account_status)
44 /// reports `Suspended`.
45 /// - FIDO2 `finish_discoverable_login` when the lockout threshold is
46 /// reached during a discoverable assertion's failure path.
47 ///
48 /// `until` is the suspension expiry when known (for `Suspended`
49 /// accounts whose `StatusDetail.until` is set), `None` for
50 /// indefinite locks or for the failed-attempt lockout (which has
51 /// no inherent expiry: an admin must reset). Use
52 /// [`lockout_expiry`](Self::lockout_expiry) for one-call access
53 /// that also handles the legacy
54 /// `NotActive(EntityState::Suspended(_))` case.
55 ///
56 /// Callers should display a generic "account locked" message
57 /// (rendering `until` if present and non-trivial) rather than
58 /// distinguishing failed-attempt vs admin-suspension reasons;
59 /// that distinction is operator-visible via the audit log,
60 /// never a property of the error response.
61 #[error("account locked")]
62 Locked {
63 /// Wall-clock expiry of the lock, when known.
64 until: Option<chrono::DateTime<chrono::Utc>>,
65 },
66
67 /// A FIDO2/WebAuthn assertion or registration failed validation.
68 ///
69 /// Distinct from [`NoFlow`](Self::NoFlow) (no ceremony in progress): this means a
70 /// ceremony was in progress but the cryptographic validation failed.
71 #[error("invalid FIDO2 assertion")]
72 InvalidAssertion,
73
74 /// An external service (LDAP, OAuth IdP, etc.) failed with a
75 /// non-credential error (connection timeout, network issue, etc.).
76 ///
77 /// Callers should treat this as a transient failure and may retry or
78 /// show a "service unavailable" message, not "invalid credentials".
79 #[error("external service error: {0}")]
80 ExternalService(String),
81
82 /// A tenant-scoped operation was invoked against a target whose
83 /// `tenant_id` does not match the caller-supplied expected tenant.
84 ///
85 /// Returned by the `_in_tenant` family of methods on `AuthnService`
86 /// (e.g. `suspend_user_in_tenant`, `activate_user_in_tenant`,
87 /// `begin_impersonation_in_tenant`) when the structural rail
88 /// fires. Suggested HTTP status: `403 Forbidden` (the caller is
89 /// authenticated but operating outside their authorised scope).
90 ///
91 /// Treat audit logging of this variant as security-relevant;
92 /// it almost always indicates either a buggy admin UI or an
93 /// attempted cross-tenant pivot.
94 #[error("cross-tenant operation refused")]
95 CrossTenant,
96}
97
98impl<E: std::error::Error + Send + Sync + 'static> AuthnError<E> {
99 /// Return the lockout expiry timestamp when this error represents
100 /// a locked account, regardless of whether the lock surfaces as
101 /// [`AuthnError::Locked`] or as `AuthnError::NotActive(EntityState::Suspended(_))`.
102 ///
103 /// Returns `None` for indefinite locks, for failed-attempt
104 /// lockouts (which have no inherent expiry), and for any
105 /// non-lock error variant. Use this from UI code that wants to
106 /// render "try again at X" without distinguishing the two error
107 /// shapes: both a `prepare_factor` Suspended-account rejection
108 /// and a FIDO2 discoverable-login lockout collapse to a single
109 /// `Option<DateTime<Utc>>` lookup.
110 ///
111 /// Saves callers from deep-pattern-matching
112 /// `EntityState::Suspended(StatusDetail { until, .. })` and
113 /// handling the `AuthnError::Locked { until }` shape separately;
114 /// both legs collapse to one call.
115 pub fn lockout_expiry(&self) -> Option<chrono::DateTime<chrono::Utc>> {
116 match self {
117 AuthnError::Locked { until } => *until,
118 AuthnError::NotActive(EntityState::Suspended(detail)) => detail.until,
119 _ => None,
120 }
121 }
122}
123
124/// Compute the `Retry-After` delta-seconds from a lockout expiry
125/// timestamp relative to a reference time.
126///
127/// Returns `Some(secs)` ONLY when `until` is strictly in the future
128/// (`until > now`); `Some(0)` is never emitted because the RFC 9110
129/// `Retry-After: 0` reading is "retry immediately," which subverts the
130/// lockout semantic. `until == now` and `until < now` both collapse to
131/// `None`.
132///
133/// Extracted from [`AuthnError::into_response`] so the strict-greater
134/// boundary is unit-testable without depending on wall-clock progress
135/// between constructing the input and reading the response.
136#[cfg(feature = "default-error-response")]
137fn retry_secs_from(
138 until: chrono::DateTime<chrono::Utc>,
139 now: chrono::DateTime<chrono::Utc>,
140) -> Option<u64> {
141 let secs = (until - now).num_seconds();
142 if secs > 0 { Some(secs as u64) } else { None }
143}
144
145/// Default `IntoResponse` mapping for [`AuthnError`].
146///
147/// Gated behind the `default-error-response` feature because mapping a
148/// library error to an HTTP response is policy that belongs to the
149/// application: status code, body shape, locale, correlation IDs, and
150/// problem+json formatting are all things callers may want to override.
151/// The doc table on [`AuthnError`] documents the recommended mapping for
152/// applications that ship their own impl.
153///
154/// Enable with `axess-core = { features = ["default-error-response"] }`
155/// to get the conservative default below.
156#[cfg(feature = "default-error-response")]
157impl<E: std::error::Error + Send + Sync + 'static> AuthnError<E> {
158 /// `IntoResponse`-shaped conversion taking the reference time
159 /// explicitly. Test code with [`MockClock`](crate::testing::MockClock)
160 /// uses this to pin the `Retry-After` header against a controllable
161 /// clock; the trait impl below delegates here with `Utc::now()`
162 /// for production callers, which produces wall-clock behaviour
163 /// that's correct but non-deterministic across runs.
164 ///
165 /// Adopters who don't enable the `default-error-response` feature
166 /// can still construct their own `IntoResponse` impl on top of
167 /// this helper without forking the body/status logic.
168 pub fn into_response_at(self, now: chrono::DateTime<chrono::Utc>) -> axum::response::Response {
169 use axum::http::{HeaderValue, StatusCode, header};
170 use axum::response::IntoResponse as _;
171
172 // Surface lockout expiry to the client. Computed BEFORE
173 // the move-into-match so the `lockout_expiry()` accessor (which
174 // already handles both `Locked { until }` and the legacy
175 // `NotActive(Suspended(_))` shape) can read `&self`.
176 let until = self.lockout_expiry();
177
178 let (status, message) = match &self {
179 AuthnError::Store(_) => {
180 tracing::error!(error = %self, "authentication store error");
181 (StatusCode::INTERNAL_SERVER_ERROR, "internal error")
182 }
183 AuthnError::NoFlow => (StatusCode::CONFLICT, "no active authentication flow"),
184 AuthnError::NotActive(_) => (StatusCode::FORBIDDEN, "account not active"),
185 AuthnError::Locked { .. } => (StatusCode::LOCKED, "account locked"),
186 AuthnError::InvalidAssertion => (StatusCode::UNAUTHORIZED, "invalid assertion"),
187 AuthnError::ExternalService(_) => {
188 tracing::error!(error = %self, "external service error");
189 (StatusCode::BAD_GATEWAY, "external service unavailable")
190 }
191 AuthnError::CrossTenant => (StatusCode::FORBIDDEN, "cross-tenant operation refused"),
192 };
193
194 // Include `until_iso` in the body so JSON-only clients
195 // (mobile apps, SPAs) can render expiry without parsing
196 // `Retry-After`. Conservative: emit only when known + in the
197 // future. Past or `None` collapses to a plain `{"error": …}`
198 // body for backwards compat with existing clients.
199 let until_secs = until.and_then(|t| retry_secs_from(t, now));
200 let body = match (until, until_secs) {
201 (Some(t), Some(_)) => {
202 serde_json::json!({ "error": message, "until_iso": t.to_rfc3339() })
203 }
204 _ => serde_json::json!({ "error": message }),
205 };
206
207 let mut response = (status, axum::Json(body)).into_response();
208
209 // Emit RFC 9110 §10.2.3 `Retry-After` header (delta-seconds)
210 // for any lockout-shaped error whose expiry is in the future.
211 // Browsers and well-behaved HTTP clients honour this for
212 // automatic backoff; rendering "locked until X" UI is a
213 // body-side concern. Pulls `until` from both `Locked { until }`
214 // and the deep-nested `NotActive(Suspended(StatusDetail{until,..}))`.
215 if let Some(secs) = until_secs {
216 response
217 .headers_mut()
218 .insert(header::RETRY_AFTER, HeaderValue::from(secs));
219 }
220
221 response
222 }
223}
224
225#[cfg(feature = "default-error-response")]
226impl<E: std::error::Error + Send + Sync + 'static> axum::response::IntoResponse for AuthnError<E> {
227 /// Delegates to [`AuthnError::into_response_at`] with `chrono::Utc::now()`
228 /// as the reference time. Production-correct but non-deterministic;
229 /// tests asserting on `Retry-After` should call `into_response_at`
230 /// directly with a `MockClock`-derived timestamp.
231 fn into_response(self) -> axum::response::Response {
232 self.into_response_at(chrono::Utc::now())
233 }
234}
235
236#[cfg(all(test, feature = "default-error-response"))]
237mod into_response_tests {
238 use super::*;
239 use axum::response::IntoResponse;
240
241 /// Concrete error type so the generic impl is monomorphised.
242 #[derive(Debug, thiserror::Error)]
243 #[error("test")]
244 struct TestStoreError;
245
246 /// Every variant maps to its documented HTTP status and a
247 /// JSON `{"error": "..."}` body. Pins the whole `into_response`
248 /// against the `Default::default()` body-replacement mutation,
249 /// which would emit an empty 200 OK and silently break the
250 /// recommended mapping for every variant.
251 #[tokio::test]
252 async fn into_response_emits_documented_status_per_variant() {
253 let cases: Vec<(AuthnError<TestStoreError>, axum::http::StatusCode)> = vec![
254 (
255 AuthnError::Store(TestStoreError),
256 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
257 ),
258 (AuthnError::NoFlow, axum::http::StatusCode::CONFLICT),
259 (
260 AuthnError::NotActive(EntityState::Active),
261 axum::http::StatusCode::FORBIDDEN,
262 ),
263 (
264 AuthnError::Locked { until: None },
265 axum::http::StatusCode::LOCKED,
266 ),
267 (
268 AuthnError::InvalidAssertion,
269 axum::http::StatusCode::UNAUTHORIZED,
270 ),
271 (
272 AuthnError::ExternalService("upstream timeout".into()),
273 axum::http::StatusCode::BAD_GATEWAY,
274 ),
275 (AuthnError::CrossTenant, axum::http::StatusCode::FORBIDDEN),
276 ];
277
278 for (err, expected_status) in cases {
279 let label = format!("{err:?}");
280 let response = err.into_response();
281 assert_eq!(
282 response.status(),
283 expected_status,
284 "{label} must map to {expected_status:?}"
285 );
286 // Body must be a non-empty JSON object; distinguishes the
287 // real impl from a `Default::default()` empty 200.
288 let body = axum::body::to_bytes(response.into_body(), 4096)
289 .await
290 .expect("collect body");
291 assert!(!body.is_empty(), "{label} response body must be non-empty");
292 let parsed: serde_json::Value =
293 serde_json::from_slice(&body).expect("body must be JSON");
294 assert!(
295 parsed.get("error").and_then(|v| v.as_str()).is_some(),
296 "{label} body must contain a string `error` field, got {parsed}"
297 );
298 }
299 }
300
301 /// `Locked { until: Some(future) }` emits a `Retry-After` header
302 /// carrying delta-seconds AND surfaces `until_iso` in the JSON
303 /// body so clients have a standard way to learn the lockout window.
304 #[tokio::test]
305 async fn locked_with_future_until_emits_retry_after_and_until_iso() {
306 use axum::http::header;
307
308 let until = chrono::Utc::now() + chrono::Duration::seconds(900);
309 let err: AuthnError<TestStoreError> = AuthnError::Locked { until: Some(until) };
310 let response = err.into_response();
311
312 assert_eq!(response.status(), axum::http::StatusCode::LOCKED);
313
314 let retry_after = response
315 .headers()
316 .get(header::RETRY_AFTER)
317 .expect("Retry-After header must be present when until is in the future")
318 .to_str()
319 .unwrap()
320 .parse::<u64>()
321 .expect("Retry-After must be a numeric delta-seconds value");
322 assert!(
323 retry_after > 0 && retry_after <= 900,
324 "Retry-After should be in (0, 900] seconds, got {retry_after}"
325 );
326
327 let body = axum::body::to_bytes(response.into_body(), 4096)
328 .await
329 .expect("collect body");
330 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
331 assert_eq!(parsed["error"], "account locked");
332 let until_iso = parsed["until_iso"]
333 .as_str()
334 .expect("until_iso must be present in the body");
335 assert!(
336 until_iso.contains('T'),
337 "until_iso must be RFC 3339 (got {until_iso:?})"
338 );
339 }
340
341 /// `Locked { until: None }` is the indefinite-lock /
342 /// failed-attempt-counter case. No `Retry-After` (the client has
343 /// nothing to wait *for*; an admin reset is required) and no
344 /// `until_iso` in the body: back-compat with the previous body
345 /// shape for clients that didn't inspect the new field.
346 #[tokio::test]
347 async fn locked_with_no_until_omits_retry_after_and_until_iso() {
348 use axum::http::header;
349
350 let err: AuthnError<TestStoreError> = AuthnError::Locked { until: None };
351 let response = err.into_response();
352
353 assert_eq!(response.status(), axum::http::StatusCode::LOCKED);
354 assert!(
355 response.headers().get(header::RETRY_AFTER).is_none(),
356 "Retry-After must be absent when until is None"
357 );
358
359 let body = axum::body::to_bytes(response.into_body(), 4096)
360 .await
361 .expect("collect body");
362 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
363 assert!(parsed.get("until_iso").is_none());
364 }
365
366 /// `NotActive(Suspended(_))` carries `until` deep-nested in
367 /// `StatusDetail`. The `lockout_expiry()` accessor reaches
368 /// it; the IntoResponse impl must too, so a Suspended user gets
369 /// the same Retry-After signal as a `Locked { until }` user.
370 #[tokio::test]
371 async fn not_active_suspended_with_future_until_emits_retry_after() {
372 use crate::authn::types::StatusDetail;
373 use axum::http::header;
374
375 let until = chrono::Utc::now() + chrono::Duration::seconds(60);
376 let detail = StatusDetail {
377 reason: "test suspend".into(),
378 since: chrono::Utc::now(),
379 until: Some(until),
380 };
381 let err: AuthnError<TestStoreError> = AuthnError::NotActive(EntityState::Suspended(detail));
382 let response = err.into_response();
383
384 assert_eq!(response.status(), axum::http::StatusCode::FORBIDDEN);
385 assert!(
386 response.headers().get(header::RETRY_AFTER).is_some(),
387 "Suspended-with-until must also emit Retry-After (parity with Locked)"
388 );
389 }
390
391 /// A Suspended `until` already in the past collapses to no
392 /// `Retry-After` and no `until_iso`: there's nothing to wait for.
393 /// Avoids emitting `Retry-After: 0` which clients interpret as
394 /// "retry immediately."
395 #[tokio::test]
396 async fn locked_with_past_until_omits_retry_after() {
397 use axum::http::header;
398
399 let past = chrono::Utc::now() - chrono::Duration::seconds(60);
400 let err: AuthnError<TestStoreError> = AuthnError::Locked { until: Some(past) };
401 let response = err.into_response();
402
403 assert!(
404 response.headers().get(header::RETRY_AFTER).is_none(),
405 "a `until` in the past must not emit Retry-After"
406 );
407 let body = axum::body::to_bytes(response.into_body(), 4096)
408 .await
409 .expect("collect body");
410 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
411 assert!(
412 parsed.get("until_iso").is_none(),
413 "a `until` in the past must not emit until_iso"
414 );
415 }
416
417 /// Pin the strict-greater boundary `secs > 0` inside `retry_secs_from`.
418 /// Discriminates `> 0` against `>= 0`, `> 1`, `delete >`:
419 /// - `until == now` (delta = 0): original returns None; `>= 0` would
420 /// return `Some(0)`, which clients read as "retry immediately"
421 /// and breaks the lockout semantic.
422 /// - `until = now + 1s`: original returns `Some(1)`; pins `> 1`
423 /// which would collapse 1-second locks to None.
424 /// - `until = now - 1s`: original returns None; pins the past path
425 /// for completeness.
426 #[test]
427 fn retry_secs_from_pins_strict_greater_boundary() {
428 let now = chrono::Utc::now();
429
430 // until == now: delta = 0. Must return None; kills `>= 0` mutation
431 // (which would emit `Some(0)` and produce a `Retry-After: 0`
432 // header that clients interpret as immediate retry).
433 assert_eq!(
434 retry_secs_from(now, now),
435 None,
436 "until == now must return None (kills `> → >=` boundary mutation)"
437 );
438
439 // until = now + 1s: delta = 1. Must return Some(1).
440 assert_eq!(
441 retry_secs_from(now + chrono::Duration::seconds(1), now),
442 Some(1)
443 );
444
445 // until = now + 60s: delta = 60. Must return Some(60).
446 assert_eq!(
447 retry_secs_from(now + chrono::Duration::seconds(60), now),
448 Some(60)
449 );
450
451 // until = now - 1s: delta = -1. Must return None.
452 assert_eq!(
453 retry_secs_from(now - chrono::Duration::seconds(1), now),
454 None
455 );
456 }
457
458 /// `into_response_at` lets test code pin the `Retry-After` header
459 /// deterministically by supplying its own reference time, instead
460 /// of being subject to the wall clock `into_response` uses by
461 /// default.
462 #[tokio::test]
463 async fn into_response_at_uses_caller_supplied_reference_time() {
464 use axum::http::header;
465
466 // Anchor "now" in the past so the lockout window we compute is
467 // independent of test-execution wall clock. With `now = 2026-01-01T00:00:00Z`
468 // and `until = now + 300s`, the Retry-After must be exactly 300
469 // regardless of when the test runs.
470 let now = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
471 .unwrap()
472 .with_timezone(&chrono::Utc);
473 let until = now + chrono::Duration::seconds(300);
474
475 let err: AuthnError<TestStoreError> = AuthnError::Locked { until: Some(until) };
476 let response = err.into_response_at(now);
477
478 let retry_after = response
479 .headers()
480 .get(header::RETRY_AFTER)
481 .expect("Retry-After present")
482 .to_str()
483 .unwrap()
484 .parse::<u64>()
485 .expect("delta-seconds value");
486 assert_eq!(
487 retry_after, 300,
488 "Retry-After must equal until-now exactly when caller pins the reference time"
489 );
490 }
491
492 /// `lockout_expiry` returns the inner `Option<DateTime<Utc>>`
493 /// from `Locked { until }` and from
494 /// `NotActive(EntityState::Suspended(StatusDetail { until, .. }))`,
495 /// and `None` for any other variant. Pins the body against the
496 /// `-> None` body-replacement mutation, which would lose the
497 /// `Locked`/`Suspended` payloads and collapse every lockout to
498 /// "no expiry".
499 #[test]
500 fn lockout_expiry_extracts_from_locked_and_suspended_variants() {
501 use crate::authn::types::{EntityState, StatusDetail};
502
503 let t = chrono::DateTime::parse_from_rfc3339("2026-06-01T00:00:00Z")
504 .unwrap()
505 .with_timezone(&chrono::Utc);
506
507 // Locked { until: Some(t) } → Some(t)
508 let locked: AuthnError<TestStoreError> = AuthnError::Locked { until: Some(t) };
509 assert_eq!(
510 locked.lockout_expiry(),
511 Some(t),
512 "Locked must surface its `until` (kills `-> None`)"
513 );
514
515 // Locked { until: None } → None (indefinite lock, not the same
516 // as a missing variant).
517 let locked_indefinite: AuthnError<TestStoreError> = AuthnError::Locked { until: None };
518 assert_eq!(locked_indefinite.lockout_expiry(), None);
519
520 // NotActive(Suspended(StatusDetail { until: Some(t), .. })) → Some(t)
521 let suspended: AuthnError<TestStoreError> =
522 AuthnError::NotActive(EntityState::Suspended(StatusDetail {
523 reason: std::sync::Arc::from("test-suspended"),
524 since: t - chrono::Duration::seconds(60),
525 until: Some(t),
526 }));
527 assert_eq!(
528 suspended.lockout_expiry(),
529 Some(t),
530 "Suspended StatusDetail.until must surface (kills `-> None`)"
531 );
532
533 // Other variants → None
534 let cross: AuthnError<TestStoreError> = AuthnError::CrossTenant;
535 assert_eq!(cross.lockout_expiry(), None);
536 }
537
538 /// The trait impl delegates to `into_response_at` with `Utc::now()`,
539 /// so wall-clock callers still get correct behaviour. Pinning a
540 /// 60-minute lockout window means the header must land somewhere
541 /// in (0, 3600] regardless of execution timing; the boundary
542 /// protects against the trait impl accidentally ignoring `until`
543 /// or emitting a negative/zero value.
544 #[tokio::test]
545 async fn into_response_trait_impl_still_emits_retry_after() {
546 use axum::http::header;
547
548 let until = chrono::Utc::now() + chrono::Duration::seconds(3600);
549 let err: AuthnError<TestStoreError> = AuthnError::Locked { until: Some(until) };
550 let response = err.into_response();
551
552 let retry_after = response
553 .headers()
554 .get(header::RETRY_AFTER)
555 .expect("Retry-After present via trait impl")
556 .to_str()
557 .unwrap()
558 .parse::<u64>()
559 .expect("delta-seconds value");
560 assert!(
561 (1..=3600).contains(&retry_after),
562 "Retry-After via trait impl must land in (0, 3600], got {retry_after}"
563 );
564 }
565}