use std::time::Duration;
use axum::extract::{MatchedPath, Request, State};
use axum::http::Method;
use axum::middleware::Next;
use axum::response::Response;
use dashmap::DashMap;
use sha2::{Digest, Sha256};
use tokio::time::Instant;
use crate::config::AdminAuthConfig;
use crate::config::constant_time_eq;
use crate::errors::OrionError;
use crate::metrics;
use crate::server::state::AppState;
const FAILURES_BEFORE_LOCKOUT: u32 = 5;
const LOCKOUT_BASE: Duration = Duration::from_millis(500);
const LOCKOUT_MAX: Duration = Duration::from_secs(30);
const FAILURE_TTL: Duration = Duration::from_secs(300);
const EVICT_THRESHOLD: usize = 10_000;
#[derive(Debug, Clone, Copy)]
struct FailureRecord {
consecutive: u32,
locked_until: Option<Instant>,
last_seen: Instant,
}
#[derive(Debug, Default)]
pub struct FailedAuthTracker {
clients: DashMap<String, FailureRecord>,
}
impl FailedAuthTracker {
pub fn locked_for(&self, client: &str) -> Option<Duration> {
let rec = self.clients.get(client)?;
let until = rec.locked_until?;
until.checked_duration_since(Instant::now())
}
pub fn record_failure(&self, client: &str) -> Option<Duration> {
let now = Instant::now();
if self.clients.len() >= EVICT_THRESHOLD {
self.evict_stale();
}
let mut entry = self
.clients
.entry(client.to_string())
.or_insert(FailureRecord {
consecutive: 0,
locked_until: None,
last_seen: now,
});
if now.duration_since(entry.last_seen) > FAILURE_TTL {
entry.consecutive = 0;
entry.locked_until = None;
}
entry.consecutive = entry.consecutive.saturating_add(1);
entry.last_seen = now;
if entry.consecutive < FAILURES_BEFORE_LOCKOUT {
return None;
}
let steps = entry.consecutive - FAILURES_BEFORE_LOCKOUT;
let backoff = LOCKOUT_BASE
.checked_mul(1u32.checked_shl(steps.min(16)).unwrap_or(u32::MAX))
.unwrap_or(LOCKOUT_MAX)
.min(LOCKOUT_MAX);
entry.locked_until = Some(now + backoff);
Some(backoff)
}
pub fn record_success(&self, client: &str) {
self.clients.remove(client);
}
fn evict_stale(&self) {
let now = Instant::now();
self.clients
.retain(|_, rec| now.duration_since(rec.last_seen) <= FAILURE_TTL);
}
}
const KEY_ID_DOMAIN: &[u8] = b"orion:audit:key-id:v1";
const KEY_ID_BYTES: usize = 8;
#[derive(Debug, Clone)]
pub struct AdminPrincipal {
pub key_id: String,
}
impl AdminPrincipal {
fn from_digest(digest: &[u8; 32]) -> Self {
let mut hasher = Sha256::new();
hasher.update(KEY_ID_DOMAIN);
hasher.update(digest);
let derived: [u8; 32] = hasher.finalize().into();
Self {
key_id: format!("key-{}", hex::encode(&derived[..KEY_ID_BYTES])),
}
}
}
pub(crate) fn is_guarded_path(path: &str, metrics_on_this_listener: bool) -> bool {
if path == SINGLE_TRACE_PATH {
return false;
}
if path == METRICS_PATH {
return metrics_on_this_listener;
}
path.starts_with("/api/v1/admin")
}
pub(crate) const SINGLE_TRACE_PATH: &str = "/api/v1/admin/traces/{id}";
pub(crate) const METRICS_PATH: &str = "/metrics";
pub async fn admin_auth_middleware(
State(state): State<AppState>,
matched_path: Option<MatchedPath>,
mut req: Request,
next: Next,
) -> Result<Response, OrionError> {
if !state.config.admin_auth.enabled {
return Ok(next.run(req).await);
}
let path = matched_path
.as_ref()
.map(|m| m.as_str())
.unwrap_or(req.uri().path());
if !is_guarded_path(path, state.config.metrics.on_main_listener()) {
return Ok(next.run(req).await);
}
let client = crate::server::rate_limit::extract_client_ip(&req, state.trusted_proxies());
if let Some(remaining) = state.admin_auth_failures.locked_for(&client) {
metrics::record_admin_auth_failure("locked_out");
tracing::warn!(
client = %client,
path = %req.uri().path(),
remaining_ms = remaining.as_millis() as u64,
"Admin API authentication refused: client is in failed-auth backoff"
);
return Err(OrionError::Unauthorized("Invalid API key".into()));
}
let token = match extract_api_key(req.headers(), &state.config.admin_auth) {
Ok(t) => t,
Err(e) => {
metrics::record_admin_auth_failure("missing_or_malformed");
state.admin_auth_failures.record_failure(&client);
return Err(e);
}
};
let presented: [u8; 32] = Sha256::digest(token.as_bytes()).into();
let matched_key = state
.config
.admin_auth
.admin_keys()
.into_iter()
.find(|key| constant_time_eq(&presented, &key.digest));
let Some(matched_key) = matched_key else {
metrics::record_admin_auth_failure("invalid_key");
let lockout = state.admin_auth_failures.record_failure(&client);
tracing::warn!(
client = %client,
path = %req.uri().path(),
lockout_ms = lockout.map(|d| d.as_millis() as u64),
"Admin API authentication failed: invalid API key"
);
return Err(OrionError::Unauthorized("Invalid API key".into()));
};
state.admin_auth_failures.record_success(&client);
if matched_key.read_only && !matches!(*req.method(), Method::GET | Method::HEAD) {
let principal = AdminPrincipal::from_digest(&matched_key.digest);
metrics::record_admin_auth_failure("read_only_write");
tracing::warn!(
key_id = %principal.key_id,
method = %req.method(),
path = %req.uri().path(),
"Admin API request refused: read-only key attempted a mutation"
);
return Err(OrionError::Forbidden(
"This API key is read-only; mutating admin requests need a full-access key".into(),
));
}
req.extensions_mut()
.insert(AdminPrincipal::from_digest(&matched_key.digest));
Ok(next.run(req).await)
}
pub(crate) fn headers_present_valid_key(
headers: &axum::http::HeaderMap,
config: &AdminAuthConfig,
) -> bool {
let Ok(token) = extract_api_key(headers, config) else {
return false;
};
let presented: [u8; 32] = Sha256::digest(token.as_bytes()).into();
config
.admin_keys()
.into_iter()
.any(|key| constant_time_eq(&presented, &key.digest))
}
pub(crate) fn hash_trace_token(token: &str) -> String {
hex::encode(Sha256::digest(token.as_bytes()))
}
pub(crate) fn trace_token_matches(presented: &str, stored_hash: &str) -> bool {
let presented: [u8; 32] = Sha256::digest(presented.as_bytes()).into();
let Ok(decoded) = hex::decode(stored_hash) else {
return false;
};
let Ok(stored) = <[u8; 32]>::try_from(decoded) else {
return false;
};
constant_time_eq(&presented, &stored)
}
fn extract_api_key(
headers: &axum::http::HeaderMap,
config: &AdminAuthConfig,
) -> Result<String, OrionError> {
let header_value = headers
.get(&config.header)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| OrionError::Unauthorized(format!("Missing {} header", config.header)))?;
if config.header.eq_ignore_ascii_case("authorization") {
header_value
.strip_prefix("Bearer ")
.or_else(|| header_value.strip_prefix("bearer "))
.map(|t| t.to_string())
.ok_or_else(|| {
OrionError::Unauthorized(
"Authorization header must use 'Bearer <token>' format".into(),
)
})
} else {
Ok(header_value.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn digest(s: &str) -> [u8; 32] {
Sha256::digest(s.as_bytes()).into()
}
#[test]
fn test_digest_compare_equal() {
assert!(constant_time_eq(&digest("secret"), &digest("secret")));
}
#[test]
fn test_digest_compare_unequal() {
assert!(!constant_time_eq(&digest("secret"), &digest("wrong!")));
}
#[test]
fn test_digest_compare_length_differs() {
assert!(!constant_time_eq(
&digest("short"),
&digest("a-much-longer-candidate-key")
));
}
#[test]
fn test_digest_compare_empty_token() {
assert!(constant_time_eq(&digest(""), &digest("")));
assert!(!constant_time_eq(&digest(""), &digest("secret")));
}
#[test]
fn test_sha256_config_form_matches_presented_plaintext() {
let config = AdminAuthConfig {
enabled: true,
api_keys: vec![format!("sha256:{}", hex::encode(digest("the-real-key")))],
read_only_api_keys: Vec::new(),
header: "Authorization".to_string(),
};
let presented = digest("the-real-key");
let keys = config.admin_keys();
assert!(keys.iter().any(|k| constant_time_eq(&presented, &k.digest)));
let wrong = digest("not-the-key");
assert!(!keys.iter().any(|k| constant_time_eq(&wrong, &k.digest)));
}
#[test]
fn metrics_is_guarded_only_where_it_is_registered() {
assert!(is_guarded_path(METRICS_PATH, true));
assert!(!is_guarded_path(METRICS_PATH, false));
}
#[test]
fn the_admin_plane_is_guarded_regardless_of_the_metrics_listener() {
for on_main in [true, false] {
assert!(is_guarded_path("/api/v1/admin/workflows", on_main));
assert!(is_guarded_path("/api/v1/admin/traces", on_main));
assert!(!is_guarded_path(SINGLE_TRACE_PATH, on_main));
assert!(!is_guarded_path("/api/v1/data/{*path}", on_main));
assert!(!is_guarded_path("/health", on_main));
}
}
#[test]
fn key_id_never_contains_the_key() {
let principal = AdminPrincipal::from_digest(&digest("orion_sk_the-real-key"));
assert!(principal.key_id.starts_with("key-"));
assert!(!principal.key_id.contains("the-real"));
assert!(!principal.key_id.contains("orion_sk"));
assert_eq!(
principal.key_id.len(),
"key-".len() + KEY_ID_BYTES * 2,
"id width is part of the documented derivation"
);
}
#[test]
fn key_id_distinguishes_keys_sharing_a_prefix() {
let a = AdminPrincipal::from_digest(&digest("orion_sk_aaaaaaaaaaaa"));
let b = AdminPrincipal::from_digest(&digest("orion_sk_bbbbbbbbbbbb"));
assert_ne!(
a.key_id, b.key_id,
"two keys sharing a 9-character prefix must not share an audit identity"
);
}
#[test]
fn key_id_is_stable_across_the_two_config_forms() {
let plaintext = AdminAuthConfig {
enabled: true,
api_keys: vec!["the-real-key".to_string()],
read_only_api_keys: Vec::new(),
header: "Authorization".to_string(),
};
let hashed = AdminAuthConfig {
api_keys: vec![format!("sha256:{}", hex::encode(digest("the-real-key")))],
read_only_api_keys: Vec::new(),
..plaintext.clone()
};
let id_of = |c: &AdminAuthConfig| {
AdminPrincipal::from_digest(&c.admin_keys().first().expect("one key").digest).key_id
};
assert_eq!(id_of(&plaintext), id_of(&hashed));
}
#[test]
fn key_id_is_not_the_stored_digest() {
let d = digest("the-real-key");
let principal = AdminPrincipal::from_digest(&d);
assert!(!principal.key_id.contains(&hex::encode(&d[..KEY_ID_BYTES])));
}
#[tokio::test(start_paused = true)]
async fn backoff_starts_only_after_a_grace_period() {
let t = FailedAuthTracker::default();
for _ in 1..FAILURES_BEFORE_LOCKOUT {
assert!(
t.record_failure("1.2.3.4").is_none(),
"a few typos must not lock anyone out"
);
assert!(t.locked_for("1.2.3.4").is_none());
}
let first = t.record_failure("1.2.3.4").expect("lockout starts");
assert_eq!(first, LOCKOUT_BASE);
assert!(t.locked_for("1.2.3.4").is_some());
}
#[tokio::test(start_paused = true)]
async fn backoff_doubles_and_is_capped() {
let t = FailedAuthTracker::default();
let mut last = Duration::ZERO;
for _ in 0..40 {
if let Some(d) = t.record_failure("1.2.3.4") {
assert!(d >= last, "backoff must not shrink");
last = d;
}
}
assert_eq!(last, LOCKOUT_MAX, "backoff must saturate, not overflow");
}
#[tokio::test(start_paused = true)]
async fn lockout_expires_on_the_monotonic_clock() {
let t = FailedAuthTracker::default();
for _ in 0..FAILURES_BEFORE_LOCKOUT {
t.record_failure("1.2.3.4");
}
assert!(t.locked_for("1.2.3.4").is_some());
tokio::time::advance(LOCKOUT_BASE + Duration::from_millis(1)).await;
assert!(
t.locked_for("1.2.3.4").is_none(),
"the lockout must lift once it elapses"
);
}
#[tokio::test(start_paused = true)]
async fn success_clears_the_record_and_clients_are_independent() {
let t = FailedAuthTracker::default();
for _ in 0..FAILURES_BEFORE_LOCKOUT {
t.record_failure("1.2.3.4");
}
assert!(t.locked_for("1.2.3.4").is_some());
assert!(t.locked_for("5.6.7.8").is_none());
t.record_success("1.2.3.4");
assert!(t.locked_for("1.2.3.4").is_none());
assert!(
t.record_failure("1.2.3.4").is_none(),
"the counter must restart after a success"
);
}
}