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/// What a token may do. Levels are ordered: each includes the ones before it.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
21pub enum Scope {
22    /// Read and search only.
23    Read,
24    /// Read and write memories.
25    Write,
26    /// Write, plus dropping and backing up the databases the token names.
27    Maintenance,
28}
29
30impl Scope {
31    /// Parse the `scope` claim: `read`, `write` or `maintenance`.
32    pub fn parse(s: &str) -> Result<Self, ApiError> {
33        match s.trim().to_lowercase().as_str() {
34            "read" => Ok(Self::Read),
35            "write" => Ok(Self::Write),
36            "maintenance" => Ok(Self::Maintenance),
37            other => Err(ApiError::Unauthorized(format!(
38                "unknown scope '{other}': use read, write or maintenance"
39            ))),
40        }
41    }
42
43    pub fn as_str(self) -> &'static str {
44        match self {
45            Self::Read => "read",
46            Self::Write => "write",
47            Self::Maintenance => "maintenance",
48        }
49    }
50}
51
52#[derive(Debug, Clone)]
53pub struct AuthenticatedAgent {
54    /// Agent the token was issued for; empty when the token is scoped to
55    /// databases rather than to one agent.
56    pub agent_id: String,
57    pub admin: bool,
58    /// Databases this token may use: exact names or globs where `*` matches
59    /// any run of characters. `None` means every database (allowed only when
60    /// the server does not require the claim).
61    pub dbs: Option<Vec<String>>,
62    /// Read-only tokens may search and read but not write. Same as
63    /// `scope == Scope::Read`.
64    pub read_only: bool,
65    /// What the token may do.
66    pub scope: Scope,
67    /// The token's `sub` claim.
68    pub subject: Option<String>,
69    /// The token's `jti` claim.
70    pub token_id: Option<String>,
71}
72
73/// Whether `text` matches `pattern`, where `*` in the pattern matches any run
74/// of characters (including none) and everything else matches itself.
75pub fn glob_match(pattern: &str, text: &str) -> bool {
76    let p: Vec<char> = pattern.chars().collect();
77    let t: Vec<char> = text.chars().collect();
78    let (mut pi, mut ti) = (0usize, 0usize);
79    let (mut star, mut mark) = (None::<usize>, 0usize);
80    while ti < t.len() {
81        if pi < p.len() && p[pi] != '*' && p[pi] == t[ti] {
82            pi += 1;
83            ti += 1;
84        } else if pi < p.len() && p[pi] == '*' {
85            star = Some(pi);
86            mark = ti;
87            pi += 1;
88        } else if let Some(s) = star {
89            pi = s + 1;
90            mark += 1;
91            ti = mark;
92        } else {
93            return false;
94        }
95    }
96    while pi < p.len() && p[pi] == '*' {
97        pi += 1;
98    }
99    pi == p.len()
100}
101
102impl AuthenticatedAgent {
103    /// Whether the token's `dbs` claim admits database `name`.
104    fn names_db(&self, name: &str) -> bool {
105        match &self.dbs {
106            None => true,
107            Some(list) => list.iter().any(|d| d == "*" || glob_match(d, name)),
108        }
109    }
110
111    /// Allow or refuse access to database `name`.
112    pub fn authorize_db(&self, name: &str, write: bool) -> Result<(), ApiError> {
113        if write && self.scope < Scope::Write {
114            return Err(ApiError::Forbidden("token is read-only".into()));
115        }
116        if self.admin || self.names_db(name) {
117            Ok(())
118        } else {
119            Err(ApiError::Forbidden(format!(
120                "token is not scoped to database '{name}'"
121            )))
122        }
123    }
124
125    /// Allow or refuse dropping or backing up database `name`: admin tokens,
126    /// or a token with the maintenance scope whose `dbs` names the database.
127    /// A maintenance token must carry `dbs` even where the server accepts
128    /// unscoped tokens, so it can never drop every database.
129    pub fn authorize_maintenance(&self, name: &str) -> Result<(), ApiError> {
130        if self.admin {
131            return Ok(());
132        }
133        if self.scope < Scope::Maintenance {
134            return Err(ApiError::Forbidden(
135                "this operation needs a token with the maintenance scope".into(),
136            ));
137        }
138        if self.dbs.is_none() {
139            return Err(ApiError::Forbidden(
140                "a maintenance token must name its databases in 'dbs'".into(),
141            ));
142        }
143        self.authorize_db(name, true)
144    }
145
146    /// The key requests from this caller are rate limited under: the token's
147    /// `sub`, else its `jti`, else its agent.
148    pub fn rate_key(&self) -> Option<String> {
149        self.subject
150            .clone()
151            .or_else(|| self.token_id.clone())
152            .or_else(|| (!self.agent_id.is_empty()).then(|| self.agent_id.clone()))
153    }
154
155    /// The agent the token is bound to, if any.
156    pub fn agent(&self) -> Result<Option<mentedb_core::types::AgentId>, ApiError> {
157        if self.agent_id.is_empty() {
158            return Ok(None);
159        }
160        self.agent_id
161            .parse()
162            .map(Some)
163            .map_err(|_| ApiError::Unauthorized("token contains invalid agent_id UUID".into()))
164    }
165}
166
167/// JWT claims embedded in every token.
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct Claims {
170    /// The agent ID this token was issued for. Optional for tokens scoped to
171    /// databases instead.
172    #[serde(default)]
173    pub agent_id: String,
174    /// Whether this token grants admin privileges.
175    #[serde(default)]
176    pub admin: bool,
177    /// Token expiration as a Unix timestamp.
178    pub exp: usize,
179    /// Databases the token may use (`"*"` for all). Absent means all.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub dbs: Option<Vec<String>>,
182    /// Read-only access.
183    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
184    pub read_only: bool,
185    /// `read`, `write` or `maintenance`. Absent means `write`, or `read` when
186    /// `read_only` is set.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub scope: Option<String>,
189    /// Subject the token was issued to; binds `dbs` when the server sets a
190    /// subject pattern, and keys the rate limit.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub sub: Option<String>,
193    /// Token id; keys the rate limit when there is no `sub`.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub jti: Option<String>,
196}
197
198impl Claims {
199    fn into_agent(self) -> Result<AuthenticatedAgent, ApiError> {
200        let scope = match self.scope.as_deref() {
201            Some(s) => Scope::parse(s)?,
202            None if self.read_only => Scope::Read,
203            None => Scope::Write,
204        };
205        Ok(AuthenticatedAgent {
206            agent_id: self.agent_id,
207            admin: self.admin,
208            dbs: self.dbs,
209            read_only: scope == Scope::Read,
210            scope,
211            subject: self.sub,
212            token_id: self.jti,
213        })
214    }
215}
216
217/// Rules a token must meet beyond a valid signature.
218#[derive(Clone, Debug, Default)]
219pub struct TokenPolicy {
220    /// Refuse non admin tokens without a `dbs` claim (by default an absent
221    /// claim means every database).
222    pub require_dbs: bool,
223    /// A glob every `dbs` entry must match after `{sub}` is replaced by the
224    /// token's `sub`, e.g. `tenant.{sub}.*`. Tokens without a `sub`, or whose
225    /// `sub` holds anything but letters, digits, `-` and `_`, are refused.
226    pub dbs_sub_pattern: Option<String>,
227}
228
229impl TokenPolicy {
230    /// Check a decoded caller against the policy.
231    pub fn check(&self, agent: &AuthenticatedAgent) -> Result<(), ApiError> {
232        if agent.admin {
233            return Ok(());
234        }
235        if self.require_dbs && agent.dbs.is_none() {
236            return Err(ApiError::Unauthorized(
237                "token has no 'dbs' claim; this server requires one".into(),
238            ));
239        }
240        if let Some(pattern) = &self.dbs_sub_pattern {
241            let sub = agent.subject.as_deref().unwrap_or("");
242            let safe = !sub.is_empty()
243                && sub
244                    .chars()
245                    .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
246            if !safe {
247                return Err(ApiError::Unauthorized(
248                    "token needs a 'sub' of letters, digits, '-' or '_' to use databases".into(),
249                ));
250            }
251            let bound = pattern.replace("{sub}", sub);
252            let dbs = agent.dbs.as_deref().ok_or_else(|| {
253                ApiError::Unauthorized(
254                    "token has no 'dbs' claim; this server binds databases to 'sub'".into(),
255                )
256            })?;
257            if let Some(bad) = dbs.iter().find(|d| !glob_match(&bound, d)) {
258                return Err(ApiError::Unauthorized(format!(
259                    "dbs entry '{bad}' is outside '{bound}', the databases of this subject"
260                )));
261            }
262        }
263        Ok(())
264    }
265}
266
267/// How bearer tokens are checked.
268///
269/// A shared secret (HS256) lets the server issue tokens itself. A public key
270/// (RS256, ES256 or EdDSA) lets another service sign tokens, for example one
271/// per database, while the server holds no secret at all.
272pub struct TokenVerifier {
273    key: DecodingKey,
274    validation: Validation,
275    policy: TokenPolicy,
276}
277
278impl TokenVerifier {
279    /// Verify HS256 tokens signed with `secret`.
280    pub fn shared_secret(secret: &str) -> Self {
281        Self {
282            key: DecodingKey::from_secret(secret.as_bytes()),
283            validation: Validation::default(),
284            policy: TokenPolicy::default(),
285        }
286    }
287
288    /// Enforce `policy` on every token.
289    pub fn with_policy(mut self, policy: TokenPolicy) -> Self {
290        self.policy = policy;
291        self
292    }
293
294    /// Verify tokens with a public key given as PEM, or as base64 of the DER
295    /// encoded SubjectPublicKeyInfo (what `openssl pkey -pubout -outform DER`
296    /// writes), for keys passed inline through an environment variable.
297    pub fn public_key_text(text: &str) -> Result<Self, String> {
298        let trimmed = text.trim();
299        if trimmed.starts_with("-----BEGIN") {
300            return Self::public_key_pem(trimmed.as_bytes());
301        }
302        let b64: String = trimmed.chars().filter(|c| !c.is_whitespace()).collect();
303        if b64.is_empty()
304            || !b64
305                .chars()
306                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '='))
307        {
308            return Err("public key is neither PEM nor base64 DER".to_string());
309        }
310        let mut pem = String::from("-----BEGIN PUBLIC KEY-----\n");
311        for chunk in b64.as_bytes().chunks(64) {
312            pem.push_str(&String::from_utf8_lossy(chunk));
313            pem.push('\n');
314        }
315        pem.push_str("-----END PUBLIC KEY-----\n");
316        Self::public_key_pem(pem.as_bytes())
317    }
318
319    /// Verify tokens signed by the private half of a PEM public key. The
320    /// algorithm follows the key: RSA keys use RS256, EC P-256 keys ES256,
321    /// Ed25519 keys EdDSA.
322    pub fn public_key_pem(pem: &[u8]) -> Result<Self, String> {
323        let text = String::from_utf8_lossy(pem);
324        let (key, alg) = if let Ok(k) = DecodingKey::from_rsa_pem(pem) {
325            (k, Algorithm::RS256)
326        } else if let Ok(k) = DecodingKey::from_ec_pem(pem) {
327            (k, Algorithm::ES256)
328        } else if let Ok(k) = DecodingKey::from_ed_pem(pem) {
329            (k, Algorithm::EdDSA)
330        } else {
331            return Err(format!(
332                "not an RSA, EC or Ed25519 public key in PEM form ({} bytes)",
333                text.len()
334            ));
335        };
336        Ok(Self {
337            key,
338            validation: Validation::new(alg),
339            policy: TokenPolicy::default(),
340        })
341    }
342
343    /// Also require these `iss` / `aud` values when set.
344    pub fn with_issuer_audience(
345        mut self,
346        issuer: Option<String>,
347        audience: Option<String>,
348    ) -> Self {
349        if let Some(iss) = issuer {
350            self.validation.set_issuer(&[iss]);
351        }
352        match audience {
353            Some(aud) => self.validation.set_audience(&[aud]),
354            None => self.validation.validate_aud = false,
355        }
356        self
357    }
358
359    /// Validate a token and return its claims.
360    pub fn verify(&self, token: &str) -> Result<Claims, ApiError> {
361        decode::<Claims>(token, &self.key, &self.validation)
362            .map(|data| data.claims)
363            .map_err(|e| ApiError::Unauthorized(format!("invalid token: {e}")))
364    }
365
366    /// Validate a token into the caller it represents, enforcing the policy.
367    pub fn authenticate(&self, token: &str) -> Result<AuthenticatedAgent, ApiError> {
368        let agent = self.verify(token)?.into_agent()?;
369        self.policy.check(&agent)?;
370        Ok(agent)
371    }
372}
373
374/// Request body for the token generation endpoint.
375#[derive(Deserialize)]
376pub struct TokenRequest {
377    /// The agent ID to issue a token for (may be empty for a token scoped to
378    /// databases).
379    #[serde(default)]
380    pub agent_id: String,
381    /// How many hours until the token expires (default: 24).
382    #[serde(default = "default_expiry_hours")]
383    pub expiry_hours: u64,
384    /// Databases the token may use. Absent means all.
385    #[serde(default)]
386    pub dbs: Option<Vec<String>>,
387    /// Issue a read-only token.
388    #[serde(default)]
389    pub read_only: bool,
390    /// `read`, `write` or `maintenance`.
391    #[serde(default)]
392    pub scope: Option<String>,
393    /// Subject to put in the `sub` claim.
394    #[serde(default)]
395    pub sub: Option<String>,
396}
397
398/// Extra claims for [`create_token_with`].
399#[derive(Clone, Debug, Default)]
400pub struct TokenOptions {
401    pub dbs: Option<Vec<String>>,
402    pub read_only: bool,
403    pub scope: Option<Scope>,
404    pub sub: Option<String>,
405    pub jti: Option<String>,
406}
407
408fn default_expiry_hours() -> u64 {
409    24
410}
411
412/// Create a signed JWT for the given agent.
413pub fn create_token(secret: &str, agent_id: &str, admin: bool, expiry_hours: u64) -> String {
414    create_scoped_token(secret, agent_id, admin, expiry_hours, None, false)
415}
416
417/// Create a signed JWT limited to `dbs` (all when `None`).
418pub fn create_scoped_token(
419    secret: &str,
420    agent_id: &str,
421    admin: bool,
422    expiry_hours: u64,
423    dbs: Option<Vec<String>>,
424    read_only: bool,
425) -> String {
426    create_token_with(
427        secret,
428        agent_id,
429        admin,
430        expiry_hours,
431        TokenOptions {
432            dbs,
433            read_only,
434            ..TokenOptions::default()
435        },
436    )
437}
438
439/// Create a signed HS256 JWT with every optional claim.
440pub fn create_token_with(
441    secret: &str,
442    agent_id: &str,
443    admin: bool,
444    expiry_hours: u64,
445    opts: TokenOptions,
446) -> String {
447    let exp = jsonwebtoken::get_current_timestamp() as usize + (expiry_hours as usize * 3600);
448    let claims = Claims {
449        agent_id: agent_id.to_string(),
450        admin,
451        exp,
452        dbs: opts.dbs,
453        read_only: opts.read_only,
454        scope: opts.scope.map(|s| s.as_str().to_string()),
455        sub: opts.sub,
456        jti: opts.jti,
457    };
458    // HS256 encoding of plain claims has no failure mode short of a broken
459    // serializer; an empty token is refused by every verifier.
460    encode(
461        &Header::default(),
462        &claims,
463        &EncodingKey::from_secret(secret.as_bytes()),
464    )
465    .unwrap_or_default()
466}
467
468/// Handler: POST /v1/auth/token: generate a new JWT.
469fn extract_admin_key(request: &Request<Body>) -> Option<String> {
470    if let Some(v) = request
471        .headers()
472        .get("x-api-key")
473        .and_then(|v| v.to_str().ok())
474    {
475        return Some(v.to_string());
476    }
477    request
478        .headers()
479        .get("authorization")
480        .and_then(|v| v.to_str().ok())
481        .and_then(|v| v.strip_prefix("Bearer "))
482        .map(String::from)
483}
484
485/// Authorize an admin request from request headers against the configured admin
486/// key. Errors if no admin key is configured (admin endpoints are disabled) or the
487/// provided `x-api-key` / bearer does not match. Used by the /v1/admin endpoints.
488pub fn admin_authorized(
489    headers: &axum::http::HeaderMap,
490    admin_key: &Option<String>,
491) -> Result<(), ApiError> {
492    let expected = admin_key
493        .as_deref()
494        .ok_or_else(|| ApiError::Unauthorized("admin endpoints disabled: no admin key".into()))?;
495    let provided = headers
496        .get("x-api-key")
497        .and_then(|v| v.to_str().ok())
498        .or_else(|| {
499            headers
500                .get("authorization")
501                .and_then(|v| v.to_str().ok())
502                .and_then(|v| v.strip_prefix("Bearer "))
503        })
504        .ok_or_else(|| ApiError::Unauthorized("admin key required".into()))?;
505    if provided != expected {
506        return Err(ApiError::Forbidden("invalid admin key".into()));
507    }
508    Ok(())
509}
510
511pub async fn generate_token(
512    State(state): State<Arc<AppState>>,
513    request: Request<Body>,
514) -> Result<impl IntoResponse, ApiError> {
515    let secret = state.jwt_secret.as_deref().ok_or_else(|| {
516        ApiError::BadRequest("auth is disabled (no jwt-secret configured)".into())
517    })?;
518    match &state.admin_key {
519        None => {
520            return Err(ApiError::Forbidden(
521                "token endpoint disabled: no admin key configured".into(),
522            ));
523        }
524        Some(expected) => {
525            let provided = extract_admin_key(&request)
526                .ok_or_else(|| ApiError::Unauthorized("admin key required".into()))?;
527            if provided != *expected {
528                return Err(ApiError::Forbidden("invalid admin key".into()));
529            }
530        }
531    }
532    let body_bytes = request
533        .into_body()
534        .collect()
535        .await
536        .map_err(|e| ApiError::BadRequest(format!("failed to read body: {e}")))?
537        .to_bytes();
538    let req: TokenRequest = serde_json::from_slice(&body_bytes)
539        .map_err(|e| ApiError::BadRequest(format!("invalid JSON: {e}")))?;
540    if let Some(dbs) = &req.dbs {
541        for name in dbs.iter().filter(|d| d.as_str() != "*") {
542            // A glob entry is a valid name with `*` standing in for characters.
543            crate::databases::validate_name(&name.replace('*', "x"))?;
544        }
545    }
546    let scope = req
547        .scope
548        .as_deref()
549        .map(Scope::parse)
550        .transpose()
551        .map_err(|e| ApiError::BadRequest(e.to_string()))?;
552    let token = create_token_with(
553        secret,
554        &req.agent_id,
555        false,
556        req.expiry_hours,
557        TokenOptions {
558            dbs: req.dbs.clone(),
559            read_only: req.read_only,
560            scope,
561            sub: req.sub.clone(),
562            jti: Some(uuid::Uuid::new_v4().to_string()),
563        },
564    );
565    Ok(Json(json!({
566        "token": token,
567        "agent_id": req.agent_id,
568        "dbs": req.dbs,
569        "read_only": req.read_only,
570        "scope": scope.map(|s| s.as_str()),
571        "sub": req.sub,
572    })))
573}
574
575/// Middleware that enforces JWT auth when a secret is configured.
576pub async fn auth_middleware(
577    State(state): State<Arc<AppState>>,
578    mut request: Request<Body>,
579    next: Next,
580) -> Response {
581    let verifier = match &state.verifier {
582        Some(v) => v.clone(),
583        // No secret or key configured means development mode; skip auth.
584        None => return next.run(request).await,
585    };
586
587    // Allow health, token, metrics, and peer-to-peer gossip endpoints without auth.
588    let path = request.uri().path();
589    if path == "/v1/health"
590        || path == "/v1/ready"
591        || path == "/v1/live"
592        || path == "/v1/auth/token"
593        || path == "/metrics"
594        || path == "/console"
595        || path == crate::cluster::GOSSIP_PATH
596    {
597        return next.run(request).await;
598    }
599    // Admin endpoints are gated by the admin key or an admin token inside the
600    // handler, so the bundled console (which sends x-api-key) can reach them.
601    // A valid bearer token is still decoded so admin tokens work too; an
602    // invalid or absent one is left for the handler to refuse.
603    if is_admin_path(path) {
604        if let Some(agent) = request
605            .headers()
606            .get("authorization")
607            .and_then(|v| v.to_str().ok())
608            .and_then(|h| h.strip_prefix("Bearer "))
609            .and_then(|t| verifier.authenticate(t).ok())
610        {
611            request.extensions_mut().insert(agent);
612        }
613        return next.run(request).await;
614    }
615
616    let auth_header = request
617        .headers()
618        .get("authorization")
619        .and_then(|v| v.to_str().ok())
620        .map(String::from);
621
622    let token = match auth_header.as_deref() {
623        Some(h) if h.starts_with("Bearer ") => &h[7..],
624        _ => {
625            return (
626                StatusCode::UNAUTHORIZED,
627                Json(json!({ "error": "missing or invalid Authorization header" })),
628            )
629                .into_response();
630        }
631    };
632
633    match verifier.authenticate(token) {
634        Ok(agent) => {
635            request.extensions_mut().insert(agent);
636            next.run(request).await
637        }
638        Err(e) => e.into_response(),
639    }
640}
641
642/// Paths whose handlers check the admin key themselves: `/v1/admin/...`,
643/// `/v1/databases...`, `/v1/db/{db}` itself (drop), and per database admin,
644/// backup and backups routes. A valid token is still decoded on these, so
645/// database scoped maintenance tokens reach the handler too.
646fn is_admin_path(path: &str) -> bool {
647    let rest = match path.strip_prefix("/v1/db/") {
648        Some(r) => match r.split_once('/') {
649            Some((_, rest)) => rest,
650            None => return !r.is_empty(),
651        },
652        None => match path.strip_prefix("/v1/") {
653            Some(r) => r,
654            None => return false,
655        },
656    };
657    rest == "databases"
658        || rest.starts_with("databases/")
659        || rest == "admin"
660        || rest.starts_with("admin/")
661        || rest == "backup"
662        || rest == "backups"
663}
664
665#[cfg(test)]
666mod admin_path_tests {
667    use super::is_admin_path;
668
669    #[test]
670    fn admin_paths_are_recognized_on_every_mount() {
671        for p in [
672            "/v1/admin/mql",
673            "/v1/db/alice/admin/memories",
674            "/v1/databases",
675            "/v1/databases/alice",
676            "/v1/backup",
677            "/v1/db/alice/backups",
678            // The database itself (DELETE drops it).
679            "/v1/db/admin",
680            "/v1/db/tenant.alice.notes",
681        ] {
682            assert!(is_admin_path(p), "{p}");
683        }
684        for p in [
685            "/v1/memories",
686            "/v1/db/alice/memories",
687            "/v1/db/admin/memories",
688            "/v1/db/",
689            "/v1/search",
690        ] {
691            assert!(!is_admin_path(p), "{p}");
692        }
693    }
694}
695
696#[cfg(test)]
697mod scope_tests {
698    use super::*;
699
700    const SECRET: &str = "test-secret";
701
702    fn token(opts: TokenOptions) -> String {
703        create_token_with(SECRET, "", false, 1, opts)
704    }
705
706    fn verifier(policy: TokenPolicy) -> TokenVerifier {
707        TokenVerifier::shared_secret(SECRET).with_policy(policy)
708    }
709
710    #[test]
711    fn glob_matching() {
712        assert!(glob_match("tenant.alice.*", "tenant.alice.notes"));
713        assert!(glob_match("tenant.alice.*", "tenant.alice."));
714        assert!(!glob_match("tenant.alice.*", "tenant.alicex.notes"));
715        assert!(!glob_match("tenant.alice.*", "tenant.bob.notes"));
716        assert!(glob_match("*", "anything"));
717        assert!(glob_match("a*c*e", "abcde"));
718        assert!(!glob_match("a*c*e", "abcd"));
719        assert!(glob_match("exact", "exact"));
720        assert!(!glob_match("exact", "exactly"));
721    }
722
723    #[test]
724    fn read_scope_denies_writes_and_write_scope_allows_them() {
725        let v = verifier(TokenPolicy::default());
726        let read = v
727            .authenticate(&token(TokenOptions {
728                scope: Some(Scope::Read),
729                ..Default::default()
730            }))
731            .unwrap();
732        assert!(read.authorize_db("x", false).is_ok());
733        assert!(read.authorize_db("x", true).is_err());
734        let legacy = v
735            .authenticate(&token(TokenOptions {
736                read_only: true,
737                ..Default::default()
738            }))
739            .unwrap();
740        assert_eq!(legacy.scope, Scope::Read);
741        assert!(legacy.authorize_db("x", true).is_err());
742        let write = v.authenticate(&token(TokenOptions::default())).unwrap();
743        assert_eq!(write.scope, Scope::Write);
744        assert!(write.authorize_db("x", true).is_ok());
745    }
746
747    #[test]
748    fn maintenance_needs_the_scope_and_the_database() {
749        let v = verifier(TokenPolicy::default());
750        let maint = v
751            .authenticate(&token(TokenOptions {
752                scope: Some(Scope::Maintenance),
753                dbs: Some(vec!["tenant.alice.*".into()]),
754                ..Default::default()
755            }))
756            .unwrap();
757        assert!(maint.authorize_maintenance("tenant.alice.notes").is_ok());
758        assert!(maint.authorize_maintenance("tenant.bob.notes").is_err());
759        assert!(maint.authorize_db("tenant.alice.notes", true).is_ok());
760        let write = v
761            .authenticate(&token(TokenOptions {
762                dbs: Some(vec!["tenant.alice.notes".into()]),
763                ..Default::default()
764            }))
765            .unwrap();
766        assert!(write.authorize_maintenance("tenant.alice.notes").is_err());
767        let unscoped = v
768            .authenticate(&token(TokenOptions {
769                scope: Some(Scope::Maintenance),
770                ..Default::default()
771            }))
772            .unwrap();
773        assert!(
774            unscoped
775                .authorize_maintenance("tenant.alice.notes")
776                .is_err()
777        );
778    }
779
780    #[test]
781    fn unknown_scope_is_refused() {
782        let v = verifier(TokenPolicy::default());
783        let claims = Claims {
784            agent_id: String::new(),
785            admin: false,
786            exp: jsonwebtoken::get_current_timestamp() as usize + 600,
787            dbs: None,
788            read_only: false,
789            scope: Some("owner".into()),
790            sub: None,
791            jti: None,
792        };
793        let t = encode(
794            &Header::default(),
795            &claims,
796            &EncodingKey::from_secret(SECRET.as_bytes()),
797        )
798        .unwrap();
799        assert!(v.authenticate(&t).is_err());
800    }
801
802    #[test]
803    fn required_dbs_refuses_unscoped_tokens() {
804        let v = verifier(TokenPolicy {
805            require_dbs: true,
806            ..Default::default()
807        });
808        assert!(v.authenticate(&token(TokenOptions::default())).is_err());
809        assert!(
810            v.authenticate(&token(TokenOptions {
811                dbs: Some(vec!["a".into()]),
812                ..Default::default()
813            }))
814            .is_ok()
815        );
816        let admin = create_token_with(SECRET, "", true, 1, TokenOptions::default());
817        assert!(v.authenticate(&admin).is_ok(), "admin tokens are exempt");
818        // Without the policy an absent claim still means every database.
819        let open = verifier(TokenPolicy::default())
820            .authenticate(&token(TokenOptions::default()))
821            .unwrap();
822        assert!(open.authorize_db("anything", true).is_ok());
823    }
824
825    #[test]
826    fn sub_pattern_binds_dbs_to_the_subject() {
827        let v = verifier(TokenPolicy {
828            require_dbs: true,
829            dbs_sub_pattern: Some("tenant.{sub}.*".into()),
830        });
831        let ok = |sub: &str, dbs: &[&str]| {
832            v.authenticate(&token(TokenOptions {
833                sub: Some(sub.into()),
834                dbs: Some(dbs.iter().map(|s| s.to_string()).collect()),
835                ..Default::default()
836            }))
837            .is_ok()
838        };
839        assert!(ok("alice", &["tenant.alice.notes"]));
840        assert!(ok("alice", &["tenant.alice.*"]));
841        assert!(!ok("alice", &["tenant.bob.notes"]));
842        assert!(!ok("alice", &["tenant.alice.notes", "tenant.bob.notes"]));
843        assert!(!ok("alice", &["*"]));
844        assert!(!ok("a*", &["tenant.a*.x"]), "glob characters in sub");
845        assert!(!ok("a.b", &["tenant.a.b.x"]), "dots in sub");
846        assert!(
847            v.authenticate(&token(TokenOptions {
848                dbs: Some(vec!["tenant.alice.notes".into()]),
849                ..Default::default()
850            }))
851            .is_err(),
852            "no sub"
853        );
854    }
855
856    #[test]
857    fn rate_key_prefers_sub_then_jti_then_agent() {
858        let v = verifier(TokenPolicy::default());
859        let a = v
860            .authenticate(&token(TokenOptions {
861                sub: Some("s".into()),
862                jti: Some("j".into()),
863                ..Default::default()
864            }))
865            .unwrap();
866        assert_eq!(a.rate_key().as_deref(), Some("s"));
867        let b = v
868            .authenticate(&token(TokenOptions {
869                jti: Some("j".into()),
870                ..Default::default()
871            }))
872            .unwrap();
873        assert_eq!(b.rate_key().as_deref(), Some("j"));
874    }
875
876    #[test]
877    fn public_key_accepts_pem_or_base64_der() {
878        // An Ed25519 public key in PEM and as base64 DER.
879        let pem = "-----BEGIN PUBLIC KEY-----\n\
880                   MCowBQYDK2VwAyEAGb9ECWmEzf6FQbrBZ9w7lshQhqowtrbLDFw4rXAxZuE=\n\
881                   -----END PUBLIC KEY-----\n";
882        assert!(TokenVerifier::public_key_text(pem).is_ok());
883        assert!(
884            TokenVerifier::public_key_text(
885                "MCowBQYDK2VwAyEAGb9ECWmEzf6FQbrBZ9w7lshQhqowtrbLDFw4rXAxZuE="
886            )
887            .is_ok()
888        );
889        assert!(TokenVerifier::public_key_text("not a key!").is_err());
890    }
891}