Skip to main content

mentedb_server/
auth.rs

1//! JWT authentication middleware and token management.
2
3use 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    /// Agent the token was issued for; empty when the token is scoped to
22    /// databases rather than to one agent.
23    pub agent_id: String,
24    pub admin: bool,
25    /// Databases this token may use. `None` means every database (tokens
26    /// issued before database scopes existed, and admin tokens).
27    pub dbs: Option<Vec<String>>,
28    /// Read-only tokens may search and read but not write.
29    pub read_only: bool,
30}
31
32impl AuthenticatedAgent {
33    /// Allow or refuse access to database `name`.
34    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    /// The agent the token is bound to, if any.
51    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/// JWT claims embedded in every token.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct Claims {
65    /// The agent ID this token was issued for. Optional for tokens scoped to
66    /// databases instead.
67    #[serde(default)]
68    pub agent_id: String,
69    /// Whether this token grants admin privileges.
70    #[serde(default)]
71    pub admin: bool,
72    /// Token expiration as a Unix timestamp.
73    pub exp: usize,
74    /// Databases the token may use (`"*"` for all). Absent means all.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub dbs: Option<Vec<String>>,
77    /// Read-only access.
78    #[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
93/// How bearer tokens are checked.
94///
95/// A shared secret (HS256) lets the server issue tokens itself. A public key
96/// (RS256, ES256 or EdDSA) lets another service sign tokens, for example one
97/// per database, while the server holds no secret at all.
98pub struct TokenVerifier {
99    key: DecodingKey,
100    validation: Validation,
101}
102
103impl TokenVerifier {
104    /// Verify HS256 tokens signed with `secret`.
105    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    /// Verify tokens signed by the private half of a PEM public key. The
113    /// algorithm follows the key: RSA keys use RS256, EC P-256 keys ES256,
114    /// Ed25519 keys EdDSA.
115    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    /// Also require these `iss` / `aud` values when set.
136    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    /// Validate a token and return its claims.
152    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    /// Validate a token into the caller it represents.
159    pub fn authenticate(&self, token: &str) -> Result<AuthenticatedAgent, ApiError> {
160        self.verify(token).map(Claims::into_agent)
161    }
162}
163
164/// Request body for the token generation endpoint.
165#[derive(Deserialize)]
166pub struct TokenRequest {
167    /// The agent ID to issue a token for (may be empty for a token scoped to
168    /// databases).
169    #[serde(default)]
170    pub agent_id: String,
171    /// How many hours until the token expires (default: 24).
172    #[serde(default = "default_expiry_hours")]
173    pub expiry_hours: u64,
174    /// Databases the token may use. Absent means all.
175    #[serde(default)]
176    pub dbs: Option<Vec<String>>,
177    /// Issue a read-only token.
178    #[serde(default)]
179    pub read_only: bool,
180}
181
182fn default_expiry_hours() -> u64 {
183    24
184}
185
186/// Create a signed JWT for the given agent.
187pub 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
191/// Create a signed JWT limited to `dbs` (all when `None`).
192pub 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
216/// Handler: POST /v1/auth/token: generate a new JWT.
217fn 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
233/// Authorize an admin request from request headers against the configured admin
234/// key. Errors if no admin key is configured (admin endpoints are disabled) or the
235/// provided `x-api-key` / bearer does not match. Used by the /v1/admin endpoints.
236pub 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
309/// Middleware that enforces JWT auth when a secret is configured.
310pub 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        // No secret or key configured means development mode; skip auth.
318        None => return next.run(request).await,
319    };
320
321    // Allow health, token, metrics, and peer-to-peer gossip endpoints without auth.
322    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    // Admin endpoints are gated by the admin key or an admin token inside the
333    // handler, so the bundled console (which sends x-api-key) can reach them.
334    // A valid bearer token is still decoded so admin tokens work too; an
335    // invalid or absent one is left for the handler to refuse.
336    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
375/// Paths whose handlers check the admin key themselves: `/v1/admin/...`,
376/// `/v1/databases...`, and per database admin, backup and backups routes.
377fn 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}