1use std::sync::Arc;
4
5use axum::Json;
6use axum::body::Body;
7use axum::extract::State;
8use axum::http::{Request, StatusCode};
9use axum::middleware::Next;
10use axum::response::{IntoResponse, Response};
11use http_body_util::BodyExt;
12use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
13use serde::{Deserialize, Serialize};
14use serde_json::json;
15
16use crate::error::ApiError;
17use crate::state::AppState;
18
19#[derive(Debug, Clone)]
20pub struct AuthenticatedAgent {
21 pub agent_id: String,
24 pub admin: bool,
25 pub dbs: Option<Vec<String>>,
28 pub read_only: bool,
30}
31
32impl AuthenticatedAgent {
33 pub fn authorize_db(&self, name: &str, write: bool) -> Result<(), ApiError> {
35 if write && self.read_only {
36 return Err(ApiError::Forbidden("token is read-only".into()));
37 }
38 if self.admin {
39 return Ok(());
40 }
41 match &self.dbs {
42 None => Ok(()),
43 Some(list) if list.iter().any(|d| d == "*" || d == name) => Ok(()),
44 Some(_) => Err(ApiError::Forbidden(format!(
45 "token is not scoped to database '{name}'"
46 ))),
47 }
48 }
49
50 pub fn agent(&self) -> Result<Option<mentedb_core::types::AgentId>, ApiError> {
52 if self.agent_id.is_empty() {
53 return Ok(None);
54 }
55 self.agent_id
56 .parse()
57 .map(Some)
58 .map_err(|_| ApiError::Unauthorized("token contains invalid agent_id UUID".into()))
59 }
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct Claims {
65 #[serde(default)]
68 pub agent_id: String,
69 #[serde(default)]
71 pub admin: bool,
72 pub exp: usize,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub dbs: Option<Vec<String>>,
77 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
79 pub read_only: bool,
80}
81
82impl Claims {
83 fn into_agent(self) -> AuthenticatedAgent {
84 AuthenticatedAgent {
85 agent_id: self.agent_id,
86 admin: self.admin,
87 dbs: self.dbs,
88 read_only: self.read_only,
89 }
90 }
91}
92
93pub struct TokenVerifier {
99 key: DecodingKey,
100 validation: Validation,
101}
102
103impl TokenVerifier {
104 pub fn shared_secret(secret: &str) -> Self {
106 Self {
107 key: DecodingKey::from_secret(secret.as_bytes()),
108 validation: Validation::default(),
109 }
110 }
111
112 pub fn public_key_pem(pem: &[u8]) -> Result<Self, String> {
116 let text = String::from_utf8_lossy(pem);
117 let (key, alg) = if let Ok(k) = DecodingKey::from_rsa_pem(pem) {
118 (k, Algorithm::RS256)
119 } else if let Ok(k) = DecodingKey::from_ec_pem(pem) {
120 (k, Algorithm::ES256)
121 } else if let Ok(k) = DecodingKey::from_ed_pem(pem) {
122 (k, Algorithm::EdDSA)
123 } else {
124 return Err(format!(
125 "not an RSA, EC or Ed25519 public key in PEM form ({} bytes)",
126 text.len()
127 ));
128 };
129 Ok(Self {
130 key,
131 validation: Validation::new(alg),
132 })
133 }
134
135 pub fn with_issuer_audience(
137 mut self,
138 issuer: Option<String>,
139 audience: Option<String>,
140 ) -> Self {
141 if let Some(iss) = issuer {
142 self.validation.set_issuer(&[iss]);
143 }
144 match audience {
145 Some(aud) => self.validation.set_audience(&[aud]),
146 None => self.validation.validate_aud = false,
147 }
148 self
149 }
150
151 pub fn verify(&self, token: &str) -> Result<Claims, ApiError> {
153 decode::<Claims>(token, &self.key, &self.validation)
154 .map(|data| data.claims)
155 .map_err(|e| ApiError::Unauthorized(format!("invalid token: {e}")))
156 }
157
158 pub fn authenticate(&self, token: &str) -> Result<AuthenticatedAgent, ApiError> {
160 self.verify(token).map(Claims::into_agent)
161 }
162}
163
164#[derive(Deserialize)]
166pub struct TokenRequest {
167 #[serde(default)]
170 pub agent_id: String,
171 #[serde(default = "default_expiry_hours")]
173 pub expiry_hours: u64,
174 #[serde(default)]
176 pub dbs: Option<Vec<String>>,
177 #[serde(default)]
179 pub read_only: bool,
180}
181
182fn default_expiry_hours() -> u64 {
183 24
184}
185
186pub fn create_token(secret: &str, agent_id: &str, admin: bool, expiry_hours: u64) -> String {
188 create_scoped_token(secret, agent_id, admin, expiry_hours, None, false)
189}
190
191pub fn create_scoped_token(
193 secret: &str,
194 agent_id: &str,
195 admin: bool,
196 expiry_hours: u64,
197 dbs: Option<Vec<String>>,
198 read_only: bool,
199) -> String {
200 let exp = jsonwebtoken::get_current_timestamp() as usize + (expiry_hours as usize * 3600);
201 let claims = Claims {
202 agent_id: agent_id.to_string(),
203 admin,
204 exp,
205 dbs,
206 read_only,
207 };
208 encode(
209 &Header::default(),
210 &claims,
211 &EncodingKey::from_secret(secret.as_bytes()),
212 )
213 .expect("JWT encoding should not fail with valid inputs")
214}
215
216fn extract_admin_key(request: &Request<Body>) -> Option<String> {
218 if let Some(v) = request
219 .headers()
220 .get("x-api-key")
221 .and_then(|v| v.to_str().ok())
222 {
223 return Some(v.to_string());
224 }
225 request
226 .headers()
227 .get("authorization")
228 .and_then(|v| v.to_str().ok())
229 .and_then(|v| v.strip_prefix("Bearer "))
230 .map(String::from)
231}
232
233pub fn admin_authorized(
237 headers: &axum::http::HeaderMap,
238 admin_key: &Option<String>,
239) -> Result<(), ApiError> {
240 let expected = admin_key
241 .as_deref()
242 .ok_or_else(|| ApiError::Unauthorized("admin endpoints disabled: no admin key".into()))?;
243 let provided = headers
244 .get("x-api-key")
245 .and_then(|v| v.to_str().ok())
246 .or_else(|| {
247 headers
248 .get("authorization")
249 .and_then(|v| v.to_str().ok())
250 .and_then(|v| v.strip_prefix("Bearer "))
251 })
252 .ok_or_else(|| ApiError::Unauthorized("admin key required".into()))?;
253 if provided != expected {
254 return Err(ApiError::Forbidden("invalid admin key".into()));
255 }
256 Ok(())
257}
258
259pub async fn generate_token(
260 State(state): State<Arc<AppState>>,
261 request: Request<Body>,
262) -> Result<impl IntoResponse, ApiError> {
263 let secret = state.jwt_secret.as_deref().ok_or_else(|| {
264 ApiError::BadRequest("auth is disabled (no jwt-secret configured)".into())
265 })?;
266 match &state.admin_key {
267 None => {
268 return Err(ApiError::Forbidden(
269 "token endpoint disabled: no admin key configured".into(),
270 ));
271 }
272 Some(expected) => {
273 let provided = extract_admin_key(&request)
274 .ok_or_else(|| ApiError::Unauthorized("admin key required".into()))?;
275 if provided != *expected {
276 return Err(ApiError::Forbidden("invalid admin key".into()));
277 }
278 }
279 }
280 let body_bytes = request
281 .into_body()
282 .collect()
283 .await
284 .map_err(|e| ApiError::BadRequest(format!("failed to read body: {e}")))?
285 .to_bytes();
286 let req: TokenRequest = serde_json::from_slice(&body_bytes)
287 .map_err(|e| ApiError::BadRequest(format!("invalid JSON: {e}")))?;
288 if let Some(dbs) = &req.dbs {
289 for name in dbs.iter().filter(|d| d.as_str() != "*") {
290 crate::databases::validate_name(name)?;
291 }
292 }
293 let token = create_scoped_token(
294 secret,
295 &req.agent_id,
296 false,
297 req.expiry_hours,
298 req.dbs.clone(),
299 req.read_only,
300 );
301 Ok(Json(json!({
302 "token": token,
303 "agent_id": req.agent_id,
304 "dbs": req.dbs,
305 "read_only": req.read_only,
306 })))
307}
308
309pub async fn auth_middleware(
311 State(state): State<Arc<AppState>>,
312 mut request: Request<Body>,
313 next: Next,
314) -> Response {
315 let verifier = match &state.verifier {
316 Some(v) => v.clone(),
317 None => return next.run(request).await,
319 };
320
321 let path = request.uri().path();
323 if path == "/v1/health"
324 || path == "/v1/ready"
325 || path == "/v1/auth/token"
326 || path == "/metrics"
327 || path == "/console"
328 || path == crate::cluster::GOSSIP_PATH
329 {
330 return next.run(request).await;
331 }
332 if is_admin_path(path) {
337 if let Some(agent) = request
338 .headers()
339 .get("authorization")
340 .and_then(|v| v.to_str().ok())
341 .and_then(|h| h.strip_prefix("Bearer "))
342 .and_then(|t| verifier.authenticate(t).ok())
343 {
344 request.extensions_mut().insert(agent);
345 }
346 return next.run(request).await;
347 }
348
349 let auth_header = request
350 .headers()
351 .get("authorization")
352 .and_then(|v| v.to_str().ok())
353 .map(String::from);
354
355 let token = match auth_header.as_deref() {
356 Some(h) if h.starts_with("Bearer ") => &h[7..],
357 _ => {
358 return (
359 StatusCode::UNAUTHORIZED,
360 Json(json!({ "error": "missing or invalid Authorization header" })),
361 )
362 .into_response();
363 }
364 };
365
366 match verifier.authenticate(token) {
367 Ok(agent) => {
368 request.extensions_mut().insert(agent);
369 next.run(request).await
370 }
371 Err(e) => e.into_response(),
372 }
373}
374
375fn is_admin_path(path: &str) -> bool {
378 let rest = match path.strip_prefix("/v1/db/") {
379 Some(r) => match r.split_once('/') {
380 Some((_, rest)) => rest,
381 None => return false,
382 },
383 None => match path.strip_prefix("/v1/") {
384 Some(r) => r,
385 None => return false,
386 },
387 };
388 rest == "databases"
389 || rest.starts_with("databases/")
390 || rest == "admin"
391 || rest.starts_with("admin/")
392 || rest == "backup"
393 || rest == "backups"
394}
395
396#[cfg(test)]
397mod admin_path_tests {
398 use super::is_admin_path;
399
400 #[test]
401 fn admin_paths_are_recognized_on_every_mount() {
402 for p in [
403 "/v1/admin/mql",
404 "/v1/db/alice/admin/memories",
405 "/v1/databases",
406 "/v1/databases/alice",
407 "/v1/backup",
408 "/v1/db/alice/backups",
409 ] {
410 assert!(is_admin_path(p), "{p}");
411 }
412 for p in [
413 "/v1/memories",
414 "/v1/db/alice/memories",
415 "/v1/db/admin",
416 "/v1/db/admin/memories",
417 "/v1/search",
418 ] {
419 assert!(!is_admin_path(p), "{p}");
420 }
421 }
422}