Skip to main content

reinhardt_admin/server/
cookie_auth.rs

1//! Admin Cookie-based JWT Authentication Middleware
2//!
3//! Extracts JWT tokens from the `reinhardt_admin_token` HTTP-Only cookie
4//! and populates the request's [`AuthState`](reinhardt_http::AuthState) extension. This replaces the
5//! `Authorization: Bearer` header approach for the admin panel, providing
6//! XSS protection since JavaScript cannot access HTTP-Only cookies.
7//!
8//! # Extraction Order
9//!
10//! 1. `reinhardt_admin_token` cookie (primary — set by admin login)
11//! 2. `Authorization: Bearer` header (fallback — for API testing / migration)
12//!
13//! # Security Properties
14//!
15//! - The cookie is `HttpOnly`, so XSS attacks cannot steal the token.
16//! - `SameSite=Strict` prevents cross-origin cookie sending (CSRF protection).
17//! - `Path=/admin` limits the cookie scope to admin routes.
18//! - `Secure` flag ensures HTTPS-only transmission in production.
19
20use async_trait::async_trait;
21use reinhardt_auth::JwtAuth;
22use reinhardt_http::{
23	AuthState, Handler, IsActive, IsAdmin, IsAuthenticated, Middleware, Request, Response, Result,
24};
25use std::sync::Arc;
26
27use super::security::extract_admin_auth_cookie;
28
29/// Admin-specific JWT authentication middleware.
30///
31/// Unlike the general `JwtAuthMiddleware` from reinhardt-middleware, this
32/// middleware extracts JWT tokens from the `reinhardt_admin_token` cookie
33/// first, falling back to the `Authorization: Bearer` header.
34///
35/// # Example
36///
37/// ```ignore
38/// use reinhardt_admin::server::cookie_auth::AdminCookieAuthMiddleware;
39///
40/// let middleware = AdminCookieAuthMiddleware::new(b"jwt-secret");
41/// let router = ServerRouter::new()
42///     .with_namespace("admin")
43///     .with_middleware(middleware);
44/// ```
45pub struct AdminCookieAuthMiddleware {
46	jwt_auth: JwtAuth,
47}
48
49impl AdminCookieAuthMiddleware {
50	/// Creates a new admin cookie auth middleware from a JWT secret.
51	pub fn new(secret: &[u8]) -> Self {
52		Self {
53			jwt_auth: JwtAuth::new(secret),
54		}
55	}
56
57	/// Creates a new admin cookie auth middleware from a pre-built `JwtAuth`.
58	pub fn from_jwt_auth(jwt_auth: JwtAuth) -> Self {
59		Self { jwt_auth }
60	}
61
62	/// Extracts the JWT token from the admin auth cookie or Authorization header.
63	fn extract_token(request: &Request) -> Option<String> {
64		// 1. Try cookie first (primary for admin panel)
65		if let Some(token) = extract_admin_auth_cookie(&request.headers) {
66			return Some(token);
67		}
68
69		// 2. Fall back to Authorization: Bearer header (API testing / migration)
70		request
71			.headers
72			.get("Authorization")
73			.and_then(|v| v.to_str().ok())
74			.and_then(|s| s.strip_prefix("Bearer "))
75			.map(|s| s.to_string())
76	}
77}
78
79#[async_trait]
80impl Middleware for AdminCookieAuthMiddleware {
81	async fn process(&self, request: Request, next: Arc<dyn Handler>) -> Result<Response> {
82		let auth_state = if let Some(token) = Self::extract_token(&request)
83			&& let Ok(claims) = self.jwt_auth.verify_token(&token)
84		{
85			let user_id = claims.sub;
86			let is_admin = claims.is_staff || claims.is_superuser;
87			let is_active = true;
88
89			// Insert individual values for backward compatibility with
90			// existing code that reads from extensions directly.
91			request.extensions.insert(user_id.clone());
92			request.extensions.insert(IsAuthenticated(true));
93			request.extensions.insert(IsAdmin(is_admin));
94			request.extensions.insert(IsActive(is_active));
95
96			AuthState::authenticated(user_id, is_admin, is_active)
97		} else {
98			AuthState::anonymous()
99		};
100
101		request.extensions.insert(auth_state);
102		next.handle(request).await
103	}
104}
105
106#[cfg(all(test, server))]
107mod tests {
108	use super::*;
109	use crate::server::security::ADMIN_AUTH_COOKIE_NAME;
110	use bytes::Bytes;
111	use hyper::{HeaderMap, Method, StatusCode, Version};
112	use reinhardt_auth::JwtAuth;
113
114	struct AuthCheckHandler;
115
116	#[async_trait]
117	impl Handler for AuthCheckHandler {
118		async fn handle(&self, request: Request) -> Result<Response> {
119			let auth_state: AuthState = request
120				.extensions
121				.get()
122				.unwrap_or_else(AuthState::anonymous);
123			let body = if auth_state.is_authenticated() {
124				format!("authenticated:{}", auth_state.user_id())
125			} else {
126				"anonymous".to_string()
127			};
128			Ok(Response::new(StatusCode::OK).with_body(body))
129		}
130	}
131
132	fn make_request(headers: HeaderMap) -> Request {
133		Request::builder()
134			.method(Method::POST)
135			.uri("/admin/api/server_fn/get_list")
136			.version(Version::HTTP_11)
137			.headers(headers)
138			.body(Bytes::new())
139			.build()
140			.unwrap()
141	}
142
143	fn test_secret() -> &'static [u8] {
144		b"test-secret-key-for-jwt-testing-purposes"
145	}
146
147	fn generate_test_token(user_id: &str) -> String {
148		let jwt_auth = JwtAuth::new(test_secret());
149		jwt_auth
150			.generate_token(user_id.to_string(), "admin".to_string(), true, false)
151			.unwrap()
152	}
153
154	#[tokio::test]
155	async fn test_no_token_returns_anonymous() {
156		let mw = AdminCookieAuthMiddleware::new(test_secret());
157		let next = Arc::new(AuthCheckHandler);
158		let req = make_request(HeaderMap::new());
159		let resp = mw.process(req, next).await.unwrap();
160		assert_eq!(resp.body, "anonymous");
161	}
162
163	#[tokio::test]
164	async fn test_cookie_token_authenticates() {
165		let token = generate_test_token("user-123");
166		let mw = AdminCookieAuthMiddleware::new(test_secret());
167		let next = Arc::new(AuthCheckHandler);
168		let mut headers = HeaderMap::new();
169		headers.insert(
170			"cookie",
171			format!("{}={}", ADMIN_AUTH_COOKIE_NAME, token)
172				.parse()
173				.unwrap(),
174		);
175		let req = make_request(headers);
176		let resp = mw.process(req, next).await.unwrap();
177		assert_eq!(resp.body, "authenticated:user-123");
178	}
179
180	#[tokio::test]
181	async fn test_bearer_token_authenticates_as_fallback() {
182		let token = generate_test_token("user-456");
183		let mw = AdminCookieAuthMiddleware::new(test_secret());
184		let next = Arc::new(AuthCheckHandler);
185		let mut headers = HeaderMap::new();
186		headers.insert(
187			"Authorization",
188			format!("Bearer {}", token).parse().unwrap(),
189		);
190		let req = make_request(headers);
191		let resp = mw.process(req, next).await.unwrap();
192		assert_eq!(resp.body, "authenticated:user-456");
193	}
194
195	#[tokio::test]
196	async fn test_cookie_takes_precedence_over_bearer() {
197		let cookie_token = generate_test_token("cookie-user");
198		let bearer_token = generate_test_token("bearer-user");
199		let mw = AdminCookieAuthMiddleware::new(test_secret());
200		let next = Arc::new(AuthCheckHandler);
201		let mut headers = HeaderMap::new();
202		headers.insert(
203			"cookie",
204			format!("{}={}", ADMIN_AUTH_COOKIE_NAME, cookie_token)
205				.parse()
206				.unwrap(),
207		);
208		headers.insert(
209			"Authorization",
210			format!("Bearer {}", bearer_token).parse().unwrap(),
211		);
212		let req = make_request(headers);
213		let resp = mw.process(req, next).await.unwrap();
214		assert_eq!(resp.body, "authenticated:cookie-user");
215	}
216
217	#[tokio::test]
218	async fn test_invalid_token_returns_anonymous() {
219		let mw = AdminCookieAuthMiddleware::new(test_secret());
220		let next = Arc::new(AuthCheckHandler);
221		let mut headers = HeaderMap::new();
222		headers.insert(
223			"cookie",
224			format!("{}=invalid.token.here", ADMIN_AUTH_COOKIE_NAME)
225				.parse()
226				.unwrap(),
227		);
228		let req = make_request(headers);
229		let resp = mw.process(req, next).await.unwrap();
230		assert_eq!(resp.body, "anonymous");
231	}
232}