1use std::fmt;
10use std::path::Path;
11use std::str::FromStr;
12use std::sync::Arc;
13
14use anyhow::{Result, anyhow};
15use argon2::Argon2;
16use argon2::password_hash::rand_core::OsRng;
17use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
18use chrono::{DateTime, Utc};
19use serde::{Deserialize, Serialize};
20use tokio::sync::RwLock;
21use tracing::{debug, info, warn};
22use uuid::Uuid;
23
24#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
30#[serde(rename_all = "lowercase")]
31pub enum Scope {
32 Read,
33 Write,
34 Admin,
35}
36
37impl fmt::Display for Scope {
38 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39 match self {
40 Scope::Read => write!(f, "read"),
41 Scope::Write => write!(f, "write"),
42 Scope::Admin => write!(f, "admin"),
43 }
44 }
45}
46
47impl FromStr for Scope {
48 type Err = anyhow::Error;
49
50 fn from_str(s: &str) -> Result<Self> {
51 match s.to_lowercase().as_str() {
52 "read" => Ok(Scope::Read),
53 "write" => Ok(Scope::Write),
54 "admin" => Ok(Scope::Admin),
55 other => Err(anyhow!(
56 "Unknown scope '{}'. Use: read, write, admin",
57 other
58 )),
59 }
60 }
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct TokenEntry {
70 pub id: String,
72 pub token_hash: String,
74 pub scopes: Vec<Scope>,
76 pub namespaces: Vec<String>,
78 pub expires_at: Option<DateTime<Utc>>,
80 pub description: String,
82 pub created_at: DateTime<Utc>,
84}
85
86impl TokenEntry {
87 pub fn is_expired(&self) -> bool {
89 if let Some(exp) = self.expires_at {
90 Utc::now() > exp
91 } else {
92 false
93 }
94 }
95
96 pub fn has_namespace_access(&self, namespace: &str) -> bool {
98 self.namespaces
99 .iter()
100 .any(|ns| ns == "*" || ns == namespace)
101 }
102
103 pub fn has_scope(&self, scope: &Scope) -> bool {
105 self.scopes.contains(&Scope::Admin) || self.scopes.contains(scope)
107 }
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct TokenStoreV2 {
117 pub version: u32,
118 pub tokens: Vec<TokenEntry>,
119}
120
121impl Default for TokenStoreV2 {
122 fn default() -> Self {
123 Self {
124 version: 2,
125 tokens: Vec::new(),
126 }
127 }
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
132struct TokenEntryV1 {
133 namespace: String,
134 token: String,
135 created_at: u64,
136 description: Option<String>,
137}
138
139#[derive(Debug)]
141pub struct TokenStoreFile {
142 store: Arc<RwLock<TokenStoreV2>>,
143 store_path: String,
144}
145
146impl TokenStoreFile {
147 pub fn new(store_path: String) -> Self {
149 Self {
150 store: Arc::new(RwLock::new(TokenStoreV2::default())),
151 store_path,
152 }
153 }
154
155 fn expanded_path(&self) -> String {
157 shellexpand::tilde(&self.store_path).to_string()
158 }
159
160 pub async fn load(&self) -> Result<()> {
162 let expanded = self.expanded_path();
163 let path = Path::new(&expanded);
164
165 if !path.exists() {
166 debug!("No token store at {}, starting fresh", expanded);
167 return Ok(());
168 }
169
170 let contents = tokio::fs::read_to_string(path).await?;
171
172 if let Ok(v2) = serde_json::from_str::<TokenStoreV2>(&contents)
174 && v2.version == 2
175 {
176 let count = v2.tokens.len();
177 let mut store = self.store.write().await;
178 *store = v2;
179 info!("Loaded {} tokens from v2 store at {}", count, expanded);
180 return Ok(());
181 }
182
183 if let Ok(v1_map) =
185 serde_json::from_str::<std::collections::HashMap<String, TokenEntryV1>>(&contents)
186 {
187 info!(
188 "Detected v1 token store with {} entries, migrating to v2",
189 v1_map.len()
190 );
191
192 let backup_path = format!("{}.v1.bak", expanded);
194 tokio::fs::copy(&expanded, &backup_path).await?;
195 info!("Backed up v1 store to {}", backup_path);
196
197 let argon2 = Argon2::default();
199 let mut migrated = Vec::new();
200 for (ns, entry) in &v1_map {
201 let salt = SaltString::generate(&mut OsRng);
202 let hash = argon2
203 .hash_password(entry.token.as_bytes(), &salt)
204 .map_err(|e| anyhow!("Failed to hash v1 token for '{}': {}", ns, e))?
205 .to_string();
206
207 migrated.push(TokenEntry {
208 id: format!("migrated-{}", ns),
209 token_hash: hash,
210 scopes: vec![Scope::Read, Scope::Write, Scope::Admin],
211 namespaces: vec![ns.clone()],
212 expires_at: None,
213 description: entry
214 .description
215 .clone()
216 .unwrap_or_else(|| format!("Migrated from v1 for namespace '{}'", ns)),
217 created_at: DateTime::from_timestamp(entry.created_at as i64, 0)
218 .unwrap_or_else(Utc::now),
219 });
220 }
221
222 let v2 = TokenStoreV2 {
223 version: 2,
224 tokens: migrated,
225 };
226 let mut store = self.store.write().await;
227 *store = v2;
228 drop(store);
229
230 self.save().await?;
231 warn!(
232 "Migrated v1 token store to v2. Old store backed up to {}",
233 backup_path
234 );
235 return Ok(());
236 }
237
238 Err(anyhow!(
239 "Cannot parse token store at {}. Expected v2 or v1 format.",
240 expanded
241 ))
242 }
243
244 pub async fn save(&self) -> Result<()> {
246 let expanded = self.expanded_path();
247 let path = Path::new(&expanded);
248
249 if let Some(parent) = path.parent() {
250 tokio::fs::create_dir_all(parent).await?;
251 }
252
253 let store = self.store.read().await;
254 let contents = serde_json::to_string_pretty(&*store)?;
255 tokio::fs::write(path, contents).await?;
256 debug!("Saved {} tokens to {}", store.tokens.len(), expanded);
257 Ok(())
258 }
259
260 pub async fn create_token(
262 &self,
263 id: String,
264 scopes: Vec<Scope>,
265 namespaces: Vec<String>,
266 expires_at: Option<DateTime<Utc>>,
267 description: String,
268 ) -> Result<String> {
269 {
271 let store = self.store.read().await;
272 if store.tokens.iter().any(|t| t.id == id) {
273 return Err(anyhow!(
274 "Token with id '{}' already exists. Use 'auth revoke' first or pick a different id.",
275 id
276 ));
277 }
278 }
279
280 let plaintext = format!("memex_{}", Uuid::new_v4().to_string().replace('-', ""));
282
283 let argon2 = Argon2::default();
285 let salt = SaltString::generate(&mut OsRng);
286 let hash = argon2
287 .hash_password(plaintext.as_bytes(), &salt)
288 .map_err(|e| anyhow!("Failed to hash token: {}", e))?
289 .to_string();
290
291 let entry = TokenEntry {
292 id: id.clone(),
293 token_hash: hash,
294 scopes,
295 namespaces,
296 expires_at,
297 description,
298 created_at: Utc::now(),
299 };
300
301 {
302 let mut store = self.store.write().await;
303 store.tokens.push(entry);
304 }
305
306 self.save().await?;
307 info!("Created token '{}'", id);
308 Ok(plaintext)
309 }
310
311 pub async fn list_tokens(&self) -> Vec<TokenEntry> {
313 let store = self.store.read().await;
314 store.tokens.clone()
315 }
316
317 pub async fn revoke_token(&self, id: &str) -> Result<bool> {
319 let removed = {
320 let mut store = self.store.write().await;
321 let before = store.tokens.len();
322 store.tokens.retain(|t| t.id != id);
323 store.tokens.len() < before
324 };
325
326 if removed {
327 self.save().await?;
328 info!("Revoked token '{}'", id);
329 }
330 Ok(removed)
331 }
332
333 pub async fn rotate_token(&self, id: &str) -> Result<String> {
335 let old_entry = {
336 let store = self.store.read().await;
337 store
338 .tokens
339 .iter()
340 .find(|t| t.id == id)
341 .cloned()
342 .ok_or_else(|| anyhow!("Token '{}' not found", id))?
343 };
344
345 {
347 let mut store = self.store.write().await;
348 store.tokens.retain(|t| t.id != id);
349 }
350
351 self.create_token(
353 old_entry.id,
354 old_entry.scopes,
355 old_entry.namespaces,
356 old_entry.expires_at,
357 old_entry.description,
358 )
359 .await
360 }
361
362 pub async fn lookup_by_plaintext(&self, plaintext: &str) -> Option<TokenEntry> {
365 let store = self.store.read().await;
366 let argon2 = Argon2::default();
367
368 for entry in &store.tokens {
369 if let Ok(parsed_hash) = PasswordHash::new(&entry.token_hash)
370 && argon2
371 .verify_password(plaintext.as_bytes(), &parsed_hash)
372 .is_ok()
373 {
374 return Some(entry.clone());
375 }
376 }
377 None
378 }
379}
380
381#[derive(Debug)]
394pub struct AuthManager {
395 token_store: TokenStoreFile,
396 legacy_token: Option<String>,
398}
399
400#[derive(Debug, Clone)]
402pub struct AuthResult {
403 pub token: TokenEntry,
405}
406
407#[derive(Debug, Clone)]
409pub enum AuthDenial {
410 MissingToken,
412 InvalidToken,
414 Expired { id: String },
416 InsufficientScope {
418 id: String,
419 required: Scope,
420 granted: Vec<Scope>,
421 },
422 NamespaceDenied {
424 id: String,
425 requested: String,
426 allowed: Vec<String>,
427 },
428}
429
430impl fmt::Display for AuthDenial {
431 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
432 match self {
433 AuthDenial::MissingToken => write!(f, "Authorization header missing or malformed"),
434 AuthDenial::InvalidToken => write!(f, "Invalid or unrecognized token"),
435 AuthDenial::Expired { id } => write!(f, "Token '{}' has expired", id),
436 AuthDenial::InsufficientScope {
437 id,
438 required,
439 granted,
440 } => {
441 let granted_str: Vec<String> = granted.iter().map(|s| s.to_string()).collect();
442 write!(
443 f,
444 "Token '{}' lacks scope '{}' (has: [{}])",
445 id,
446 required,
447 granted_str.join(", ")
448 )
449 }
450 AuthDenial::NamespaceDenied {
451 id,
452 requested,
453 allowed,
454 } => write!(
455 f,
456 "Token '{}' cannot access namespace '{}' (allowed: [{}])",
457 id,
458 requested,
459 allowed.join(", ")
460 ),
461 }
462 }
463}
464
465impl AuthManager {
466 pub fn new(store_path: String, legacy_token: Option<String>) -> Self {
468 Self {
469 token_store: TokenStoreFile::new(store_path),
470 legacy_token,
471 }
472 }
473
474 pub async fn init(&self) -> Result<()> {
476 self.token_store.load().await?;
477
478 if self.legacy_token.is_some() {
479 warn!(
480 "DEPRECATED: --auth-token flag used. This maps to a single wildcard token. \
481 Migrate to 'rust-memex auth create' for per-token scopes and namespace ACL."
482 );
483 }
484 Ok(())
485 }
486
487 pub async fn authenticate(&self, bearer_token: &str) -> Result<AuthResult, AuthDenial> {
489 if let Some(ref legacy) = self.legacy_token
491 && bearer_token == legacy
492 {
493 return Ok(AuthResult {
494 token: TokenEntry {
495 id: "__legacy__".to_string(),
496 token_hash: String::new(),
497 scopes: vec![Scope::Read, Scope::Write, Scope::Admin],
498 namespaces: vec!["*".to_string()],
499 expires_at: None,
500 description: "Legacy --auth-token (wildcard)".to_string(),
501 created_at: Utc::now(),
502 },
503 });
504 }
505
506 match self.token_store.lookup_by_plaintext(bearer_token).await {
508 Some(entry) => {
509 if entry.is_expired() {
510 return Err(AuthDenial::Expired {
511 id: entry.id.clone(),
512 });
513 }
514 Ok(AuthResult { token: entry })
515 }
516 None => Err(AuthDenial::InvalidToken),
517 }
518 }
519
520 pub async fn authorize(
522 &self,
523 bearer_token: &str,
524 required_scope: &Scope,
525 namespace: Option<&str>,
526 ) -> Result<AuthResult, AuthDenial> {
527 let result = self.authenticate(bearer_token).await?;
528
529 if !result.token.has_scope(required_scope) {
531 return Err(AuthDenial::InsufficientScope {
532 id: result.token.id.clone(),
533 required: required_scope.clone(),
534 granted: result.token.scopes.clone(),
535 });
536 }
537
538 if let Some(ns) = namespace
540 && !result.token.has_namespace_access(ns)
541 {
542 return Err(AuthDenial::NamespaceDenied {
543 id: result.token.id.clone(),
544 requested: ns.to_string(),
545 allowed: result.token.namespaces.clone(),
546 });
547 }
548
549 Ok(result)
550 }
551
552 pub async fn create_token(
554 &self,
555 id: String,
556 scopes: Vec<Scope>,
557 namespaces: Vec<String>,
558 expires_at: Option<DateTime<Utc>>,
559 description: String,
560 ) -> Result<String> {
561 self.token_store
562 .create_token(id, scopes, namespaces, expires_at, description)
563 .await
564 }
565
566 pub async fn list_tokens(&self) -> Vec<TokenEntry> {
568 self.token_store.list_tokens().await
569 }
570
571 pub async fn revoke_token(&self, id: &str) -> Result<bool> {
573 self.token_store.revoke_token(id).await
574 }
575
576 pub async fn rotate_token(&self, id: &str) -> Result<String> {
578 self.token_store.rotate_token(id).await
579 }
580
581 pub async fn has_any_tokens(&self) -> bool {
583 self.legacy_token.is_some() || !self.token_store.list_tokens().await.is_empty()
584 }
585}
586
587#[cfg(test)]
592mod tests {
593 use super::*;
594
595 #[test]
596 fn scope_display_and_parse() {
597 assert_eq!(Scope::Read.to_string(), "read");
598 assert_eq!(Scope::Write.to_string(), "write");
599 assert_eq!(Scope::Admin.to_string(), "admin");
600
601 assert_eq!(Scope::from_str("read").unwrap(), Scope::Read);
602 assert_eq!(Scope::from_str("WRITE").unwrap(), Scope::Write);
603 assert_eq!(Scope::from_str("Admin").unwrap(), Scope::Admin);
604 assert!(Scope::from_str("invalid").is_err());
605 }
606
607 #[test]
608 fn token_entry_scope_check() {
609 let entry = TokenEntry {
610 id: "test".to_string(),
611 token_hash: String::new(),
612 scopes: vec![Scope::Read],
613 namespaces: vec!["ns1".to_string()],
614 expires_at: None,
615 description: "test".to_string(),
616 created_at: Utc::now(),
617 };
618
619 assert!(entry.has_scope(&Scope::Read));
620 assert!(!entry.has_scope(&Scope::Write));
621 assert!(!entry.has_scope(&Scope::Admin));
622 }
623
624 #[test]
625 fn admin_scope_implies_all() {
626 let entry = TokenEntry {
627 id: "admin".to_string(),
628 token_hash: String::new(),
629 scopes: vec![Scope::Admin],
630 namespaces: vec!["*".to_string()],
631 expires_at: None,
632 description: "admin".to_string(),
633 created_at: Utc::now(),
634 };
635
636 assert!(entry.has_scope(&Scope::Read));
637 assert!(entry.has_scope(&Scope::Write));
638 assert!(entry.has_scope(&Scope::Admin));
639 }
640
641 #[test]
642 fn namespace_wildcard_access() {
643 let entry = TokenEntry {
644 id: "wild".to_string(),
645 token_hash: String::new(),
646 scopes: vec![Scope::Read],
647 namespaces: vec!["*".to_string()],
648 expires_at: None,
649 description: "wildcard".to_string(),
650 created_at: Utc::now(),
651 };
652
653 assert!(entry.has_namespace_access("kb:claude"));
654 assert!(entry.has_namespace_access("anything"));
655 }
656
657 #[test]
658 fn namespace_acl_check() {
659 let entry = TokenEntry {
660 id: "limited".to_string(),
661 token_hash: String::new(),
662 scopes: vec![Scope::Read],
663 namespaces: vec!["kb:claude".to_string(), "kb:mikserka".to_string()],
664 expires_at: None,
665 description: "limited".to_string(),
666 created_at: Utc::now(),
667 };
668
669 assert!(entry.has_namespace_access("kb:claude"));
670 assert!(entry.has_namespace_access("kb:mikserka"));
671 assert!(!entry.has_namespace_access("kb:reports"));
672 }
673
674 #[test]
675 fn token_entry_expiry() {
676 let expired = TokenEntry {
677 id: "expired".to_string(),
678 token_hash: String::new(),
679 scopes: vec![Scope::Read],
680 namespaces: vec!["*".to_string()],
681 expires_at: Some(
682 DateTime::parse_from_rfc3339("2020-01-01T00:00:00Z")
683 .unwrap()
684 .with_timezone(&Utc),
685 ),
686 description: "expired".to_string(),
687 created_at: Utc::now(),
688 };
689 assert!(expired.is_expired());
690
691 let future = TokenEntry {
692 id: "future".to_string(),
693 token_hash: String::new(),
694 scopes: vec![Scope::Read],
695 namespaces: vec!["*".to_string()],
696 expires_at: Some(
697 DateTime::parse_from_rfc3339("2099-12-31T00:00:00Z")
698 .unwrap()
699 .with_timezone(&Utc),
700 ),
701 description: "future".to_string(),
702 created_at: Utc::now(),
703 };
704 assert!(!future.is_expired());
705
706 let no_expiry = TokenEntry {
707 id: "noexp".to_string(),
708 token_hash: String::new(),
709 scopes: vec![Scope::Read],
710 namespaces: vec!["*".to_string()],
711 expires_at: None,
712 description: "no expiry".to_string(),
713 created_at: Utc::now(),
714 };
715 assert!(!no_expiry.is_expired());
716 }
717
718 #[tokio::test]
719 async fn token_create_and_lookup() {
720 let dir = tempfile::tempdir().unwrap();
721 let store_path = dir.path().join("tokens.json").to_str().unwrap().to_string();
722
723 let store = TokenStoreFile::new(store_path);
724
725 let plaintext = store
726 .create_token(
727 "test-token".to_string(),
728 vec![Scope::Read, Scope::Write],
729 vec!["kb:claude".to_string()],
730 None,
731 "Test token".to_string(),
732 )
733 .await
734 .unwrap();
735
736 assert!(plaintext.starts_with("memex_"));
737
738 let found = store.lookup_by_plaintext(&plaintext).await;
740 assert!(found.is_some());
741 let entry = found.unwrap();
742 assert_eq!(entry.id, "test-token");
743 assert_eq!(entry.scopes, vec![Scope::Read, Scope::Write]);
744
745 let not_found = store.lookup_by_plaintext("memex_wrong").await;
747 assert!(not_found.is_none());
748 }
749
750 #[tokio::test]
751 async fn token_revoke() {
752 let dir = tempfile::tempdir().unwrap();
753 let store_path = dir.path().join("tokens.json").to_str().unwrap().to_string();
754
755 let store = TokenStoreFile::new(store_path);
756 let plaintext = store
757 .create_token(
758 "revokable".to_string(),
759 vec![Scope::Read],
760 vec!["*".to_string()],
761 None,
762 "Will be revoked".to_string(),
763 )
764 .await
765 .unwrap();
766
767 assert!(store.lookup_by_plaintext(&plaintext).await.is_some());
769
770 assert!(store.revoke_token("revokable").await.unwrap());
772
773 assert!(store.lookup_by_plaintext(&plaintext).await.is_none());
775 }
776
777 #[tokio::test]
778 async fn auth_manager_scope_enforcement() {
779 let dir = tempfile::tempdir().unwrap();
780 let store_path = dir.path().join("tokens.json").to_str().unwrap().to_string();
781
782 let manager = AuthManager::new(store_path, None);
783 manager.init().await.unwrap();
784
785 let plaintext = manager
786 .create_token(
787 "read-only".to_string(),
788 vec![Scope::Read],
789 vec!["*".to_string()],
790 None,
791 "Read-only token".to_string(),
792 )
793 .await
794 .unwrap();
795
796 let result = manager.authorize(&plaintext, &Scope::Read, None).await;
798 assert!(result.is_ok());
799
800 let result = manager.authorize(&plaintext, &Scope::Write, None).await;
802 assert!(result.is_err());
803 match result.unwrap_err() {
804 AuthDenial::InsufficientScope { required, .. } => {
805 assert_eq!(required, Scope::Write);
806 }
807 other => panic!("Expected InsufficientScope, got: {:?}", other),
808 }
809 }
810
811 #[tokio::test]
812 async fn auth_manager_namespace_enforcement() {
813 let dir = tempfile::tempdir().unwrap();
814 let store_path = dir.path().join("tokens.json").to_str().unwrap().to_string();
815
816 let manager = AuthManager::new(store_path, None);
817 manager.init().await.unwrap();
818
819 let plaintext = manager
820 .create_token(
821 "ns-limited".to_string(),
822 vec![Scope::Read, Scope::Write],
823 vec!["kb:claude".to_string()],
824 None,
825 "Limited to kb:claude".to_string(),
826 )
827 .await
828 .unwrap();
829
830 let result = manager
832 .authorize(&plaintext, &Scope::Read, Some("kb:claude"))
833 .await;
834 assert!(result.is_ok());
835
836 let result = manager
838 .authorize(&plaintext, &Scope::Read, Some("kb:reports"))
839 .await;
840 assert!(result.is_err());
841 match result.unwrap_err() {
842 AuthDenial::NamespaceDenied { requested, .. } => {
843 assert_eq!(requested, "kb:reports");
844 }
845 other => panic!("Expected NamespaceDenied, got: {:?}", other),
846 }
847 }
848
849 #[tokio::test]
850 async fn auth_manager_legacy_token() {
851 let dir = tempfile::tempdir().unwrap();
852 let store_path = dir.path().join("tokens.json").to_str().unwrap().to_string();
853
854 let manager = AuthManager::new(store_path, Some("my-legacy-token".to_string()));
855 manager.init().await.unwrap();
856
857 let result = manager
859 .authorize("my-legacy-token", &Scope::Admin, Some("any-ns"))
860 .await;
861 assert!(result.is_ok());
862 assert_eq!(result.unwrap().token.id, "__legacy__");
863
864 let result = manager.authenticate("wrong-token").await;
866 assert!(result.is_err());
867 }
868
869 #[tokio::test]
870 async fn auth_manager_expired_token() {
871 let dir = tempfile::tempdir().unwrap();
872 let store_path = dir.path().join("tokens.json").to_str().unwrap().to_string();
873
874 let manager = AuthManager::new(store_path, None);
875 manager.init().await.unwrap();
876
877 {
879 let store = &manager.token_store;
880 let argon2 = Argon2::default();
881 let salt = SaltString::generate(&mut OsRng);
882 let hash = argon2
883 .hash_password(b"expired_token_value", &salt)
884 .unwrap()
885 .to_string();
886
887 let entry = TokenEntry {
888 id: "expired-test".to_string(),
889 token_hash: hash,
890 scopes: vec![Scope::Read],
891 namespaces: vec!["*".to_string()],
892 expires_at: Some(
893 DateTime::parse_from_rfc3339("2020-01-01T00:00:00Z")
894 .unwrap()
895 .with_timezone(&Utc),
896 ),
897 description: "Expired test".to_string(),
898 created_at: Utc::now(),
899 };
900 let mut s = store.store.write().await;
901 s.tokens.push(entry);
902 }
903
904 let result = manager.authenticate("expired_token_value").await;
905 assert!(result.is_err());
906 match result.unwrap_err() {
907 AuthDenial::Expired { id } => assert_eq!(id, "expired-test"),
908 other => panic!("Expected Expired, got: {:?}", other),
909 }
910 }
911
912 #[tokio::test]
913 async fn token_store_persistence() {
914 let dir = tempfile::tempdir().unwrap();
915 let store_path = dir.path().join("tokens.json").to_str().unwrap().to_string();
916
917 let store1 = TokenStoreFile::new(store_path.clone());
919 let plaintext = store1
920 .create_token(
921 "persist-test".to_string(),
922 vec![Scope::Read],
923 vec!["*".to_string()],
924 None,
925 "Persistence test".to_string(),
926 )
927 .await
928 .unwrap();
929
930 let store2 = TokenStoreFile::new(store_path);
932 store2.load().await.unwrap();
933
934 let found = store2.lookup_by_plaintext(&plaintext).await;
935 assert!(found.is_some());
936 assert_eq!(found.unwrap().id, "persist-test");
937 }
938
939 #[tokio::test]
940 async fn token_rotate() {
941 let dir = tempfile::tempdir().unwrap();
942 let store_path = dir.path().join("tokens.json").to_str().unwrap().to_string();
943
944 let store = TokenStoreFile::new(store_path);
945 let old_plaintext = store
946 .create_token(
947 "rotate-me".to_string(),
948 vec![Scope::Read, Scope::Write],
949 vec!["kb:claude".to_string()],
950 None,
951 "Will be rotated".to_string(),
952 )
953 .await
954 .unwrap();
955
956 let new_plaintext = store.rotate_token("rotate-me").await.unwrap();
958 assert_ne!(old_plaintext, new_plaintext);
959
960 assert!(store.lookup_by_plaintext(&old_plaintext).await.is_none());
962
963 let found = store.lookup_by_plaintext(&new_plaintext).await;
965 assert!(found.is_some());
966 assert_eq!(found.unwrap().id, "rotate-me");
967 }
968
969 #[tokio::test]
970 async fn v1_migration() {
971 let dir = tempfile::tempdir().unwrap();
972 let store_path = dir.path().join("tokens.json");
973
974 let v1_data: std::collections::HashMap<String, serde_json::Value> = [(
976 "kb:claude".to_string(),
977 serde_json::json!({
978 "namespace": "kb:claude",
979 "token": "ns_test123456",
980 "created_at": 1700000000_u64,
981 "description": "Original v1 token"
982 }),
983 )]
984 .into_iter()
985 .collect();
986
987 tokio::fs::write(&store_path, serde_json::to_string_pretty(&v1_data).unwrap())
988 .await
989 .unwrap();
990
991 let store = TokenStoreFile::new(store_path.to_str().unwrap().to_string());
993 store.load().await.unwrap();
994
995 let tokens = store.list_tokens().await;
997 assert_eq!(tokens.len(), 1);
998 assert_eq!(tokens[0].id, "migrated-kb:claude");
999 assert_eq!(tokens[0].namespaces, vec!["kb:claude".to_string()]);
1000 assert_eq!(
1001 tokens[0].scopes,
1002 vec![Scope::Read, Scope::Write, Scope::Admin]
1003 );
1004
1005 let found = store.lookup_by_plaintext("ns_test123456").await;
1007 assert!(found.is_some());
1008
1009 let backup_path = format!("{}.v1.bak", store_path.to_str().unwrap());
1011 assert!(Path::new(&backup_path).exists());
1012 }
1013}