This is an automated email from the ASF dual-hosted git repository.

FreeOnePlus pushed a commit to branch agent/sec-015-shared-auth-state
in repository https://gitbox.apache.org/repos/asf/doris-mcp-server.git

commit e8ec7876491a5ff0a0f55bf80337a30d76e84e10
Author: FreeOnePlus <[email protected]>
AuthorDate: Wed Jul 29 23:35:07 2026 +0800

    fix: share static token state across workers
---
 .env.example                             |   3 +
 .gitignore                               |   2 +
 README.md                                |  36 ++-
 doris_mcp_server/auth/token_manager.py   | 534 ++++++++++++++++++++++---------
 pyproject.toml                           |   1 +
 test/security/test_shared_token_state.py | 296 +++++++++++++++++
 tokens.json                              |   2 +
 uv.lock                                  |   2 +
 8 files changed, 716 insertions(+), 160 deletions(-)

diff --git a/.env.example b/.env.example
index 900155f..61e2508 100644
--- a/.env.example
+++ b/.env.example
@@ -87,6 +87,9 @@ TOKEN_HASH_ALGORITHM=sha256
 # Managed tokens.json writes persist only a self-describing token digest and
 # return plaintext only at creation time. Legacy plaintext files are migrated
 # to digest-only version 2.0 on first successful load.
+# Local worker processes serialize updates through tokens.json.lock and check
+# shared state before authentication. Revocation digests also disable matching
+# TOKEN_<ID> environment credentials across every worker.
 
 # ===================================================================
 # Token Management Security Configuration (NEW in v0.6.0) - CRITICAL SECURITY 
SETTINGS
diff --git a/.gitignore b/.gitignore
index 92aa8a1..e25cc12 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,4 +20,6 @@ env.bak/
 venv.bak/
 .idea/
 .coverage
+.coverage.*
 coverage.xml
+tokens.json.lock
diff --git a/README.md b/README.md
index 003ec03..d06e8a0 100644
--- a/README.md
+++ b/README.md
@@ -174,6 +174,8 @@ field and do not persist or propagate the token in URLs.
   - Complete audit logging
   - **Digest-only persistence** to `tokens.json`; plaintext is returned once
     when a token is created
+  - **Multi-worker consistency** through a process-shared lock, atomic
+    read-modify-write updates, and digest-only revocation records
 
 > **🔐 Security Note**: The interface is designed for localhost administration 
 > only. It cannot be accessed remotely, ensuring maximum security for token 
 > management operations.
 
@@ -1860,16 +1862,27 @@ Use `WORKERS=1` with Doris-backed OAuth. `WORKERS=0` 
expands to CPU count and fa
 **A:** The hot reload system is designed for enterprise production 
environments with comprehensive safety measures:
 
 **How It Works:**
-- **File Monitoring**: Checks tokens.json every 10 seconds for modifications
-- **Immediate Validation**: New tokens are validated including database 
connectivity
-- **Atomic Updates**: All-or-nothing configuration updates
-- **Rollback Protection**: Automatic rollback if any token validation fails
+- **Request-time synchronization**: Every token lookup compares the shared
+  file signature, so another local worker's create or revoke is observed on
+  the next authenticated request rather than waiting for the polling interval
+- **Background monitoring**: A 10-second monitor still refreshes idle workers
+- **Serialized updates**: `tokens.json.lock` protects every managed
+  read-modify-write operation across local worker processes
+- **Atomic updates**: A same-directory temporary file is flushed and replaced
+  atomically with owner-only permissions
+- **Rollback protection**: Invalid externally edited state does not partially
+  replace a worker's current in-memory view
+- **Shared revocation**: `revoked_tokens` stores only token digests and also
+  disables matching `TOKEN_<ID>` environment credentials in every worker
 
 **Safety Features:**
-- **Backup and Restore**: Current configuration backed up before changes
-- **Connection Testing**: Database connections tested before applying changes
-- **Error Isolation**: Invalid tokens don't affect existing valid tokens
-- **Audit Logging**: Complete trail of all configuration changes
+- **No lost updates**: Concurrent create/revoke operations reload the latest
+  document while holding the process-shared lock
+- **No bearer-token plaintext persistence**: Live and revoked bearer values
+  are represented only by self-describing digests
+- **Owner-only state files**: Managed state and lock files use mode `0600`
+- **Error isolation**: Invalid state is rejected before it can replace the
+  complete local token map
 
 **Best Practices:**
 ```bash
@@ -1907,6 +1920,13 @@ environment secret or generate a bearer/digest pair 
offline as shown above.
 Manual `tokens.json` entries must use `token_digest`; plaintext `token` entries
 exist only for one-way migration from version 1.
 
+The file backend coordinates multiple worker processes on one host. Every
+worker must use the same `TOKEN_FILE_PATH`, and the underlying filesystem must
+provide reliable file locking and atomic rename semantics. Multiple hosts or
+containers without a shared locking filesystem require an external
+transactional state backend; copying separate `tokens.json` files does not
+provide cluster-wide revocation.
+
 **Administrative Endpoints (Secure, Local Access Only):**
 
 🛡️ **SECURITY**: These endpoints are protected by comprehensive security 
controls and are **disabled by default**.
diff --git a/doris_mcp_server/auth/token_manager.py 
b/doris_mcp_server/auth/token_manager.py
index 32f55ec..2416377 100644
--- a/doris_mcp_server/auth/token_manager.py
+++ b/doris_mcp_server/auth/token_manager.py
@@ -27,10 +27,13 @@ import json
 import os
 import secrets
 import tempfile
+from contextlib import contextmanager
 from dataclasses import dataclass, field
 from datetime import datetime, timedelta, timezone
-from typing import Dict, List, Optional, Any
 from pathlib import Path
+from typing import Any, Dict, Iterator, List, Optional
+
+from filelock import FileLock
 
 from ..utils.logger import get_logger
 from ..utils.secret_policy import (
@@ -97,9 +100,19 @@ class TokenManager:
         self._tokens: Dict[str, TokenInfo] = {}  # token_hash -> TokenInfo
         self._token_ids: Dict[str, str] = {}     # token_id -> token_hash
         self._digest_algorithms: set[str] = set()
+        self._revoked_tokens: Dict[str, Dict[str, str]] = {}
         
         # Configuration
         self.token_file_path = getattr(config.security, 'token_file_path', 
'tokens.json')
+        self._token_file = Path(self.token_file_path)
+        self._token_lock_file = self._token_file.with_name(
+            f"{self._token_file.name}.lock"
+        )
+        self._token_file_lock = FileLock(
+            str(self._token_lock_file),
+            timeout=30,
+            mode=0o600,
+        )
         self.enable_token_expiry = getattr(config.security, 
'enable_token_expiry', True)
         self.default_token_expiry_hours = getattr(config.security, 
'default_token_expiry_hours', 24 * 30)  # 30 days
         self.token_hash_algorithm = normalize_token_hash_algorithm(
@@ -110,6 +123,7 @@ class TokenManager:
         self.enable_hot_reload = True
         self.hot_reload_interval = 10  # Check every 10 seconds
         self._file_last_modified = 0.0
+        self._file_signature: Optional[tuple[int, int, int, int]] = None
         self._hot_reload_task: Optional[asyncio.Task[None]] = None
         
         # Load tokens from configuration
@@ -142,6 +156,70 @@ class TokenManager:
                 f"'{RESERVED_DORIS_OAUTH_TOKEN_PREFIX}'"
             )
 
+    @contextmanager
+    def _shared_state_lock(self) -> Iterator[None]:
+        """Serialize read-modify-write operations across worker processes."""
+        with self._token_file_lock:
+            os.chmod(self._token_lock_file, 0o600)
+            yield
+
+    def _current_file_signature(self) -> Optional[tuple[int, int, int, int]]:
+        """Return a signature that also changes after an atomic replacement."""
+        try:
+            file_stat = self._token_file.stat()
+        except FileNotFoundError:
+            return None
+        return (
+            file_stat.st_dev,
+            file_stat.st_ino,
+            file_stat.st_size,
+            file_stat.st_mtime_ns,
+        )
+
+    def _reload_tokens_from_sources(self) -> None:
+        """Replace local state from the environment and shared token file."""
+        previous_state = (
+            self._tokens,
+            self._token_ids,
+            self._digest_algorithms,
+            self._revoked_tokens,
+            self._file_signature,
+            self._file_last_modified,
+        )
+        self._tokens = {}
+        self._token_ids = {}
+        self._digest_algorithms = set()
+        self._revoked_tokens = {}
+        try:
+            self._load_tokens_from_env()
+            if self._token_file.exists():
+                self._load_tokens_from_file()
+            self._update_file_modified_time()
+        except Exception:
+            (
+                self._tokens,
+                self._token_ids,
+                self._digest_algorithms,
+                self._revoked_tokens,
+                self._file_signature,
+                self._file_last_modified,
+            ) = previous_state
+            raise
+
+    def _synchronize_shared_state(self) -> bool:
+        """Reload immediately when another worker atomically changes the 
file."""
+        if self._current_file_signature() == self._file_signature:
+            return False
+        with self._shared_state_lock():
+            if self._current_file_signature() == self._file_signature:
+                return False
+            self._reload_tokens_from_sources()
+        self.logger.info(
+            "Synchronized %s static tokens from shared state",
+            len(self._tokens),
+        )
+        return True
+
     @staticmethod
     def _parse_datetime(value: Any, *, setting: str) -> Optional[datetime]:
         """Parse an RFC 3339 timestamp into the manager's naive UTC form."""
@@ -247,6 +325,15 @@ class TokenManager:
                 token_hash = self._hash_token(raw_token)
             self._digest_algorithms.add(token_hash.partition(":")[0])
             
+            # A later source wins cleanly when the same token ID is rotated.
+            previous_hash = self._token_ids.get(token_info.token_id)
+            if (
+                previous_hash
+                and previous_hash != token_hash
+                and previous_hash in self._tokens
+            ):
+                del self._tokens[previous_hash]
+
             # Store token
             self._tokens[token_hash] = token_info
             self._token_ids[token_info.token_id] = token_hash
@@ -261,13 +348,12 @@ class TokenManager:
     
     def _load_tokens(self) -> None:
         """Load tokens from configuration sources"""
-        # 1. Load from environment variables
-        self._load_tokens_from_env()
-        
-        # 2. Load from token file if exists
-        if os.path.exists(self.token_file_path):
-            self._load_tokens_from_file()
-        
+        if self._token_file.exists():
+            with self._shared_state_lock():
+                self._reload_tokens_from_sources()
+        else:
+            self._reload_tokens_from_sources()
+
         self.logger.info(f"Token loading completed, total tokens: 
{len(self._tokens)}")
     
     def _load_tokens_from_env(self) -> None:
@@ -333,8 +419,18 @@ class TokenManager:
                 return
             if not isinstance(tokens_list, list):
                 raise ValueError("Static token file must contain a tokens 
array")
+            revoked_tokens = (
+                tokens_data.get("revoked_tokens", [])
+                if isinstance(tokens_data, dict)
+                else []
+            )
+            if not isinstance(revoked_tokens, list):
+                raise ValueError(
+                    "Static token file revoked_tokens must be an array"
+                )
 
             persisted_tokens = []
+            persisted_revocations = []
             needs_migration = (
                 not isinstance(tokens_data, dict)
                 or tokens_data.get("version") != "2.0"
@@ -350,8 +446,28 @@ class TokenManager:
                 )
                 needs_migration = needs_migration or had_raw_token
 
+            for revoked_token in revoked_tokens:
+                sanitized = self._sanitize_revoked_token(revoked_token)
+                token_hash = sanitized["token_digest"]
+                self._revoked_tokens[token_hash] = sanitized
+                self._digest_algorithms.add(token_hash.partition(":")[0])
+                persisted_revocations.append(sanitized)
+
+                revoked_token_info = self._tokens.get(token_hash)
+                if revoked_token_info is not None:
+                    del self._tokens[token_hash]
+                if (
+                    revoked_token_info is not None
+                    and self._token_ids.get(revoked_token_info.token_id)
+                    == token_hash
+                ):
+                    self._token_ids.pop(revoked_token_info.token_id, None)
+
             if needs_migration:
-                migrated_data = self._token_file_document(persisted_tokens)
+                migrated_data = self._token_file_document(
+                    persisted_tokens,
+                    persisted_revocations,
+                )
                 try:
                     self._atomic_write_token_file(
                         Path(self.token_file_path),
@@ -376,13 +492,29 @@ class TokenManager:
     
     def _hash_token(self, token: str, algorithm: Optional[str] = None) -> str:
         """Hash token for secure storage"""
-        return build_token_digest(token, algorithm or 
self.token_hash_algorithm)
+        return str(
+            build_token_digest(
+                token,
+                algorithm or self.token_hash_algorithm,
+            )
+        )
+
+    def _candidate_token_digests(self, token: str) -> List[str]:
+        algorithms = self._digest_algorithms | {self.token_hash_algorithm}
+        return [
+            self._hash_token(token, algorithm)
+            for algorithm in sorted(algorithms)
+        ]
 
     def _lookup_token(self, token: str) -> tuple[str, Optional[TokenInfo]]:
         """Look up a raw token against every digest algorithm in the store."""
-        algorithms = self._digest_algorithms or {self.token_hash_algorithm}
-        for algorithm in sorted(algorithms):
-            token_hash = self._hash_token(token, algorithm)
+        candidate_digests = self._candidate_token_digests(token)
+        if any(
+            token_hash in self._revoked_tokens
+            for token_hash in candidate_digests
+        ):
+            return "", None
+        for token_hash in candidate_digests:
             token_info = self._tokens.get(token_hash)
             if token_info is not None:
                 return token_hash, token_info
@@ -391,6 +523,8 @@ class TokenManager:
     async def validate_token(self, token: str) -> TokenValidationResult:
         """Validate token and return user information"""
         try:
+            self._synchronize_shared_state()
+
             # Find token info
             _token_hash, token_info = self._lookup_token(token)
             if not token_info:
@@ -442,10 +576,6 @@ class TokenManager:
     ) -> str:
         """Create a new token"""
         try:
-            # Check if token_id already exists
-            if token_id in self._token_ids:
-                raise ValueError(f"Token ID '{token_id}' already exists")
-            
             # Generate or use provided token
             if custom_token:
                 raw_token = custom_token
@@ -471,27 +601,35 @@ class TokenManager:
                 description=description,
                 database_config=database_config
             )
-            
-            # Hash and store token
-            token_hash = self._hash_token(raw_token)
-            if token_hash in self._tokens:
-                raise ValueError("Token value already exists")
-            self._tokens[token_hash] = token_info
-            self._token_ids[token_id] = token_hash
-            self._digest_algorithms.add(self.token_hash_algorithm)
-            
-            self.logger.info(f"Created new token '{token_id}'")
-            
-            # Save token to file
-            try:
+
+            with self._shared_state_lock():
+                self._reload_tokens_from_sources()
+                if token_id in self._token_ids:
+                    raise ValueError(
+                        f"Token ID '{token_id}' already exists"
+                    )
+
+                candidate_digests = self._candidate_token_digests(raw_token)
+                if any(
+                    digest in self._revoked_tokens
+                    for digest in candidate_digests
+                ):
+                    raise ValueError(
+                        "A revoked token value cannot be reused"
+                    )
+                if any(
+                    digest in self._tokens
+                    for digest in candidate_digests
+                ):
+                    raise ValueError("Token value already exists")
+
+                token_hash = self._hash_token(raw_token)
                 self._save_token_to_file(token_id, token_hash, token_info)
-            except Exception:
-                self._tokens.pop(token_hash, None)
-                self._token_ids.pop(token_id, None)
-                self._digest_algorithms = {
-                    digest.partition(":")[0] for digest in self._tokens
-                }
-                raise
+                self._tokens[token_hash] = token_info
+                self._token_ids[token_id] = token_hash
+                self._digest_algorithms.add(self.token_hash_algorithm)
+
+            self.logger.info(f"Created new token '{token_id}'")
             
             return raw_token
             
@@ -502,18 +640,25 @@ class TokenManager:
     async def revoke_token(self, token_id: str) -> bool:
         """Revoke a token by token ID"""
         try:
-            if token_id not in self._token_ids:
-                self.logger.warning(f"Token ID '{token_id}' not found")
-                return False
-            
-            # Persist the revocation before changing live state. Otherwise a
-            # failed file write would let the token reappear after restart.
-            token_hash = self._token_ids[token_id]
-            self._remove_token_from_file(token_id)
-
-            if token_hash in self._tokens:
-                del self._tokens[token_hash]
-            del self._token_ids[token_id]
+            with self._shared_state_lock():
+                self._reload_tokens_from_sources()
+                if token_id not in self._token_ids:
+                    self.logger.warning(
+                        f"Token ID '{token_id}' not found"
+                    )
+                    return False
+
+                # Persist before changing live state. The shared revocation
+                # record also disables environment-provisioned credentials.
+                token_hash = self._token_ids[token_id]
+                revocation = self._remove_token_from_file(
+                    token_id,
+                    token_hash,
+                )
+                self._tokens.pop(token_hash, None)
+                self._token_ids.pop(token_id, None)
+                self._revoked_tokens[token_hash] = revocation
+                self._digest_algorithms.add(token_hash.partition(":")[0])
             
             self.logger.info(f"Revoked token '{token_id}'")
             return True
@@ -525,15 +670,20 @@ class TokenManager:
     def _save_tokens_to_file(self) -> None:
         """Save current tokens to JSON file"""
         try:
-            tokens_list = [
-                self._token_info_to_config(token_hash, token_info)
-                for token_hash, token_info in self._tokens.items()
-            ]
-            file_content = self._token_file_document(tokens_list)
-            self._atomic_write_token_file(
-                Path(self.token_file_path),
-                file_content,
-            )
+            with self._shared_state_lock():
+                self._reload_tokens_from_sources()
+                tokens_list = [
+                    self._token_info_to_config(token_hash, token_info)
+                    for token_hash, token_info in self._tokens.items()
+                ]
+                file_content = self._token_file_document(
+                    tokens_list,
+                    list(self._revoked_tokens.values()),
+                )
+                self._atomic_write_token_file(
+                    Path(self.token_file_path),
+                    file_content,
+                )
             self.logger.info(f"Saved {len(tokens_list)} tokens to file: 
{self.token_file_path}")
             
         except Exception as e:
@@ -548,7 +698,7 @@ class TokenManager:
         """Save one new token as a digest-only record."""
         try:
             # Load existing file
-            existing_data: Dict[str, Any] = {"tokens": []}
+            existing_data: Any = {"tokens": []}
             if os.path.exists(self.token_file_path):
                 try:
                     with open(self.token_file_path, 'r', encoding='utf-8') as 
f:
@@ -557,10 +707,19 @@ class TokenManager:
                     raise ValueError(
                         f"Could not load existing token file: {e}"
                     ) from e
+            if isinstance(existing_data, list):
+                existing_data = {"tokens": existing_data}
+            if not isinstance(existing_data, dict):
+                raise ValueError("Static token file must contain an object")
             
             # Ensure tokens list exists
             if 'tokens' not in existing_data or not 
isinstance(existing_data['tokens'], list):
                 existing_data['tokens'] = []
+            revoked_tokens = existing_data.get("revoked_tokens", [])
+            if not isinstance(revoked_tokens, list):
+                raise ValueError(
+                    "Static token file revoked_tokens must be an array"
+                )
             
             # Check if token already exists in file
             token_exists = False
@@ -586,9 +745,16 @@ class TokenManager:
                 self._sanitize_persisted_token(token_config)
                 for token_config in existing_data['tokens']
             ]
+            revoked_tokens = [
+                self._sanitize_revoked_token(revoked_token)
+                for revoked_token in revoked_tokens
+            ]
 
             # Update metadata
-            existing_data = self._token_file_document(existing_data['tokens'])
+            existing_data = self._token_file_document(
+                existing_data['tokens'],
+                revoked_tokens,
+            )
             self._atomic_write_token_file(
                 Path(self.token_file_path),
                 existing_data,
@@ -599,16 +765,30 @@ class TokenManager:
             self.logger.error(f"Failed to save token '{token_id}' to file: 
{e}")
             raise
     
-    def _token_file_document(self, tokens: List[Dict[str, Any]]) -> Dict[str, 
Any]:
+    def _token_file_document(
+        self,
+        tokens: List[Dict[str, Any]],
+        revoked_tokens: Optional[List[Dict[str, str]]] = None,
+    ) -> Dict[str, Any]:
         """Build the versioned digest-only token file document."""
         return {
             "version": "2.0",
-            "description": "Doris MCP Server digest-only token configuration 
file",
+            "description": (
+                "Doris MCP Server digest-only shared token state"
+            ),
             "updated_at": datetime.utcnow().isoformat() + "Z",
             "tokens": tokens,
+            "revoked_tokens": sorted(
+                revoked_tokens or [],
+                key=lambda entry: (
+                    entry["revoked_at"],
+                    entry["token_id"],
+                ),
+            ),
             "notes": [
                 "Bearer token plaintext is returned only when a token is 
created.",
                 "token_digest is self-describing and may use sha256 or 
sha512.",
+                "revoked_tokens is shared by every worker and prevents token 
reuse.",
                 "Do not replace token_digest with a plaintext token.",
             ],
         }
@@ -691,6 +871,39 @@ class TokenManager:
             )
         return sanitized
 
+    def _sanitize_revoked_token(
+        self,
+        revoked_token: Dict[str, Any],
+    ) -> Dict[str, str]:
+        """Validate and whitelist a shared revocation record."""
+        if not isinstance(revoked_token, dict):
+            raise ValueError("Static token revocation entries must be objects")
+        token_id = str(revoked_token.get("token_id") or "").strip()
+        if not token_id:
+            raise ValueError("Static token revocation requires token_id")
+        raw_digest = revoked_token.get("token_digest")
+        if not isinstance(raw_digest, str):
+            raise ValueError(
+                f"revoked static token '{token_id}' token_digest is required"
+            )
+        token_digest = normalize_token_digest(
+            raw_digest,
+            setting=f"revoked static token '{token_id}' token_digest",
+        )
+        revoked_at = self._parse_datetime(
+            revoked_token.get("revoked_at"),
+            setting=f"revoked static token '{token_id}' revoked_at",
+        )
+        if revoked_at is None:
+            raise ValueError(
+                f"revoked static token '{token_id}' revoked_at is required"
+            )
+        return {
+            "token_id": token_id,
+            "token_digest": token_digest,
+            "revoked_at": revoked_at.isoformat() + "Z",
+        }
+
     def _token_info_to_config(
         self,
         token_hash: str,
@@ -732,35 +945,72 @@ class TokenManager:
         
         return token_config
     
-    def _remove_token_from_file(self, token_id: str) -> None:
-        """Remove a token from the JSON file"""
+    def _remove_token_from_file(
+        self,
+        token_id: str,
+        token_hash: str,
+    ) -> Dict[str, str]:
+        """Remove a live record and persist a digest-only revocation."""
         try:
-            if not os.path.exists(self.token_file_path):
-                return
-            
-            # Load existing file
-            with open(self.token_file_path, 'r', encoding='utf-8') as f:
-                existing_data = json.load(f)
-            
-            if 'tokens' not in existing_data or not 
isinstance(existing_data['tokens'], list):
-                return
-            
-            # Remove the token
-            original_count = len(existing_data['tokens'])
-            existing_data['tokens'] = [
-                self._sanitize_persisted_token(token)
-                for token in existing_data['tokens']
-                if token.get('token_id') != token_id
-            ]
-            
-            if len(existing_data['tokens']) < original_count:
-                # Update metadata
-                existing_data = 
self._token_file_document(existing_data['tokens'])
-                self._atomic_write_token_file(
-                    Path(self.token_file_path),
-                    existing_data,
+            existing_data: Any = {"tokens": [], "revoked_tokens": []}
+            if self._token_file.exists():
+                with self._token_file.open("r", encoding="utf-8") as 
token_file:
+                    existing_data = json.load(token_file)
+            if isinstance(existing_data, list):
+                existing_data = {"tokens": existing_data}
+            if not isinstance(existing_data, dict):
+                raise ValueError("Static token file must contain an object")
+
+            tokens = existing_data.get("tokens", [])
+            revoked_tokens = existing_data.get("revoked_tokens", [])
+            if not isinstance(tokens, list):
+                raise ValueError(
+                    "Static token file must contain a tokens array"
                 )
-                self.logger.info(f"Removed token '{token_id}' from file: 
{self.token_file_path}")
+            if not isinstance(revoked_tokens, list):
+                raise ValueError(
+                    "Static token file revoked_tokens must be an array"
+                )
+
+            sanitized_tokens = []
+            for token in tokens:
+                sanitized = self._sanitize_persisted_token(token)
+                if (
+                    sanitized["token_id"] != token_id
+                    and sanitized["token_digest"] != token_hash
+                ):
+                    sanitized_tokens.append(sanitized)
+
+            sanitized_revocations = [
+                self._sanitize_revoked_token(revoked_token)
+                for revoked_token in revoked_tokens
+            ]
+            revocation = self._sanitize_revoked_token(
+                {
+                    "token_id": token_id,
+                    "token_digest": token_hash,
+                    "revoked_at": datetime.utcnow().isoformat() + "Z",
+                }
+            )
+            sanitized_revocations = [
+                item
+                for item in sanitized_revocations
+                if item["token_digest"] != token_hash
+            ]
+            sanitized_revocations.append(revocation)
+
+            self._atomic_write_token_file(
+                Path(self.token_file_path),
+                self._token_file_document(
+                    sanitized_tokens,
+                    sanitized_revocations,
+                ),
+            )
+            self.logger.info(
+                f"Persisted revocation for '{token_id}' in "
+                f"{self.token_file_path}"
+            )
+            return revocation
             
         except Exception as e:
             self.logger.error(f"Failed to remove token '{token_id}' from file: 
{e}")
@@ -768,6 +1018,7 @@ class TokenManager:
     
     async def list_tokens(self) -> List[Dict[str, Any]]:
         """List all tokens (without sensitive data)"""
+        self._synchronize_shared_state()
         tokens = []
         
         for token_hash, token_info in self._tokens.items():
@@ -804,20 +1055,24 @@ class TokenManager:
         """Remove expired tokens and return count"""
         if not self.enable_token_expiry:
             return 0
-        
-        now = datetime.utcnow()
-        expired_tokens = []
-        
-        # Find expired tokens
-        for token_hash, token_info in self._tokens.items():
-            if token_info.expires_at and now > token_info.expires_at:
-                expired_tokens.append((token_hash, token_info.token_id))
-        
-        # Remove expired tokens
-        for token_hash, token_id in expired_tokens:
-            del self._tokens[token_hash]
-            if token_id in self._token_ids:
-                del self._token_ids[token_id]
+
+        with self._shared_state_lock():
+            self._reload_tokens_from_sources()
+            now = datetime.utcnow()
+            expired_tokens = [
+                (token_hash, token_info.token_id)
+                for token_hash, token_info in self._tokens.items()
+                if token_info.expires_at and now > token_info.expires_at
+            ]
+
+            for token_hash, token_id in expired_tokens:
+                revocation = self._remove_token_from_file(
+                    token_id,
+                    token_hash,
+                )
+                self._tokens.pop(token_hash, None)
+                self._token_ids.pop(token_id, None)
+                self._revoked_tokens[token_hash] = revocation
         
         if expired_tokens:
             self.logger.info(f"Cleaned up {len(expired_tokens)} expired 
tokens")
@@ -828,14 +1083,19 @@ class TokenManager:
         """Save current tokens to JSON file"""
         try:
             target_path = Path(file_path or self.token_file_path)
-            tokens_list = [
-                self._token_info_to_config(token_hash, token_info)
-                for token_hash, token_info in self._tokens.items()
-            ]
-            self._atomic_write_token_file(
-                target_path,
-                self._token_file_document(tokens_list),
-            )
+            with self._shared_state_lock():
+                self._reload_tokens_from_sources()
+                tokens_list = [
+                    self._token_info_to_config(token_hash, token_info)
+                    for token_hash, token_info in self._tokens.items()
+                ]
+                self._atomic_write_token_file(
+                    target_path,
+                    self._token_file_document(
+                        tokens_list,
+                        list(self._revoked_tokens.values()),
+                    ),
+                )
             self.logger.info(f"Saved {len(tokens_list)} tokens to file: 
{target_path}")
             return True
             
@@ -853,6 +1113,7 @@ class TokenManager:
             DatabaseConfig if token exists and has database binding, None 
otherwise
         """
         try:
+            self._synchronize_shared_state()
             _token_hash, token_info = self._lookup_token(token)
             
             if not token_info or not token_info.is_active:
@@ -870,6 +1131,7 @@ class TokenManager:
     
     def get_token_stats(self) -> Dict[str, Any]:
         """Get token statistics"""
+        self._synchronize_shared_state()
         now = datetime.utcnow()
         total_tokens = len(self._tokens)
         active_tokens = sum(1 for info in self._tokens.values() if 
info.is_active)
@@ -893,10 +1155,9 @@ class TokenManager:
         """Start hot reload monitoring task"""
         if self._hot_reload_task:
             return  # Already running
-        
-        # Update initial file modification time
-        self._update_file_modified_time()
-        
+
+        # _load_tokens() already captured a signature for the exact state held
+        # in memory. Re-statting here could hide a change made in between.
         # Start monitoring task
         self._hot_reload_task = asyncio.create_task(self._hot_reload_monitor())
         self.logger.info(f"Started hot reload monitoring for 
{self.token_file_path}")
@@ -911,8 +1172,11 @@ class TokenManager:
     def _update_file_modified_time(self) -> None:
         """Update the last modified time of tokens file"""
         try:
-            if os.path.exists(self.token_file_path):
-                self._file_last_modified = 
os.path.getmtime(self.token_file_path)
+            self._file_signature = self._current_file_signature()
+            if self._file_signature is None:
+                self._file_last_modified = 0.0
+            else:
+                self._file_last_modified = self._token_file.stat().st_mtime
         except Exception as e:
             self.logger.debug(f"Failed to get file modification time: {e}")
     
@@ -921,41 +1185,7 @@ class TokenManager:
         while True:
             try:
                 await asyncio.sleep(self.hot_reload_interval)
-                
-                if not os.path.exists(self.token_file_path):
-                    continue
-                
-                # Check if file was modified
-                current_mtime = os.path.getmtime(self.token_file_path)
-                if current_mtime > self._file_last_modified:
-                    self.logger.info(f"Detected changes in 
{self.token_file_path}, reloading tokens...")
-                    
-                    try:
-                        # Backup current tokens
-                        old_tokens = self._tokens.copy()
-                        old_token_ids = self._token_ids.copy()
-                        old_digest_algorithms = self._digest_algorithms.copy()
-                        
-                        # Clear and reload
-                        self._tokens.clear()
-                        self._token_ids.clear()
-                        self._digest_algorithms.clear()
-                        
-                        # Environment credentials remain effective across file 
reloads.
-                        self._load_tokens_from_env()
-                        self._load_tokens_from_file()
-                        
-                        # Update modification time
-                        self._update_file_modified_time()
-                        
-                        self.logger.info(f"Hot reload completed, 
{len(self._tokens)} tokens loaded")
-                        
-                    except Exception as reload_error:
-                        # Restore backup on failure
-                        self.logger.error(f"Hot reload failed, restoring 
previous tokens: {reload_error}")
-                        self._tokens = old_tokens
-                        self._token_ids = old_token_ids
-                        self._digest_algorithms = old_digest_algorithms
+                self._synchronize_shared_state()
                 
             except asyncio.CancelledError:
                 self.logger.info("Hot reload monitor stopped")
diff --git a/pyproject.toml b/pyproject.toml
index fbe49a8..646c82f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -55,6 +55,7 @@ dependencies = [
     "aiofiles>=23.0.0",
     "aiohttp>=3.9.0",
     "aioredis>=2.0.0",
+    "filelock>=3.16.1,<4.0.0",
     # Data processing
     "pandas>=2.0.0",
     "numpy>=1.24.0",
diff --git a/test/security/test_shared_token_state.py 
b/test/security/test_shared_token_state.py
new file mode 100644
index 0000000..cdf780e
--- /dev/null
+++ b/test/security/test_shared_token_state.py
@@ -0,0 +1,296 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import asyncio
+import json
+import multiprocessing
+import os
+import secrets
+import stat
+from concurrent.futures import ProcessPoolExecutor
+from pathlib import Path
+
+import pytest
+
+from doris_mcp_server.auth.token_manager import TokenManager
+from doris_mcp_server.utils.config import DorisConfig
+from doris_mcp_server.utils.secret_policy import (
+    is_static_token_environment_variable,
+)
+
+
+def _config(
+    token_path: str | Path,
+    *,
+    algorithm: str = "sha256",
+) -> DorisConfig:
+    config = DorisConfig()
+    config.security.token_file_path = str(token_path)
+    config.security.token_hash_algorithm = algorithm
+    return config
+
+
+def _clear_static_token_environment(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    for name in list(os.environ):
+        if is_static_token_environment_variable(name):
+            monkeypatch.delenv(name, raising=False)
+
+
+async def _create_token_in_worker(
+    token_path: str,
+    token_id: str,
+    raw_token: str,
+) -> str:
+    manager = TokenManager(_config(token_path))
+    try:
+        await manager.create_token(
+            token_id,
+            custom_token=raw_token,
+        )
+        return "created"
+    except ValueError as exc:
+        return str(exc)
+    finally:
+        manager.stop_hot_reload()
+
+
+def _process_create_token(
+    token_path: str,
+    token_id: str,
+    raw_token: str,
+) -> str:
+    return asyncio.run(_create_token_in_worker(token_path, token_id, 
raw_token))
+
+
+async def _revoke_token_in_worker(
+    token_path: str,
+    token_id: str,
+) -> bool:
+    manager = TokenManager(_config(token_path))
+    try:
+        return await manager.revoke_token(token_id)
+    finally:
+        manager.stop_hot_reload()
+
+
+def _process_revoke_token(token_path: str, token_id: str) -> bool:
+    return asyncio.run(_revoke_token_in_worker(token_path, token_id))
+
+
[email protected]
+async def test_workers_observe_create_and_revoke_on_the_next_access(
+    tmp_path: Path,
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    _clear_static_token_environment(monkeypatch)
+    token_path = tmp_path / "tokens.json"
+    first_worker = TokenManager(_config(token_path, algorithm="sha512"))
+    second_worker = TokenManager(_config(token_path))
+    first_worker.hot_reload_interval = 3600
+    second_worker.hot_reload_interval = 3600
+    try:
+        raw_token = await first_worker.create_token("shared-service")
+
+        observed = await second_worker.validate_token(raw_token)
+        assert observed.is_valid is True
+        assert observed.token_info is not None
+        assert observed.token_info.token_id == "shared-service"
+
+        assert await second_worker.revoke_token("shared-service") is True
+        rejected = await first_worker.validate_token(raw_token)
+        assert rejected.is_valid is False
+        assert rejected.error_message == "Invalid token"
+
+        shared_state = json.loads(token_path.read_text(encoding="utf-8"))
+        assert shared_state["tokens"] == []
+        assert len(shared_state["revoked_tokens"]) == 1
+        assert raw_token not in token_path.read_text(encoding="utf-8")
+        assert stat.S_IMODE(token_path.stat().st_mode) == 0o600
+        assert stat.S_IMODE(Path(f"{token_path}.lock").stat().st_mode) == 0o600
+    finally:
+        first_worker.stop_hot_reload()
+        second_worker.stop_hot_reload()
+
+
[email protected]
+async def test_environment_token_revocation_is_shared_and_persistent(
+    tmp_path: Path,
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    _clear_static_token_environment(monkeypatch)
+    raw_token = secrets.token_urlsafe(40)
+    monkeypatch.setenv("TOKEN_SHARED_ENV", raw_token)
+    token_path = tmp_path / "tokens.json"
+    first_worker = TokenManager(_config(token_path, algorithm="sha512"))
+    second_worker = TokenManager(_config(token_path))
+    try:
+        assert (await second_worker.validate_token(raw_token)).is_valid
+        assert await first_worker.revoke_token("shared_env") is True
+        assert (await second_worker.validate_token(raw_token)).is_valid is 
False
+
+        restarted_worker = TokenManager(_config(token_path))
+        try:
+            assert (await restarted_worker.validate_token(raw_token)).is_valid 
is False
+            with pytest.raises(
+                ValueError,
+                match="revoked token value",
+            ):
+                await restarted_worker.create_token(
+                    "replayed",
+                    custom_token=raw_token,
+                )
+        finally:
+            restarted_worker.stop_hot_reload()
+
+        stored_text = token_path.read_text(encoding="utf-8")
+        stored = json.loads(stored_text)
+        assert stored["tokens"] == []
+        assert stored["revoked_tokens"][0]["token_id"] == "shared_env"
+        assert raw_token not in stored_text
+    finally:
+        first_worker.stop_hot_reload()
+        second_worker.stop_hot_reload()
+
+
[email protected]
+async def test_concurrent_worker_creates_do_not_lose_updates(
+    tmp_path: Path,
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    _clear_static_token_environment(monkeypatch)
+    token_path = tmp_path / "tokens.json"
+    entries = [(f"worker-{index}", secrets.token_urlsafe(40)) for index in 
range(8)]
+    context = multiprocessing.get_context("spawn")
+
+    with ProcessPoolExecutor(
+        max_workers=4,
+        mp_context=context,
+    ) as executor:
+        futures = [
+            executor.submit(
+                _process_create_token,
+                str(token_path),
+                token_id,
+                raw_token,
+            )
+            for token_id, raw_token in entries
+        ]
+        assert [future.result(timeout=30) for future in futures] == 
["created"] * len(
+            entries
+        )
+
+    manager = TokenManager(_config(token_path))
+    try:
+        listed = await manager.list_tokens()
+        assert {item["token_id"] for item in listed} == {
+            token_id for token_id, _raw_token in entries
+        }
+        for _token_id, raw_token in entries:
+            assert (await manager.validate_token(raw_token)).is_valid
+    finally:
+        manager.stop_hot_reload()
+
+    stored_text = token_path.read_text(encoding="utf-8")
+    stored = json.loads(stored_text)
+    assert len(stored["tokens"]) == len(entries)
+    assert all(raw_token not in stored_text for _, raw_token in entries)
+
+
[email protected]
+async def test_concurrent_create_and_revoke_preserve_both_updates(
+    tmp_path: Path,
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    _clear_static_token_environment(monkeypatch)
+    token_path = tmp_path / "tokens.json"
+    old_token = secrets.token_urlsafe(40)
+    new_token = secrets.token_urlsafe(40)
+    manager = TokenManager(_config(token_path))
+    try:
+        await manager.create_token(
+            "old-service",
+            custom_token=old_token,
+        )
+    finally:
+        manager.stop_hot_reload()
+
+    context = multiprocessing.get_context("spawn")
+    with ProcessPoolExecutor(
+        max_workers=2,
+        mp_context=context,
+    ) as executor:
+        create_future = executor.submit(
+            _process_create_token,
+            str(token_path),
+            "new-service",
+            new_token,
+        )
+        revoke_future = executor.submit(
+            _process_revoke_token,
+            str(token_path),
+            "old-service",
+        )
+        assert create_future.result(timeout=30) == "created"
+        assert revoke_future.result(timeout=30) is True
+
+    verifier = TokenManager(_config(token_path))
+    try:
+        assert (await verifier.validate_token(old_token)).is_valid is False
+        assert (await verifier.validate_token(new_token)).is_valid is True
+    finally:
+        verifier.stop_hot_reload()
+
+    stored = json.loads(token_path.read_text(encoding="utf-8"))
+    assert [token["token_id"] for token in stored["tokens"]] == ["new-service"]
+    assert [revoked["token_id"] for revoked in stored["revoked_tokens"]] == [
+        "old-service"
+    ]
+
+
[email protected]
+async def test_concurrent_duplicate_token_id_has_one_winner(
+    tmp_path: Path,
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    _clear_static_token_environment(monkeypatch)
+    token_path = tmp_path / "tokens.json"
+    entries = [
+        ("same-id", secrets.token_urlsafe(40)),
+        ("same-id", secrets.token_urlsafe(40)),
+    ]
+    context = multiprocessing.get_context("spawn")
+
+    with ProcessPoolExecutor(
+        max_workers=2,
+        mp_context=context,
+    ) as executor:
+        futures = [
+            executor.submit(
+                _process_create_token,
+                str(token_path),
+                token_id,
+                raw_token,
+            )
+            for token_id, raw_token in entries
+        ]
+        results = [future.result(timeout=30) for future in futures]
+
+    assert results.count("created") == 1
+    assert sum("already exists" in result for result in results) == 1
+    stored = json.loads(token_path.read_text(encoding="utf-8"))
+    assert [token["token_id"] for token in stored["tokens"]] == ["same-id"]
diff --git a/tokens.json b/tokens.json
index 1ae66dd..da03620 100644
--- a/tokens.json
+++ b/tokens.json
@@ -2,10 +2,12 @@
   "version": "2.0",
   "description": "Credential-free Doris MCP Server digest-only token 
configuration template",
   "tokens": [],
+  "revoked_tokens": [],
   "notes": [
     "No usable credential is shipped with the server.",
     "Create tokens through the protected management API or store a 
self-describing token_digest generated from a high-entropy bearer token.",
     "Bearer token plaintext is returned only when a token is created and must 
not be written into this file.",
+    "Managed revocations are stored as digests and shared by all local worker 
processes.",
     "Do not commit deployment credentials or usable token digests to source 
control."
   ]
 }
diff --git a/uv.lock b/uv.lock
index 8df5bde..c0b093f 100644
--- a/uv.lock
+++ b/uv.lock
@@ -605,6 +605,7 @@ dependencies = [
     { name = "cryptography" },
     { name = "fastapi", version = "0.115.12", source = { registry = 
"https://pypi.org/simple"; }, marker = "python_full_version < '3.14'" },
     { name = "fastapi", version = "0.140.13", source = { registry = 
"https://pypi.org/simple"; }, marker = "python_full_version >= '3.14'" },
+    { name = "filelock" },
     { name = "httpx" },
     { name = "mcp" },
     { name = "numpy" },
@@ -698,6 +699,7 @@ requires-dist = [
     { name = "click", specifier = ">=8.1.0" },
     { name = "cryptography", specifier = ">=41.0.0" },
     { name = "fastapi", specifier = ">=0.108.0" },
+    { name = "filelock", specifier = ">=3.16.1,<4.0.0" },
     { name = "flake8", marker = "extra == 'dev'", specifier = ">=7.0.0" },
     { name = "grafana-client", marker = "extra == 'monitoring'", specifier = 
">=3.5.0" },
     { name = "httpx", specifier = ">=0.26.0" },


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to