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, Copy, PartialEq, Eq, PartialOrd, Ord)]
21pub enum Scope {
22 Read,
24 Write,
26 Maintenance,
28}
29
30impl Scope {
31 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 pub agent_id: String,
57 pub admin: bool,
58 pub dbs: Option<Vec<String>>,
62 pub read_only: bool,
65 pub scope: Scope,
67 pub subject: Option<String>,
69 pub token_id: Option<String>,
71}
72
73pub 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 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 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct Claims {
170 #[serde(default)]
173 pub agent_id: String,
174 #[serde(default)]
176 pub admin: bool,
177 pub exp: usize,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub dbs: Option<Vec<String>>,
182 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
184 pub read_only: bool,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub scope: Option<String>,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub sub: Option<String>,
193 #[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#[derive(Clone, Debug, Default)]
219pub struct TokenPolicy {
220 pub require_dbs: bool,
223 pub dbs_sub_pattern: Option<String>,
227}
228
229impl TokenPolicy {
230 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
267pub struct TokenVerifier {
273 key: DecodingKey,
274 validation: Validation,
275 policy: TokenPolicy,
276}
277
278impl TokenVerifier {
279 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 pub fn with_policy(mut self, policy: TokenPolicy) -> Self {
290 self.policy = policy;
291 self
292 }
293
294 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 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 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 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 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#[derive(Deserialize)]
376pub struct TokenRequest {
377 #[serde(default)]
380 pub agent_id: String,
381 #[serde(default = "default_expiry_hours")]
383 pub expiry_hours: u64,
384 #[serde(default)]
386 pub dbs: Option<Vec<String>>,
387 #[serde(default)]
389 pub read_only: bool,
390 #[serde(default)]
392 pub scope: Option<String>,
393 #[serde(default)]
395 pub sub: Option<String>,
396}
397
398#[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
412pub 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
417pub 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
439pub 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 encode(
461 &Header::default(),
462 &claims,
463 &EncodingKey::from_secret(secret.as_bytes()),
464 )
465 .unwrap_or_default()
466}
467
468fn 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
485pub 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 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
575pub 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 None => return next.run(request).await,
585 };
586
587 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 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
642fn 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 "/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 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 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}