use crate::authn::types::EntityState;
#[derive(Debug, thiserror::Error)]
pub enum AuthnError<E: std::error::Error + Send + Sync + 'static> {
#[error("store error: {0}")]
Store(#[source] E),
#[error("no active authentication flow")]
NoFlow,
#[error("account not active: {0:?}")]
NotActive(EntityState),
#[error("account locked")]
Locked {
until: Option<chrono::DateTime<chrono::Utc>>,
},
#[error("invalid FIDO2 assertion")]
InvalidAssertion,
#[error("external service error: {0}")]
ExternalService(String),
#[error("cross-tenant operation refused")]
CrossTenant,
}
impl<E: std::error::Error + Send + Sync + 'static> AuthnError<E> {
pub fn lockout_expiry(&self) -> Option<chrono::DateTime<chrono::Utc>> {
match self {
AuthnError::Locked { until } => *until,
AuthnError::NotActive(EntityState::Suspended(detail)) => detail.until,
_ => None,
}
}
}
#[cfg(feature = "default-error-response")]
fn retry_secs_from(
until: chrono::DateTime<chrono::Utc>,
now: chrono::DateTime<chrono::Utc>,
) -> Option<u64> {
let secs = (until - now).num_seconds();
if secs > 0 { Some(secs as u64) } else { None }
}
#[cfg(feature = "default-error-response")]
impl<E: std::error::Error + Send + Sync + 'static> AuthnError<E> {
pub fn into_response_at(self, now: chrono::DateTime<chrono::Utc>) -> axum::response::Response {
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::IntoResponse as _;
let until = self.lockout_expiry();
let (status, message) = match &self {
AuthnError::Store(_) => {
tracing::error!(error = %self, "authentication store error");
(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
}
AuthnError::NoFlow => (StatusCode::CONFLICT, "no active authentication flow"),
AuthnError::NotActive(_) => (StatusCode::FORBIDDEN, "account not active"),
AuthnError::Locked { .. } => (StatusCode::LOCKED, "account locked"),
AuthnError::InvalidAssertion => (StatusCode::UNAUTHORIZED, "invalid assertion"),
AuthnError::ExternalService(_) => {
tracing::error!(error = %self, "external service error");
(StatusCode::BAD_GATEWAY, "external service unavailable")
}
AuthnError::CrossTenant => (StatusCode::FORBIDDEN, "cross-tenant operation refused"),
};
let until_secs = until.and_then(|t| retry_secs_from(t, now));
let body = match (until, until_secs) {
(Some(t), Some(_)) => {
serde_json::json!({ "error": message, "until_iso": t.to_rfc3339() })
}
_ => serde_json::json!({ "error": message }),
};
let mut response = (status, axum::Json(body)).into_response();
if let Some(secs) = until_secs {
response
.headers_mut()
.insert(header::RETRY_AFTER, HeaderValue::from(secs));
}
response
}
}
#[cfg(feature = "default-error-response")]
impl<E: std::error::Error + Send + Sync + 'static> axum::response::IntoResponse for AuthnError<E> {
fn into_response(self) -> axum::response::Response {
self.into_response_at(chrono::Utc::now())
}
}
#[cfg(all(test, feature = "default-error-response"))]
mod into_response_tests {
use super::*;
use axum::response::IntoResponse;
#[derive(Debug, thiserror::Error)]
#[error("test")]
struct TestStoreError;
#[tokio::test]
async fn into_response_emits_documented_status_per_variant() {
let cases: Vec<(AuthnError<TestStoreError>, axum::http::StatusCode)> = vec![
(
AuthnError::Store(TestStoreError),
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
),
(AuthnError::NoFlow, axum::http::StatusCode::CONFLICT),
(
AuthnError::NotActive(EntityState::Active),
axum::http::StatusCode::FORBIDDEN,
),
(
AuthnError::Locked { until: None },
axum::http::StatusCode::LOCKED,
),
(
AuthnError::InvalidAssertion,
axum::http::StatusCode::UNAUTHORIZED,
),
(
AuthnError::ExternalService("upstream timeout".into()),
axum::http::StatusCode::BAD_GATEWAY,
),
(AuthnError::CrossTenant, axum::http::StatusCode::FORBIDDEN),
];
for (err, expected_status) in cases {
let label = format!("{err:?}");
let response = err.into_response();
assert_eq!(
response.status(),
expected_status,
"{label} must map to {expected_status:?}"
);
let body = axum::body::to_bytes(response.into_body(), 4096)
.await
.expect("collect body");
assert!(!body.is_empty(), "{label} response body must be non-empty");
let parsed: serde_json::Value =
serde_json::from_slice(&body).expect("body must be JSON");
assert!(
parsed.get("error").and_then(|v| v.as_str()).is_some(),
"{label} body must contain a string `error` field, got {parsed}"
);
}
}
#[tokio::test]
async fn locked_with_future_until_emits_retry_after_and_until_iso() {
use axum::http::header;
let until = chrono::Utc::now() + chrono::Duration::seconds(900);
let err: AuthnError<TestStoreError> = AuthnError::Locked { until: Some(until) };
let response = err.into_response();
assert_eq!(response.status(), axum::http::StatusCode::LOCKED);
let retry_after = response
.headers()
.get(header::RETRY_AFTER)
.expect("Retry-After header must be present when until is in the future")
.to_str()
.unwrap()
.parse::<u64>()
.expect("Retry-After must be a numeric delta-seconds value");
assert!(
retry_after > 0 && retry_after <= 900,
"Retry-After should be in (0, 900] seconds, got {retry_after}"
);
let body = axum::body::to_bytes(response.into_body(), 4096)
.await
.expect("collect body");
let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(parsed["error"], "account locked");
let until_iso = parsed["until_iso"]
.as_str()
.expect("until_iso must be present in the body");
assert!(
until_iso.contains('T'),
"until_iso must be RFC 3339 (got {until_iso:?})"
);
}
#[tokio::test]
async fn locked_with_no_until_omits_retry_after_and_until_iso() {
use axum::http::header;
let err: AuthnError<TestStoreError> = AuthnError::Locked { until: None };
let response = err.into_response();
assert_eq!(response.status(), axum::http::StatusCode::LOCKED);
assert!(
response.headers().get(header::RETRY_AFTER).is_none(),
"Retry-After must be absent when until is None"
);
let body = axum::body::to_bytes(response.into_body(), 4096)
.await
.expect("collect body");
let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(parsed.get("until_iso").is_none());
}
#[tokio::test]
async fn not_active_suspended_with_future_until_emits_retry_after() {
use crate::authn::types::StatusDetail;
use axum::http::header;
let until = chrono::Utc::now() + chrono::Duration::seconds(60);
let detail = StatusDetail {
reason: "test suspend".into(),
since: chrono::Utc::now(),
until: Some(until),
};
let err: AuthnError<TestStoreError> = AuthnError::NotActive(EntityState::Suspended(detail));
let response = err.into_response();
assert_eq!(response.status(), axum::http::StatusCode::FORBIDDEN);
assert!(
response.headers().get(header::RETRY_AFTER).is_some(),
"Suspended-with-until must also emit Retry-After (parity with Locked)"
);
}
#[tokio::test]
async fn locked_with_past_until_omits_retry_after() {
use axum::http::header;
let past = chrono::Utc::now() - chrono::Duration::seconds(60);
let err: AuthnError<TestStoreError> = AuthnError::Locked { until: Some(past) };
let response = err.into_response();
assert!(
response.headers().get(header::RETRY_AFTER).is_none(),
"a `until` in the past must not emit Retry-After"
);
let body = axum::body::to_bytes(response.into_body(), 4096)
.await
.expect("collect body");
let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
parsed.get("until_iso").is_none(),
"a `until` in the past must not emit until_iso"
);
}
#[test]
fn retry_secs_from_pins_strict_greater_boundary() {
let now = chrono::Utc::now();
assert_eq!(
retry_secs_from(now, now),
None,
"until == now must return None (kills `> → >=` boundary mutation)"
);
assert_eq!(
retry_secs_from(now + chrono::Duration::seconds(1), now),
Some(1)
);
assert_eq!(
retry_secs_from(now + chrono::Duration::seconds(60), now),
Some(60)
);
assert_eq!(
retry_secs_from(now - chrono::Duration::seconds(1), now),
None
);
}
#[tokio::test]
async fn into_response_at_uses_caller_supplied_reference_time() {
use axum::http::header;
let now = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc);
let until = now + chrono::Duration::seconds(300);
let err: AuthnError<TestStoreError> = AuthnError::Locked { until: Some(until) };
let response = err.into_response_at(now);
let retry_after = response
.headers()
.get(header::RETRY_AFTER)
.expect("Retry-After present")
.to_str()
.unwrap()
.parse::<u64>()
.expect("delta-seconds value");
assert_eq!(
retry_after, 300,
"Retry-After must equal until-now exactly when caller pins the reference time"
);
}
#[test]
fn lockout_expiry_extracts_from_locked_and_suspended_variants() {
use crate::authn::types::{EntityState, StatusDetail};
let t = chrono::DateTime::parse_from_rfc3339("2026-06-01T00:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc);
let locked: AuthnError<TestStoreError> = AuthnError::Locked { until: Some(t) };
assert_eq!(
locked.lockout_expiry(),
Some(t),
"Locked must surface its `until` (kills `-> None`)"
);
let locked_indefinite: AuthnError<TestStoreError> = AuthnError::Locked { until: None };
assert_eq!(locked_indefinite.lockout_expiry(), None);
let suspended: AuthnError<TestStoreError> =
AuthnError::NotActive(EntityState::Suspended(StatusDetail {
reason: std::sync::Arc::from("test-suspended"),
since: t - chrono::Duration::seconds(60),
until: Some(t),
}));
assert_eq!(
suspended.lockout_expiry(),
Some(t),
"Suspended StatusDetail.until must surface (kills `-> None`)"
);
let cross: AuthnError<TestStoreError> = AuthnError::CrossTenant;
assert_eq!(cross.lockout_expiry(), None);
}
#[tokio::test]
async fn into_response_trait_impl_still_emits_retry_after() {
use axum::http::header;
let until = chrono::Utc::now() + chrono::Duration::seconds(3600);
let err: AuthnError<TestStoreError> = AuthnError::Locked { until: Some(until) };
let response = err.into_response();
let retry_after = response
.headers()
.get(header::RETRY_AFTER)
.expect("Retry-After present via trait impl")
.to_str()
.unwrap()
.parse::<u64>()
.expect("delta-seconds value");
assert!(
(1..=3600).contains(&retry_after),
"Retry-After via trait impl must land in (0, 3600], got {retry_after}"
);
}
}