|
5 | 5 | //! - API key authentication via `X-API-Key: <key>` header |
6 | 6 | //! - Optional authentication (for public endpoints) |
7 | 7 |
|
| 8 | +use async_trait::async_trait; |
8 | 9 | use axum::{ |
| 10 | + extract::{FromRequestParts, Request, State}, |
9 | 11 | http::{header, request::Parts, StatusCode}, |
| 12 | + middleware::Next, |
10 | 13 | response::{IntoResponse, Response}, |
11 | 14 | Json, |
12 | 15 | }; |
13 | 16 | use reasondb_core::{ApiKey, KeyPrefix, Permission, Permissions}; |
14 | 17 | use serde::Serialize; |
| 18 | +use std::sync::Arc; |
| 19 | + |
| 20 | +use crate::state::AppState; |
15 | 21 |
|
16 | 22 | /// Authenticated API key (extracted from request) |
17 | 23 | #[derive(Debug, Clone)] |
@@ -174,3 +180,106 @@ pub fn extract_api_key(parts: &Parts) -> Option<String> { |
174 | 180 |
|
175 | 181 | None |
176 | 182 | } |
| 183 | + |
| 184 | +/// Axum middleware that enforces API key authentication on all routes when |
| 185 | +/// `REASONDB_AUTH_ENABLED=true`. |
| 186 | +/// |
| 187 | +/// Public routes (`/health`, `/metrics`, `/swagger-ui`, `/api-docs`) bypass |
| 188 | +/// auth so monitoring and documentation remain accessible without a key. |
| 189 | +pub async fn auth_middleware< |
| 190 | + R: reasondb_core::llm::ReasoningEngine + Clone + Send + Sync + 'static, |
| 191 | +>( |
| 192 | + State(state): State<Arc<AppState<R>>>, |
| 193 | + request: Request, |
| 194 | + next: Next, |
| 195 | +) -> Response { |
| 196 | + // Auth disabled — pass through |
| 197 | + if !state.config.auth.enabled { |
| 198 | + return next.run(request).await; |
| 199 | + } |
| 200 | + |
| 201 | + // Public paths that never require auth |
| 202 | + let path = request.uri().path().to_owned(); |
| 203 | + if path == "/health" |
| 204 | + || path == "/metrics" |
| 205 | + || path.starts_with("/swagger-ui") |
| 206 | + || path.starts_with("/api-docs") |
| 207 | + { |
| 208 | + return next.run(request).await; |
| 209 | + } |
| 210 | + |
| 211 | + // Extract API key from headers before consuming the request |
| 212 | + let raw_key = { |
| 213 | + let headers = request.headers(); |
| 214 | + // Try Authorization: Bearer <key> |
| 215 | + let from_auth = headers |
| 216 | + .get(header::AUTHORIZATION) |
| 217 | + .and_then(|v| v.to_str().ok()) |
| 218 | + .and_then(|v| v.strip_prefix("Bearer ").map(|s| s.trim().to_string())); |
| 219 | + // Fall back to X-API-Key |
| 220 | + let from_x_key = headers |
| 221 | + .get("X-API-Key") |
| 222 | + .and_then(|v| v.to_str().ok()) |
| 223 | + .map(|s| s.trim().to_string()); |
| 224 | + from_auth.or(from_x_key) |
| 225 | + }; |
| 226 | + |
| 227 | + let raw_key = match raw_key { |
| 228 | + Some(k) => k, |
| 229 | + None => return AuthError::MissingKey.into_response(), |
| 230 | + }; |
| 231 | + |
| 232 | + // Validate against master key |
| 233 | + if let Some(ref master_key) = state.config.auth.master_key { |
| 234 | + if raw_key == *master_key { |
| 235 | + return next.run(request).await; |
| 236 | + } |
| 237 | + } |
| 238 | + |
| 239 | + // Validate against stored API keys |
| 240 | + match state.api_key_store.authenticate(&raw_key) { |
| 241 | + Ok(Some(key)) if key.is_active => next.run(request).await, |
| 242 | + Ok(Some(_)) => AuthError::RevokedKey.into_response(), |
| 243 | + Ok(None) => AuthError::InvalidKey.into_response(), |
| 244 | + Err(e) => AuthError::Internal(e.to_string()).into_response(), |
| 245 | + } |
| 246 | +} |
| 247 | + |
| 248 | +/// `FromRequestParts` impl so handlers can optionally extract an |
| 249 | +/// `AuthenticatedKey` without the middleware (used by auth management routes). |
| 250 | +#[async_trait] |
| 251 | +impl<R> FromRequestParts<Arc<AppState<R>>> for AuthenticatedKey |
| 252 | +where |
| 253 | + R: reasondb_core::llm::ReasoningEngine + Clone + Send + Sync + 'static, |
| 254 | +{ |
| 255 | + type Rejection = AuthError; |
| 256 | + |
| 257 | + async fn from_request_parts( |
| 258 | + parts: &mut Parts, |
| 259 | + state: &Arc<AppState<R>>, |
| 260 | + ) -> Result<Self, Self::Rejection> { |
| 261 | + if !state.config.auth.enabled { |
| 262 | + return Ok(AuthenticatedKey::anonymous()); |
| 263 | + } |
| 264 | + |
| 265 | + let raw_key = extract_api_key(parts).ok_or(AuthError::MissingKey)?; |
| 266 | + |
| 267 | + if let Some(ref master_key) = state.config.auth.master_key { |
| 268 | + if raw_key == *master_key { |
| 269 | + return Ok(AuthenticatedKey::master()); |
| 270 | + } |
| 271 | + } |
| 272 | + |
| 273 | + let key = state |
| 274 | + .api_key_store |
| 275 | + .authenticate(&raw_key) |
| 276 | + .map_err(|e| AuthError::Internal(e.to_string()))? |
| 277 | + .ok_or(AuthError::InvalidKey)?; |
| 278 | + |
| 279 | + if !key.is_active { |
| 280 | + return Err(AuthError::RevokedKey); |
| 281 | + } |
| 282 | + |
| 283 | + Ok(AuthenticatedKey { key }) |
| 284 | + } |
| 285 | +} |
0 commit comments