Skip to main content

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}