velesdb_server/auth.rs
1//! API key authentication middleware.
2//!
3//! When `api_keys` is non-empty, all requests except those to public paths
4//! (e.g. `GET /health`) must include a valid `Authorization: Bearer <key>` header.
5//! When `api_keys` is empty, authentication is disabled (local dev mode).
6
7use axum::{
8 body::Body,
9 extract::Request,
10 http::{header, StatusCode},
11 middleware::Next,
12 response::{IntoResponse, Response},
13 Json,
14};
15use std::sync::Arc;
16
17/// Constant-time byte comparison to prevent timing side-channel attacks.
18///
19/// Compares two byte slices in constant time relative to the length of `a`.
20/// Returns `true` only when both slices have equal length and identical contents.
21/// Uses XOR-and-fold so that the comparison does not short-circuit on the first
22/// differing byte.
23fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
24 if a.len() != b.len() {
25 // Length mismatch leaks the length difference, but not the key contents.
26 // This is acceptable: an attacker already controls `b` (the submitted
27 // token) and can trivially discover the expected length via other means
28 // (e.g. documentation). The critical property is that *content* is never
29 // leaked through timing.
30 return false;
31 }
32
33 let mut acc: u8 = 0;
34 for (x, y) in a.iter().zip(b.iter()) {
35 acc |= x ^ y;
36 }
37 acc == 0
38}
39
40/// Checks whether `token` matches any configured API key in constant time.
41///
42/// Iterates over **all** keys regardless of early matches to avoid leaking
43/// which key (if any) was correct through timing differences.
44fn any_key_matches(keys: &[String], token: &str) -> bool {
45 let token_bytes = token.as_bytes();
46 let mut matched = false;
47 for key in keys {
48 if constant_time_eq(key.as_bytes(), token_bytes) {
49 matched = true;
50 }
51 // Do NOT early-return — iterate all keys unconditionally.
52 }
53 matched
54}
55
56/// Shared authentication state injected into the middleware.
57#[derive(Debug, Clone)]
58pub struct AuthState {
59 /// Allowed API keys. Empty means auth is disabled.
60 pub api_keys: Arc<Vec<String>>,
61}
62
63impl AuthState {
64 /// Create a new `AuthState` from a list of API keys.
65 pub fn new(api_keys: Vec<String>) -> Self {
66 Self {
67 api_keys: Arc::new(api_keys),
68 }
69 }
70
71 /// Returns `true` when authentication is enabled.
72 pub fn auth_enabled(&self) -> bool {
73 !self.api_keys.is_empty()
74 }
75}
76
77/// Paths that bypass authentication (both legacy and `/v1/` versioned).
78///
79/// `/health` and `/ready` are the only genuinely public endpoints — they
80/// expose no internal state beyond liveness booleans and are needed by
81/// container orchestrators that cannot carry authentication headers.
82///
83/// `/metrics` is **not** in this list. The Prometheus metrics endpoint
84/// returns detailed operational data (collection counts, cache hit
85/// rates, query latencies, per-collection sizes, WAL depths) that
86/// constitutes an information leak when the surrounding REST API is
87/// protected by API keys. The fix for F-02 moves `/metrics` behind the
88/// same API key gate as the rest of the REST surface: scrapers must
89/// present `Authorization: Bearer <key>` like any other client.
90///
91/// Operators who need a dedicated scraping credential can provision a
92/// distinct API key for the Prometheus scraper and rotate it
93/// independently.
94fn is_public_path(path: &str) -> bool {
95 matches!(path, "/health" | "/ready" | "/v1/health" | "/v1/ready")
96}
97
98/// Extract the Bearer token from the Authorization header value.
99fn extract_bearer_token(header_value: &str) -> Option<&str> {
100 let trimmed = header_value.trim();
101 if trimmed.len() > 7 && trimmed[..7].eq_ignore_ascii_case("bearer ") {
102 let token = trimmed[7..].trim();
103 if token.is_empty() {
104 None
105 } else {
106 Some(token)
107 }
108 } else {
109 None
110 }
111}
112
113/// Axum middleware function for API key authentication.
114///
115/// Use with `axum::middleware::from_fn_with_state`.
116pub async fn auth_middleware(
117 axum::extract::State(state): axum::extract::State<AuthState>,
118 request: Request<Body>,
119 next: Next,
120) -> Response {
121 // Skip auth if disabled (no keys configured)
122 if !state.auth_enabled() {
123 return next.run(request).await;
124 }
125
126 // Skip auth for public paths
127 if is_public_path(request.uri().path()) {
128 return next.run(request).await;
129 }
130
131 // Extract and validate Bearer token
132 let auth_header = request
133 .headers()
134 .get(header::AUTHORIZATION)
135 .and_then(|v| v.to_str().ok());
136
137 match auth_header {
138 Some(value) => match extract_bearer_token(value) {
139 Some(token) if any_key_matches(&state.api_keys, token) => next.run(request).await,
140 Some(_) => unauthorized_response("invalid API key"),
141 None => {
142 unauthorized_response("invalid Authorization header format, expected: Bearer <key>")
143 }
144 },
145 None => unauthorized_response("missing Authorization header"),
146 }
147}
148
149/// Build a 401 Unauthorized JSON response.
150fn unauthorized_response(message: &str) -> Response {
151 (
152 StatusCode::UNAUTHORIZED,
153 Json(serde_json::json!({
154 "error": "Unauthorized",
155 "message": message
156 })),
157 )
158 .into_response()
159}
160
161// ============================================================================
162// Tests
163// ============================================================================
164
165#[cfg(test)]
166#[path = "auth_tests.rs"]
167mod tests;