riley_cms_api/middleware.rs
1//! Middleware for riley-cms-api
2//!
3//! Authentication middleware for protected endpoints.
4
5use axum::{
6 extract::{Request, State},
7 http::header,
8 middleware::Next,
9 response::Response,
10};
11use sha2::{Digest, Sha256};
12use std::sync::Arc;
13use subtle::ConstantTimeEq;
14
15use crate::AppState;
16
17/// Authentication status for the current request.
18///
19/// This is inserted into request extensions by the auth middleware
20/// and can be extracted by handlers to make authorization decisions.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum AuthStatus {
23 /// Unauthenticated public request
24 Public,
25 /// Authenticated admin request (valid Bearer token provided)
26 Admin,
27}
28
29/// Authentication middleware that validates Bearer tokens.
30///
31/// This middleware runs on every request and:
32/// 1. Checks for an `Authorization: Bearer <token>` header
33/// 2. Validates the token against the configured `auth.api_token`
34/// 3. Sets `AuthStatus::Admin` if valid, `AuthStatus::Public` otherwise
35/// 4. Inserts the status into request extensions for handlers to check
36pub async fn auth_middleware(
37 State(state): State<Arc<AppState>>,
38 mut request: Request,
39 next: Next,
40) -> Response {
41 let mut auth_status = AuthStatus::Public;
42
43 // Check for configured API token
44 if let Some(ref auth_config) = state.config.auth
45 && let Some(ref token_config) = auth_config.api_token
46 {
47 // Resolve the token (supports "env:VAR_NAME" syntax)
48 match token_config.resolve() {
49 Ok(expected_token) => {
50 if expected_token.is_empty() {
51 tracing::warn!("API token resolves to empty string. Admin auth disabled.");
52 } else {
53 // Check Authorization header for Bearer token
54 if let Some(auth_header) = request.headers().get(header::AUTHORIZATION)
55 && let Ok(auth_str) = auth_header.to_str()
56 && let Some(provided_token) = auth_str.strip_prefix("Bearer ")
57 {
58 // Hash both tokens before comparing to prevent
59 // leaking token length via timing side-channel.
60 // SHA-256 produces fixed 32-byte hashes regardless
61 // of input length.
62 let provided_hash = Sha256::digest(provided_token.trim().as_bytes());
63 let expected_hash = Sha256::digest(expected_token.as_bytes());
64 if provided_hash.ct_eq(&expected_hash).into() {
65 auth_status = AuthStatus::Admin;
66 }
67 }
68 }
69 }
70 Err(e) => {
71 tracing::warn!("Failed to resolve API token: {}. Admin auth disabled.", e);
72 }
73 }
74 }
75
76 // Insert status into extensions so handlers can read it
77 request.extensions_mut().insert(auth_status);
78
79 next.run(request).await
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85
86 #[test]
87 fn test_auth_status_equality() {
88 assert_eq!(AuthStatus::Public, AuthStatus::Public);
89 assert_eq!(AuthStatus::Admin, AuthStatus::Admin);
90 assert_ne!(AuthStatus::Public, AuthStatus::Admin);
91 }
92}