Skip to main content

rust_memex/auth/
mod.rs

1//! Multi-token auth with per-token scopes and namespace ACL.
2//!
3//! Replaces the single global bearer token with a flexible token store.
4//! Each token is hashed with argon2id at rest. Plaintext is shown ONCE
5//! on creation and never stored.
6//!
7//! Vibecrafted with AI Agents by Loctree (c)2024-2026 The LibraxisAI Team
8
9use 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// ============================================================================
25// Scope
26// ============================================================================
27
28/// Permission scope for a token.
29#[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// ============================================================================
64// TokenEntry
65// ============================================================================
66
67/// A single token entry persisted in tokens.json (v2 schema).
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct TokenEntry {
70    /// Human-readable identifier (e.g., "monika-iphone")
71    pub id: String,
72    /// Argon2id hash of the token. Plaintext never stored.
73    pub token_hash: String,
74    /// Permission scopes granted to this token.
75    pub scopes: Vec<Scope>,
76    /// Namespace ACL. `["*"]` means all namespaces.
77    pub namespaces: Vec<String>,
78    /// Optional expiry timestamp. `None` = never expires.
79    pub expires_at: Option<DateTime<Utc>>,
80    /// Human-readable description.
81    pub description: String,
82    /// When this token was created.
83    pub created_at: DateTime<Utc>,
84}
85
86impl TokenEntry {
87    /// Check if the token has expired.
88    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    /// Check if the token grants access to a given namespace.
97    pub fn has_namespace_access(&self, namespace: &str) -> bool {
98        self.namespaces
99            .iter()
100            .any(|ns| ns == "*" || ns == namespace)
101    }
102
103    /// Check if the token has a given scope.
104    pub fn has_scope(&self, scope: &Scope) -> bool {
105        // Admin implies all scopes
106        self.scopes.contains(&Scope::Admin) || self.scopes.contains(scope)
107    }
108}
109
110// ============================================================================
111// TokenStoreV2
112// ============================================================================
113
114/// Version 2 token store schema, persisted as JSON.
115#[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/// Version 1 schema (legacy) for migration.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132struct TokenEntryV1 {
133    namespace: String,
134    token: String,
135    created_at: u64,
136    description: Option<String>,
137}
138
139/// Persistent token store backed by `tokens.json`.
140#[derive(Debug)]
141pub struct TokenStoreFile {
142    store: Arc<RwLock<TokenStoreV2>>,
143    store_path: String,
144}
145
146impl TokenStoreFile {
147    /// Create a new token store at the given path.
148    pub fn new(store_path: String) -> Self {
149        Self {
150            store: Arc::new(RwLock::new(TokenStoreV2::default())),
151            store_path,
152        }
153    }
154
155    /// Expand and return the canonical file path.
156    fn expanded_path(&self) -> String {
157        shellexpand::tilde(&self.store_path).to_string()
158    }
159
160    /// Load tokens from disk. Handles v1 -> v2 migration.
161    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        // Try v2 first
173        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        // Try v1 (legacy: HashMap<String, TokenEntryV1>)
184        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            // Back up v1
193            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            // Migrate each v1 token
198            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    /// Save current store to disk.
245    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    /// Create a new token, hash it, store it, and return the plaintext.
261    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        // Check for duplicate id
270        {
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        // Generate plaintext token
281        let plaintext = format!("memex_{}", Uuid::new_v4().to_string().replace('-', ""));
282
283        // Hash it
284        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    /// List all token entries (no plaintext exposed).
312    pub async fn list_tokens(&self) -> Vec<TokenEntry> {
313        let store = self.store.read().await;
314        store.tokens.clone()
315    }
316
317    /// Revoke (remove) a token by id.
318    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    /// Rotate a token: revoke old, create new with same metadata.
334    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        // Remove old
346        {
347            let mut store = self.store.write().await;
348            store.tokens.retain(|t| t.id != id);
349        }
350
351        // Create new with same metadata
352        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    /// Look up a token by verifying a plaintext against all stored hashes.
363    /// Returns the matching entry if found and valid.
364    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// ============================================================================
382// AuthManager
383// ============================================================================
384
385/// Unified auth manager replacing the legacy `NamespaceAccessManager`.
386///
387/// Handles:
388/// - Token lookup by hash (argon2id verification)
389/// - Scope enforcement (read/write/admin)
390/// - Namespace ACL checks
391/// - Expiry checks
392/// - Legacy `--auth-token` compatibility (mapped to wildcard token)
393#[derive(Debug)]
394pub struct AuthManager {
395    token_store: TokenStoreFile,
396    /// Legacy fallback: if set, a single token that grants wildcard access.
397    legacy_token: Option<String>,
398}
399
400/// Result of authenticating a request.
401#[derive(Debug, Clone)]
402pub struct AuthResult {
403    /// The token entry that authenticated the request.
404    pub token: TokenEntry,
405}
406
407/// Reason an auth check was denied.
408#[derive(Debug, Clone)]
409pub enum AuthDenial {
410    /// No bearer token provided.
411    MissingToken,
412    /// Token provided but not recognized.
413    InvalidToken,
414    /// Token is expired.
415    Expired { id: String },
416    /// Token lacks the required scope.
417    InsufficientScope {
418        id: String,
419        required: Scope,
420        granted: Vec<Scope>,
421    },
422    /// Token lacks access to the requested namespace.
423    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    /// Create a new AuthManager with the given store path and optional legacy token.
467    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    /// Initialize: load tokens from disk, warn about legacy token usage.
475    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    /// Authenticate a bearer token. Returns the matched entry or denial reason.
488    pub async fn authenticate(&self, bearer_token: &str) -> Result<AuthResult, AuthDenial> {
489        // Check legacy token first
490        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        // Look up in v2 store
507        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    /// Full authorization check: authenticate + scope + namespace.
521    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        // Check scope
530        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        // Check namespace ACL (if a namespace is specified)
539        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    /// Delegate to token store: create a new token.
553    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    /// Delegate to token store: list all tokens.
567    pub async fn list_tokens(&self) -> Vec<TokenEntry> {
568        self.token_store.list_tokens().await
569    }
570
571    /// Delegate to token store: revoke a token.
572    pub async fn revoke_token(&self, id: &str) -> Result<bool> {
573        self.token_store.revoke_token(id).await
574    }
575
576    /// Delegate to token store: rotate a token.
577    pub async fn rotate_token(&self, id: &str) -> Result<String> {
578        self.token_store.rotate_token(id).await
579    }
580
581    /// Check if any tokens are configured (v2 or legacy).
582    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// ============================================================================
588// Tests
589// ============================================================================
590
591#[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        // Lookup should succeed
739        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        // Wrong token should fail
746        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        // Verify it works
768        assert!(store.lookup_by_plaintext(&plaintext).await.is_some());
769
770        // Revoke
771        assert!(store.revoke_token("revokable").await.unwrap());
772
773        // Should no longer be found
774        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        // Read should succeed
797        let result = manager.authorize(&plaintext, &Scope::Read, None).await;
798        assert!(result.is_ok());
799
800        // Write should fail with InsufficientScope
801        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        // Allowed namespace
831        let result = manager
832            .authorize(&plaintext, &Scope::Read, Some("kb:claude"))
833            .await;
834        assert!(result.is_ok());
835
836        // Disallowed namespace
837        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        // Legacy token should have wildcard access
858        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        // Wrong token should fail
865        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        // Directly create an expired entry via the store
878        {
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        // Create and save
918        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        // Load from fresh instance
931        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        // Rotate
957        let new_plaintext = store.rotate_token("rotate-me").await.unwrap();
958        assert_ne!(old_plaintext, new_plaintext);
959
960        // Old should not work
961        assert!(store.lookup_by_plaintext(&old_plaintext).await.is_none());
962
963        // New should work
964        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        // Write a v1 store
975        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        // Load should migrate
992        let store = TokenStoreFile::new(store_path.to_str().unwrap().to_string());
993        store.load().await.unwrap();
994
995        // Verify migration
996        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        // Old plaintext should verify
1006        let found = store.lookup_by_plaintext("ns_test123456").await;
1007        assert!(found.is_some());
1008
1009        // Backup file should exist
1010        let backup_path = format!("{}.v1.bak", store_path.to_str().unwrap());
1011        assert!(Path::new(&backup_path).exists());
1012    }
1013}