This is an automated email from the ASF dual-hosted git repository.
FreeOnePlus pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris-mcp-server.git
The following commit(s) were added to refs/heads/master by this push:
new 88fdc23 fix: persist static tokens as digests (#123)
88fdc23 is described below
commit 88fdc23844c6de3706c344af7fc5da792adca8d6
Author: Yijia Su <[email protected]>
AuthorDate: Wed Jul 29 23:07:23 2026 +0800
fix: persist static tokens as digests (#123)
---
.env.example | 3 +
CHANGELOG.md | 3 +
README.md | 113 +++---
doris_mcp_server/auth/token_manager.py | 531 +++++++++++++++++++----------
doris_mcp_server/utils/config.py | 44 ++-
doris_mcp_server/utils/secret_policy.py | 51 +++
test/security/test_token_digest_storage.py | 340 ++++++++++++++++++
tokens.json | 9 +-
8 files changed, 862 insertions(+), 232 deletions(-)
diff --git a/.env.example b/.env.example
index d511b84..900155f 100644
--- a/.env.example
+++ b/.env.example
@@ -84,6 +84,9 @@ TOKEN_HASH_ALGORITHM=sha256
# No static bearer token is shipped. Generate one outside source control:
# python -c 'import secrets; print(secrets.token_urlsafe(32))'
# TOKEN_ADMIN=<paste-generated-value-here>
+# 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.
# ===================================================================
# Token Management Security Configuration (NEW in v0.6.0) - CRITICAL SECURITY
SETTINGS
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 81a80a7..b1185f0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -47,6 +47,9 @@ under **Unreleased** until a new version is selected and
published.
constraints.
- Positioned Dynamic Client Registration as a compatibility fallback behind
preconfigured clients and Client ID Metadata Documents.
+- Persisted static bearer tokens as self-describing SHA-256/SHA-512 digests,
+ with atomic owner-only writes and one-way migration from legacy plaintext
+ token files.
### Fixed
diff --git a/README.md b/README.md
index e02275a..003ec03 100644
--- a/README.md
+++ b/README.md
@@ -172,7 +172,8 @@ field and do not persist or propagate the token in URLs.
- All operations require admin authentication
- Real-time IP validation
- Complete audit logging
- - **Automatic persistence** to `tokens.json`
+ - **Digest-only persistence** to `tokens.json`; plaintext is returned once
+ when a token is created
> **🔐 Security Note**: The interface is designed for localhost administration
> only. It cannot be accessed remotely, ensuring maximum security for token
> management operations.
@@ -283,6 +284,7 @@ cp .env.example .env
* `DORIS_OAUTH_CIMD_MAX_CLIENTS`: Maximum discovered Client ID Metadata
clients held in memory (default: 1000)
* `TOKEN_FILE_PATH`: Path to tokens.json file for token management
(default: tokens.json)
* `TOKEN_HOT_RELOAD`: Enable hot reloading of token configuration
(default: true)
+ * `TOKEN_HASH_ALGORITHM`: Digest algorithm for newly created static
tokens (`sha256` or `sha512`; default: `sha256`)
* `TOKEN_<ID>`: Explicit static bearer token; each active token must be a
securely generated value of at least 32 characters
* **Legacy Security Configuration**:
@@ -815,17 +817,35 @@ For production deployments:
### Token-Bound Database Configuration (New in v0.6.0)
-Create a `tokens.json` file for advanced token management with database
binding:
+Managed token creation returns the bearer value once and writes only its digest
+to `tokens.json`. For manual provisioning, generate the bearer value and digest
+outside the repository, store the bearer value in the client secret store, and
+place only the digest in the server file:
+
+```bash
+python - <<'PY'
+import hashlib
+import secrets
+
+token = secrets.token_urlsafe(32)
+print(f"Bearer token (store once): {token}")
+print(f"token_digest: sha256:{hashlib.sha256(token.encode()).hexdigest()}")
+PY
+```
+
+The v2 file format uses the generated digest:
```json
{
- "version": "1.0",
+ "version": "2.0",
"tokens": [
{
"token_id": "customer-a-token",
- "token": "customer_a_secure_token_12345",
+ "token_digest":
"sha256:0000000000000000000000000000000000000000000000000000000000000000",
+ "created_at": "2026-07-29T00:00:00Z",
+ "expires_at": null,
+ "last_used": null,
"description": "Customer A dedicated database access",
- "expires_hours": null,
"is_active": true,
"database_config": {
"host": "customer-a-db.example.com",
@@ -836,27 +856,17 @@ Create a `tokens.json` file for advanced token management
with database binding:
"charset": "UTF8",
"fe_http_port": 8030
}
- },
- {
- "token_id": "customer-b-token",
- "token": "customer_b_secure_token_67890",
- "description": "Customer B dedicated database access",
- "expires_hours": 720,
- "is_active": true,
- "database_config": {
- "host": "customer-b-db.example.com",
- "port": 9030,
- "user": "customer_b_user",
- "password": "secure_password",
- "database": "customer_b_data",
- "charset": "UTF8",
- "fe_http_port": 8030
- }
}
]
}
```
+Replace the all-zero example with the generated digest; it is intentionally not
+a usable credential. Managed writes are atomic and set the file mode to `0600`.
+Version 1 files containing `token` plaintext are accepted once for migration,
+then immediately replaced with version 2 digest-only records. The server cannot
+recover or display the original bearer value from `token_digest`.
+
### Hot Reload Configuration Updates (New in v0.6.0)
The system automatically detects and applies configuration changes:
@@ -1783,16 +1793,18 @@ cat logs/doris_mcp_server_critical.log
TOKEN_FILE_PATH=tokens.json
```
-2. **Create tokens.json Configuration**:
+2. **Create the bearer token once and store only its digest in tokens.json**:
```json
{
- "version": "1.0",
+ "version": "2.0",
"tokens": [
{
"token_id": "tenant-alpha",
- "token": "<generated-deployment-token-at-least-32-characters>",
+ "token_digest":
"sha256:0000000000000000000000000000000000000000000000000000000000000000",
+ "created_at": "2026-07-29T00:00:00Z",
+ "expires_at": null,
+ "last_used": null,
"description": "Tenant Alpha database access",
- "expires_hours": null,
"is_active": true,
"database_config": {
"host": "tenant-alpha-db.company.com",
@@ -1806,6 +1818,10 @@ cat logs/doris_mcp_server_critical.log
]
}
```
+ Generate the real bearer token and digest with the command in
+ [Token-Bound Database
Configuration](#token-bound-database-configuration-new-in-v060).
+ The all-zero value above is deliberately unusable. Give the bearer value to
+ the client once; do not place it in the file.
3. **Configuration Priority** (New in v0.6.0):
- **Token-bound DB config** (highest priority)
@@ -1872,16 +1888,25 @@ cp tokens.json tokens.json.backup
**Primary Token Management Method (Recommended):**
```bash
-# 1. Edit tokens.json file directly (safest method)
-nano tokens.json
-
-# 2. Hot reload will automatically detect changes
-# No server restart required - changes applied within 10 seconds
-
-# 3. Monitor hot reload in logs
+# 1. Keep the management endpoint disabled unless local administration is
needed.
+# 2. When enabled, create a token through the protected localhost endpoint.
+curl -X POST \
+ -H "Authorization: Bearer $TOKEN_MANAGEMENT_ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ --data '{"token_id":"service-a","expires_hours":720}' \
+ http://127.0.0.1:3000/token/create
+
+# 3. Capture the returned bearer token once and place it in the client secret
store.
+# 4. The server writes only token_digest to tokens.json.
+# 5. Monitor hot reload in logs.
tail -f logs/doris_mcp_server_info.log | grep "hot reload"
```
+For deployments without HTTP token management, use a high-entropy `TOKEN_<ID>`
+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.
+
**Administrative Endpoints (Secure, Local Access Only):**
🛡️ **SECURITY**: These endpoints are protected by comprehensive security
controls and are **disabled by default**.
@@ -1912,18 +1937,22 @@ curl -H "Authorization: Bearer
$TOKEN_MANAGEMENT_ADMIN_TOKEN" http://127.0.0.1:3
```json
// tokens.json
{
- "version": "1.0",
+ "version": "2.0",
"tokens": [
{
"token_id": "dev-token",
- "token": "<generated-development-token-at-least-32-characters>",
+ "token_digest":
"sha256:0000000000000000000000000000000000000000000000000000000000000000",
+ "created_at": "2026-07-29T00:00:00Z",
+ "expires_at": "2026-07-30T00:00:00Z",
+ "last_used": null,
"description": "Development environment access",
- "expires_hours": 24,
"is_active": true
}
]
}
```
+ Replace the all-zero digest with one derived from a securely generated
+ bearer token, and keep that bearer token outside the server file.
2. **Production Deployment**:
```bash
@@ -1936,19 +1965,25 @@ curl -H "Authorization: Bearer
$TOKEN_MANAGEMENT_ADMIN_TOKEN" http://127.0.0.1:3
```
**Security Features:**
-- **File-Based Management**: Primary management through secured configuration
files
+- **Digest-Only Persistence**: Bearer plaintext is returned only when created;
+ version 2 files contain self-describing SHA-256/SHA-512 digests
+- **Atomic File Management**: Managed writes use same-directory replacement and
+ force owner-only `0600` permissions
- **Hot Reload**: Automatic configuration updates without service interruption
-- **Token Hashing**: Tokens stored as SHA-256 hashes internally
+- **Legacy Migration**: Version 1 plaintext entries are replaced by digest-only
+ records on first successful load
- **Audit Trail**: Complete logging of all token operations and changes
- **Expiration Management**: Automatic cleanup of expired tokens
- **Local Admin Only**: Management endpoints restricted to localhost access
- **Configuration Validation**: Immediate validation of token and database
configurations
**Security Best Practices:**
-- Always manage tokens through secure configuration files
+- Store bearer values in client-side secret management; keep only digests on
+ the server
- Never expose token management endpoints to external networks
- Use strong, randomly generated tokens for production
-- Implement proper file permissions for tokens.json (600 or 640)
+- Keep manually managed `tokens.json` files owner-readable only; managed writes
+ enforce `0600`
- Regular audit of active tokens and their usage patterns
- Monitor hot reload logs for unauthorized configuration changes
diff --git a/doris_mcp_server/auth/token_manager.py
b/doris_mcp_server/auth/token_manager.py
index bbd11a4..32f55ec 100644
--- a/doris_mcp_server/auth/token_manager.py
+++ b/doris_mcp_server/auth/token_manager.py
@@ -22,20 +22,22 @@ Provides enterprise-grade token authentication system with
configurable tokens,
expiration management, role-based access control and secure token storage.
"""
-import hashlib
+import asyncio
import json
import os
import secrets
-import time
-import asyncio
+import tempfile
from dataclasses import dataclass, field
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional, Any
from pathlib import Path
from ..utils.logger import get_logger
from ..utils.secret_policy import (
+ build_token_digest,
is_static_token_environment_variable,
+ normalize_token_digest,
+ normalize_token_hash_algorithm,
validate_high_entropy_secret,
)
from ..utils.security import RESERVED_DORIS_OAUTH_TOKEN_PREFIX, SecurityLevel
@@ -87,25 +89,28 @@ class TokenManager:
- Token lifecycle management
"""
- def __init__(self, config):
+ def __init__(self, config: Any) -> None:
self.config = config
self.logger = get_logger(__name__)
# Token storage
self._tokens: Dict[str, TokenInfo] = {} # token_hash -> TokenInfo
self._token_ids: Dict[str, str] = {} # token_id -> token_hash
+ self._digest_algorithms: set[str] = set()
# Configuration
self.token_file_path = getattr(config.security, 'token_file_path',
'tokens.json')
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 = getattr(config.security,
'token_hash_algorithm', 'sha256')
+ self.token_hash_algorithm = normalize_token_hash_algorithm(
+ getattr(config.security, 'token_hash_algorithm', 'sha256')
+ )
# Hot reload configuration
self.enable_hot_reload = True
self.hot_reload_interval = 10 # Check every 10 seconds
- self._file_last_modified = 0
- self._hot_reload_task = None
+ self._file_last_modified = 0.0
+ self._hot_reload_task: Optional[asyncio.Task[None]] = None
# Load tokens from configuration
self._load_tokens()
@@ -130,22 +135,62 @@ class TokenManager:
self.logger.info(f"TokenManager initialized with {len(self._tokens)}
tokens, hot reload: {self.enable_hot_reload}")
- def _validate_static_token_prefix(self, token: str):
+ def _validate_static_token_prefix(self, token: str) -> None:
if token.startswith(RESERVED_DORIS_OAUTH_TOKEN_PREFIX):
raise ValueError(
f"Static tokens cannot use reserved Doris OAuth prefix "
f"'{RESERVED_DORIS_OAUTH_TOKEN_PREFIX}'"
)
-
- def _add_token_from_config(self, token_config: Dict[str, Any]):
+
+ @staticmethod
+ def _parse_datetime(value: Any, *, setting: str) -> Optional[datetime]:
+ """Parse an RFC 3339 timestamp into the manager's naive UTC form."""
+ if value in (None, ""):
+ return None
+ if not isinstance(value, str):
+ raise ValueError(f"{setting} must be an RFC 3339 timestamp")
+ normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
+ try:
+ parsed = datetime.fromisoformat(normalized)
+ except ValueError as exc:
+ raise ValueError(f"{setting} must be an RFC 3339 timestamp") from
exc
+ if parsed.tzinfo is not None:
+ parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
+ return parsed
+
+ def _add_token_from_config(
+ self,
+ token_config: Dict[str, Any],
+ ) -> tuple[str, TokenInfo, bool]:
"""Add token from configuration with optional database binding"""
try:
+ token_id = str(token_config.get('token_id') or '').strip()
+ if not token_id:
+ raise ValueError("Static token entry requires token_id")
+
+ created_at = self._parse_datetime(
+ token_config.get('created_at'),
+ setting=f"static token '{token_id}' created_at",
+ ) or datetime.utcnow()
+
# Calculate expiration time
- expires_at = None
- if self.enable_token_expiry:
+ if 'expires_at' in token_config:
+ expires_at = self._parse_datetime(
+ token_config.get('expires_at'),
+ setting=f"static token '{token_id}' expires_at",
+ )
+ elif self.enable_token_expiry:
+ expires_at = None
expires_hours = token_config.get('expires_hours',
self.default_token_expiry_hours)
if expires_hours is not None:
- expires_at = datetime.utcnow() +
timedelta(hours=expires_hours)
+ expires_at = created_at + timedelta(hours=expires_hours)
+ else:
+ expires_at = None
+
+ last_used = self._parse_datetime(
+ token_config.get('last_used'),
+ setting=f"static token '{token_id}' last_used",
+ )
# Parse database configuration if provided
database_config = None
@@ -163,21 +208,44 @@ class TokenManager:
# Create token info
token_info = TokenInfo(
- token_id=token_config['token_id'],
+ token_id=token_id,
+ created_at=created_at,
expires_at=expires_at,
+ last_used=last_used,
description=token_config.get('description', ''),
is_active=token_config.get('is_active', True),
database_config=database_config
)
-
- # Hash the token
- raw_token = token_config['token']
- self._validate_static_token_prefix(raw_token)
- validate_high_entropy_secret(
- raw_token,
- setting=f"static token '{token_info.token_id}'",
- )
- token_hash = self._hash_token(raw_token)
+
+ has_raw_token = 'token' in token_config
+ has_token_digest = 'token_digest' in token_config
+ if has_raw_token == has_token_digest:
+ raise ValueError(
+ f"static token '{token_id}' must contain exactly one of "
+ "token or token_digest"
+ )
+
+ if has_token_digest:
+ stored_digest = token_config.get('token_digest')
+ if not isinstance(stored_digest, str):
+ raise ValueError(
+ f"static token '{token_id}' token_digest is required"
+ )
+ token_hash = normalize_token_digest(
+ stored_digest,
+ setting=f"static token '{token_id}' token_digest",
+ )
+ else:
+ raw_token = token_config.get('token')
+ if not isinstance(raw_token, str):
+ raise ValueError(f"static token '{token_id}' is required")
+ self._validate_static_token_prefix(raw_token)
+ validate_high_entropy_secret(
+ raw_token,
+ setting=f"static token '{token_info.token_id}'",
+ )
+ token_hash = self._hash_token(raw_token)
+ self._digest_algorithms.add(token_hash.partition(":")[0])
# Store token
self._tokens[token_hash] = token_info
@@ -185,12 +253,13 @@ class TokenManager:
db_info = f" with DB binding ({database_config.host})" if
database_config else ""
self.logger.debug(f"Added token '{token_info.token_id}'{db_info}")
-
+ return token_hash, token_info, has_raw_token
+
except Exception as e:
self.logger.error(f"Failed to add token from config: {e}")
raise
- def _load_tokens(self):
+ def _load_tokens(self) -> None:
"""Load tokens from configuration sources"""
# 1. Load from environment variables
self._load_tokens_from_env()
@@ -201,7 +270,7 @@ class TokenManager:
self.logger.info(f"Token loading completed, total tokens:
{len(self._tokens)}")
- def _load_tokens_from_env(self):
+ def _load_tokens_from_env(self) -> None:
"""Load tokens from environment variables
Simplified format:
@@ -249,7 +318,7 @@ class TokenManager:
except Exception as e:
self.logger.error(f"Failed to load token {token_id} from
environment: {e}")
- def _load_tokens_from_file(self):
+ def _load_tokens_from_file(self) -> None:
"""Load tokens from JSON file"""
try:
with open(self.token_file_path, 'r', encoding='utf-8') as f:
@@ -262,10 +331,42 @@ class TokenManager:
else:
self.logger.error(f"Invalid token file format:
{self.token_file_path}")
return
-
+ if not isinstance(tokens_list, list):
+ raise ValueError("Static token file must contain a tokens
array")
+
+ persisted_tokens = []
+ needs_migration = (
+ not isinstance(tokens_data, dict)
+ or tokens_data.get("version") != "2.0"
+ )
for token_config in tokens_list:
- self._add_token_from_config(token_config)
-
+ if not isinstance(token_config, dict):
+ raise ValueError("Static token file entries must be
objects")
+ token_hash, token_info, had_raw_token =
self._add_token_from_config(
+ token_config
+ )
+ persisted_tokens.append(
+ self._token_info_to_config(token_hash, token_info)
+ )
+ needs_migration = needs_migration or had_raw_token
+
+ if needs_migration:
+ migrated_data = self._token_file_document(persisted_tokens)
+ try:
+ self._atomic_write_token_file(
+ Path(self.token_file_path),
+ migrated_data,
+ )
+ except Exception as exc:
+ raise ValueError(
+ "Unable to migrate the static token file to
digest-only "
+ "storage"
+ ) from exc
+ self.logger.info(
+ "Migrated %s static token records to digest-only storage",
+ len(persisted_tokens),
+ )
+
self.logger.info(f"Loaded {len(tokens_list)} tokens from file:
{self.token_file_path}")
except ValueError:
@@ -273,24 +374,25 @@ class TokenManager:
except Exception as e:
self.logger.error(f"Failed to load tokens from file
{self.token_file_path}: {e}")
- def _hash_token(self, token: str) -> str:
+ def _hash_token(self, token: str, algorithm: Optional[str] = None) -> str:
"""Hash token for secure storage"""
- if self.token_hash_algorithm == 'sha256':
- return hashlib.sha256(token.encode('utf-8')).hexdigest()
- elif self.token_hash_algorithm == 'sha512':
- return hashlib.sha512(token.encode('utf-8')).hexdigest()
- else:
- # Fallback to sha256
- return hashlib.sha256(token.encode('utf-8')).hexdigest()
-
+ return build_token_digest(token, algorithm or
self.token_hash_algorithm)
+
+ 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)
+ token_info = self._tokens.get(token_hash)
+ if token_info is not None:
+ return token_hash, token_info
+ return "", None
+
async def validate_token(self, token: str) -> TokenValidationResult:
"""Validate token and return user information"""
try:
- # Hash the provided token
- token_hash = self._hash_token(token)
-
# Find token info
- token_info = self._tokens.get(token_hash)
+ _token_hash, token_info = self._lookup_token(token)
if not token_info:
return TokenValidationResult(
is_valid=False,
@@ -372,13 +474,24 @@ class TokenManager:
# 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
- self._save_token_to_file(token_id, raw_token, token_info)
+ try:
+ 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
return raw_token
@@ -393,106 +506,57 @@ class TokenManager:
self.logger.warning(f"Token ID '{token_id}' not found")
return False
- # Get token hash and remove from storage
+ # 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]
self.logger.info(f"Revoked token '{token_id}'")
-
- # Save updated tokens to file
- self._remove_token_from_file(token_id)
-
return True
except Exception as e:
self.logger.error(f"Failed to revoke token '{token_id}': {e}")
return False
- def _save_tokens_to_file(self):
+ def _save_tokens_to_file(self) -> None:
"""Save current tokens to JSON file"""
try:
- # Convert current tokens to file format
- tokens_list = []
-
- for token_hash, token_info in self._tokens.items():
- # Find the raw token for this token_info
- raw_token = None
- for tid, thash in self._token_ids.items():
- if thash == token_hash and tid == token_info.token_id:
- # We can't recover the original token from hash,
- # so we'll create a placeholder for existing tokens
- raw_token = f"<existing_token_hash_{token_hash[:8]}>"
- break
-
- if raw_token is None:
- continue
-
- token_config = {
- "token_id": token_info.token_id,
- "token": raw_token,
- "description": token_info.description,
- "expires_hours": None,
- "is_active": token_info.is_active
- }
-
- # Add expiration info
- if token_info.expires_at:
- # Calculate remaining hours from now
- remaining = token_info.expires_at - datetime.utcnow()
- if remaining.total_seconds() > 0:
- token_config["expires_hours"] =
int(remaining.total_seconds() / 3600)
- else:
- token_config["expires_hours"] = 0
-
- # Add database config if present
- if token_info.database_config:
- token_config["database_config"] = {
- "host": token_info.database_config.host,
- "port": token_info.database_config.port,
- "user": token_info.database_config.user,
- "password": token_info.database_config.password,
- "database": token_info.database_config.database,
- "charset": token_info.database_config.charset,
- "fe_http_port": token_info.database_config.fe_http_port
- }
-
- tokens_list.append(token_config)
-
- # Create file content
- file_content = {
- "version": "1.0",
- "description": "Doris MCP Server Token configuration file",
- "created_at": datetime.utcnow().isoformat() + "Z",
- "tokens": tokens_list,
- "notes": [
- "This file is automatically updated when tokens are
created or revoked",
- "Please backup this file before making manual changes",
- "Tokens with hash placeholders were loaded from previous
configuration"
- ]
- }
-
- # Save to file
- with open(self.token_file_path, 'w', encoding='utf-8') as f:
- json.dump(file_content, f, indent=2, ensure_ascii=False)
-
+ 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,
+ )
self.logger.info(f"Saved {len(tokens_list)} tokens to file:
{self.token_file_path}")
except Exception as e:
self.logger.error(f"Failed to save tokens to file
{self.token_file_path}: {e}")
- def _save_token_to_file(self, token_id: str, raw_token: str, token_info:
TokenInfo):
- """Save a single new token to file (for newly created tokens only)"""
+ def _save_token_to_file(
+ self,
+ token_id: str,
+ token_hash: str,
+ token_info: TokenInfo,
+ ) -> None:
+ """Save one new token as a digest-only record."""
try:
# Load existing file
- existing_data = {"tokens": []}
+ existing_data: Dict[str, Any] = {"tokens": []}
if os.path.exists(self.token_file_path):
try:
with open(self.token_file_path, 'r', encoding='utf-8') as
f:
existing_data = json.load(f)
except Exception as e:
- self.logger.warning(f"Could not load existing token file:
{e}")
+ raise ValueError(
+ f"Could not load existing token file: {e}"
+ ) from e
# Ensure tokens list exists
if 'tokens' not in existing_data or not
isinstance(existing_data['tokens'], list):
@@ -503,47 +567,157 @@ class TokenManager:
for i, token_config in enumerate(existing_data['tokens']):
if token_config.get('token_id') == token_id:
# Update existing token
- existing_data['tokens'][i] =
self._token_info_to_config(token_id, raw_token, token_info)
+ existing_data['tokens'][i] = self._token_info_to_config(
+ token_hash,
+ token_info,
+ )
token_exists = True
break
# Add new token if it doesn't exist
if not token_exists:
- new_token_config = self._token_info_to_config(token_id,
raw_token, token_info)
+ new_token_config = self._token_info_to_config(
+ token_hash,
+ token_info,
+ )
existing_data['tokens'].append(new_token_config)
-
+
+ existing_data['tokens'] = [
+ self._sanitize_persisted_token(token_config)
+ for token_config in existing_data['tokens']
+ ]
+
# Update metadata
- existing_data.update({
- "version": "1.0",
- "description": "Doris MCP Server Token configuration file",
- "updated_at": datetime.utcnow().isoformat() + "Z"
- })
-
- # Save to file
- with open(self.token_file_path, 'w', encoding='utf-8') as f:
- json.dump(existing_data, f, indent=2, ensure_ascii=False)
-
+ existing_data = self._token_file_document(existing_data['tokens'])
+ self._atomic_write_token_file(
+ Path(self.token_file_path),
+ existing_data,
+ )
self.logger.info(f"Saved token '{token_id}' to file:
{self.token_file_path}")
except Exception as e:
self.logger.error(f"Failed to save token '{token_id}' to file:
{e}")
+ raise
- def _token_info_to_config(self, token_id: str, raw_token: str, token_info:
TokenInfo) -> dict:
- """Convert TokenInfo to file configuration format"""
- token_config = {
- "token_id": token_id,
- "token": raw_token,
+ def _token_file_document(self, tokens: List[Dict[str, Any]]) -> Dict[str,
Any]:
+ """Build the versioned digest-only token file document."""
+ return {
+ "version": "2.0",
+ "description": "Doris MCP Server digest-only token configuration
file",
+ "updated_at": datetime.utcnow().isoformat() + "Z",
+ "tokens": tokens,
+ "notes": [
+ "Bearer token plaintext is returned only when a token is
created.",
+ "token_digest is self-describing and may use sha256 or
sha512.",
+ "Do not replace token_digest with a plaintext token.",
+ ],
+ }
+
+ def _atomic_write_token_file(
+ self,
+ file_path: Path,
+ file_content: Dict[str, Any],
+ ) -> None:
+ """Atomically replace a token file with owner-only permissions."""
+ temporary_path: Optional[Path] = None
+ try:
+ with tempfile.NamedTemporaryFile(
+ mode="w",
+ encoding="utf-8",
+ dir=file_path.parent,
+ prefix=f".{file_path.name}.",
+ suffix=".tmp",
+ delete=False,
+ ) as temporary_file:
+ temporary_path = Path(temporary_file.name)
+ os.chmod(temporary_path, 0o600)
+ json.dump(file_content, temporary_file, indent=2,
ensure_ascii=False)
+ temporary_file.write("\n")
+ temporary_file.flush()
+ os.fsync(temporary_file.fileno())
+ os.replace(temporary_path, file_path)
+ os.chmod(file_path, 0o600)
+ if file_path == Path(self.token_file_path):
+ self._update_file_modified_time()
+ except Exception:
+ if temporary_path is not None:
+ temporary_path.unlink(missing_ok=True)
+ raise
+
+ def _sanitize_persisted_token(
+ self,
+ token_config: Dict[str, Any],
+ ) -> Dict[str, Any]:
+ """Remove legacy plaintext while preserving a token record's
metadata."""
+ token_id = str(token_config.get("token_id") or "").strip()
+ if not token_id:
+ raise ValueError("Static token entry requires token_id")
+ has_raw_token = "token" in token_config
+ has_token_digest = "token_digest" in token_config
+ if has_raw_token == has_token_digest:
+ raise ValueError(
+ f"static token '{token_id}' must contain exactly one of "
+ "token or token_digest"
+ )
+
+ sanitized = {
+ key: token_config[key]
+ for key in (
+ "created_at",
+ "expires_at",
+ "expires_hours",
+ "last_used",
+ "description",
+ "is_active",
+ "database_config",
+ )
+ if key in token_config
+ }
+ sanitized["token_id"] = token_id
+ if has_raw_token:
+ raw_token = token_config["token"]
+ if not isinstance(raw_token, str):
+ raise ValueError(f"static token '{token_id}' is required")
+ self._validate_static_token_prefix(raw_token)
+ validate_high_entropy_secret(
+ raw_token,
+ setting=f"static token '{token_id}'",
+ )
+ sanitized["token_digest"] = self._hash_token(raw_token)
+ else:
+ sanitized["token_digest"] = normalize_token_digest(
+ token_config["token_digest"],
+ setting=f"static token '{token_id}' token_digest",
+ )
+ return sanitized
+
+ def _token_info_to_config(
+ self,
+ token_hash: str,
+ token_info: TokenInfo,
+ ) -> Dict[str, Any]:
+ """Convert TokenInfo to a digest-only file record."""
+ token_config: Dict[str, Any] = {
+ "token_id": token_info.token_id,
+ "token_digest": normalize_token_digest(
+ token_hash,
+ setting=f"static token '{token_info.token_id}' token_digest",
+ ),
+ "created_at": token_info.created_at.isoformat() + "Z",
+ "expires_at": (
+ token_info.expires_at.isoformat() + "Z"
+ if token_info.expires_at
+ else None
+ ),
+ "last_used": (
+ token_info.last_used.isoformat() + "Z"
+ if token_info.last_used
+ else None
+ ),
"description": token_info.description,
- "expires_hours": None,
"is_active": token_info.is_active
}
-
- # Add expiration info
- if token_info.expires_at:
- # Calculate remaining hours from creation time
- remaining = token_info.expires_at - token_info.created_at
- token_config["expires_hours"] = int(remaining.total_seconds() /
3600) if remaining.total_seconds() > 0 else None
-
+
# Add database config if present
if token_info.database_config:
token_config["database_config"] = {
@@ -558,7 +732,7 @@ class TokenManager:
return token_config
- def _remove_token_from_file(self, token_id: str):
+ def _remove_token_from_file(self, token_id: str) -> None:
"""Remove a token from the JSON file"""
try:
if not os.path.exists(self.token_file_path):
@@ -574,33 +748,30 @@ class TokenManager:
# Remove the token
original_count = len(existing_data['tokens'])
existing_data['tokens'] = [
- token for token in 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.update({
- "version": "1.0",
- "description": "Doris MCP Server Token configuration file",
- "updated_at": datetime.utcnow().isoformat() + "Z"
- })
-
- # Save to file
- with open(self.token_file_path, 'w', encoding='utf-8') as f:
- json.dump(existing_data, f, indent=2, ensure_ascii=False)
-
+ existing_data =
self._token_file_document(existing_data['tokens'])
+ self._atomic_write_token_file(
+ Path(self.token_file_path),
+ existing_data,
+ )
self.logger.info(f"Removed token '{token_id}' from file:
{self.token_file_path}")
except Exception as e:
self.logger.error(f"Failed to remove token '{token_id}' from file:
{e}")
+ raise
async def list_tokens(self) -> List[Dict[str, Any]]:
"""List all tokens (without sensitive data)"""
tokens = []
for token_hash, token_info in self._tokens.items():
- token_data = {
+ token_data: Dict[str, Any] = {
'token_id': token_info.token_id,
'created_at': token_info.created_at.isoformat(),
'expires_at': token_info.expires_at.isoformat() if
token_info.expires_at else None,
@@ -625,7 +796,7 @@ class TokenManager:
tokens.append(token_data)
# Sort by creation time
- tokens.sort(key=lambda x: x['created_at'], reverse=True)
+ tokens.sort(key=lambda item: str(item['created_at']), reverse=True)
return tokens
@@ -656,19 +827,16 @@ class TokenManager:
async def save_tokens_to_file(self, file_path: Optional[str] = None) ->
bool:
"""Save current tokens to JSON file"""
try:
- file_path = file_path or self.token_file_path
- tokens_list = await self.list_tokens()
-
- tokens_data = {
- 'version': '1.0',
- 'created_at': datetime.utcnow().isoformat(),
- 'tokens': tokens_list
- }
-
- with open(file_path, 'w', encoding='utf-8') as f:
- json.dump(tokens_data, f, indent=2, ensure_ascii=False)
-
- self.logger.info(f"Saved {len(tokens_list)} tokens to file:
{file_path}")
+ 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),
+ )
+ self.logger.info(f"Saved {len(tokens_list)} tokens to file:
{target_path}")
return True
except Exception as e:
@@ -685,8 +853,7 @@ class TokenManager:
DatabaseConfig if token exists and has database binding, None
otherwise
"""
try:
- token_hash = self._hash_token(token)
- token_info = self._tokens.get(token_hash)
+ _token_hash, token_info = self._lookup_token(token)
if not token_info or not token_info.is_active:
return None
@@ -722,7 +889,7 @@ class TokenManager:
'last_file_check':
datetime.fromtimestamp(self._file_last_modified).isoformat() if
self._file_last_modified else None
}
- def _start_hot_reload(self):
+ def _start_hot_reload(self) -> None:
"""Start hot reload monitoring task"""
if self._hot_reload_task:
return # Already running
@@ -734,14 +901,14 @@ class TokenManager:
self._hot_reload_task = asyncio.create_task(self._hot_reload_monitor())
self.logger.info(f"Started hot reload monitoring for
{self.token_file_path}")
- def stop_hot_reload(self):
+ def stop_hot_reload(self) -> None:
"""Stop hot reload monitoring"""
if self._hot_reload_task:
self._hot_reload_task.cancel()
self._hot_reload_task = None
self.logger.info("Stopped hot reload monitoring")
- def _update_file_modified_time(self):
+ def _update_file_modified_time(self) -> None:
"""Update the last modified time of tokens file"""
try:
if os.path.exists(self.token_file_path):
@@ -749,7 +916,7 @@ class TokenManager:
except Exception as e:
self.logger.debug(f"Failed to get file modification time: {e}")
- async def _hot_reload_monitor(self):
+ async def _hot_reload_monitor(self) -> None:
"""Background task to monitor tokens.json file changes"""
while True:
try:
@@ -767,16 +934,19 @@ class TokenManager:
# 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()
- # Load from file
+ # Environment credentials remain effective across file
reloads.
+ self._load_tokens_from_env()
self._load_tokens_from_file()
# Update modification time
- self._file_last_modified = current_mtime
+ self._update_file_modified_time()
self.logger.info(f"Hot reload completed,
{len(self._tokens)} tokens loaded")
@@ -785,6 +955,7 @@ class TokenManager:
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
except asyncio.CancelledError:
self.logger.info("Hot reload monitor stopped")
diff --git a/doris_mcp_server/utils/config.py b/doris_mcp_server/utils/config.py
index e49a6df..001f6b5 100644
--- a/doris_mcp_server/utils/config.py
+++ b/doris_mcp_server/utils/config.py
@@ -40,6 +40,8 @@ except ImportError:
from .logger import get_logger
from .secret_policy import (
is_static_token_environment_variable,
+ normalize_token_digest,
+ normalize_token_hash_algorithm,
validate_high_entropy_secret,
)
@@ -1464,12 +1466,12 @@ def build_auth_config_inputs(config: DorisConfig,
requested_workers: int | None
def _configured_static_tokens(
security_config: SecurityConfig,
-) -> list[tuple[str, Any, bool]]:
- """Load source labels, raw values, and active flags for configured static
tokens."""
- configured: list[tuple[str, Any, bool]] = []
+) -> list[tuple[str, Any, bool, bool]]:
+ """Load source labels, credentials, active flags, and digest markers."""
+ configured: list[tuple[str, Any, bool, bool]] = []
for name, value in os.environ.items():
if is_static_token_environment_variable(name):
- configured.append((name, value, True))
+ configured.append((name, value, True, False))
token_file = Path(security_config.token_file_path)
if not token_file.exists():
@@ -1503,12 +1505,24 @@ def _configured_static_tokens(
raise AuthConfigError(
f"Static token file {token_file} entry {index} requires
token_id"
)
- raw_token = token_entry.get("token")
+ has_raw_token = "token" in token_entry
+ has_token_digest = "token_digest" in token_entry
+ if has_raw_token == has_token_digest:
+ raise AuthConfigError(
+ f"Static token file {token_file} entry {token_id} must contain
"
+ "exactly one of token or token_digest"
+ )
+ credential = (
+ token_entry.get("token_digest")
+ if has_token_digest
+ else token_entry.get("token")
+ )
configured.append(
(
f"{token_file}:{token_id}",
- raw_token,
+ credential,
_str_to_bool(token_entry.get("is_active", True)),
+ has_token_digest,
)
)
return configured
@@ -1517,12 +1531,18 @@ def _configured_static_tokens(
def _validate_static_token_bootstrap(config: DorisConfig) -> None:
"""Require at least one active high-entropy token when static auth is
enabled."""
configured = _configured_static_tokens(config.security)
- for setting, raw_token, _active in configured:
+ for setting, credential, _active, is_digest in configured:
try:
- validate_high_entropy_secret(raw_token, setting=setting)
+ if is_digest:
+ normalize_token_digest(
+ credential,
+ setting=f"{setting} token_digest",
+ )
+ else:
+ validate_high_entropy_secret(credential, setting=setting)
except ValueError as exc:
raise AuthConfigError(str(exc)) from exc
- if not any(active for _setting, _raw_token, active in configured):
+ if not any(active for _setting, _credential, active, _is_digest in
configured):
raise AuthConfigError(
"Token authentication requires at least one active high-entropy
credential. "
"Set TOKEN_<ID> or populate TOKEN_FILE_PATH before enabling it."
@@ -1540,6 +1560,12 @@ def normalize_effective_auth_config(
auth_type = str(inputs.legacy_auth_type.value or "").strip().lower()
if auth_type and auth_type not in {"token", "basic", "oauth", "jwt"}:
raise AuthConfigError(f"Unsupported AUTH_TYPE: {auth_type}")
+ try:
+ config.security.token_hash_algorithm = normalize_token_hash_algorithm(
+ config.security.token_hash_algorithm
+ )
+ except ValueError as exc:
+ raise AuthConfigError(str(exc)) from exc
modern_auth_explicit = any(
[
diff --git a/doris_mcp_server/utils/secret_policy.py
b/doris_mcp_server/utils/secret_policy.py
index 0bdc850..60a3b76 100644
--- a/doris_mcp_server/utils/secret_policy.py
+++ b/doris_mcp_server/utils/secret_policy.py
@@ -17,8 +17,14 @@
# under the License.
"""Shared policy for operator-provided authentication secrets."""
+import hashlib
+
MIN_SECRET_LENGTH = 32
MIN_DISTINCT_CHARACTERS = 10
+TOKEN_DIGEST_ALGORITHMS = {
+ "sha256": 64,
+ "sha512": 128,
+}
_INSECURE_MARKERS = (
"123456",
@@ -74,6 +80,51 @@ def validate_high_entropy_secret(value: str, *, setting:
str) -> str:
return value
+def normalize_token_hash_algorithm(
+ value: str,
+ *,
+ setting: str = "TOKEN_HASH_ALGORITHM",
+) -> str:
+ """Return a supported token digest algorithm name."""
+ algorithm = str(value or "").strip().lower()
+ if algorithm not in TOKEN_DIGEST_ALGORITHMS:
+ supported = ", ".join(sorted(TOKEN_DIGEST_ALGORITHMS))
+ raise ValueError(f"{setting} must be one of: {supported}")
+ return algorithm
+
+
+def build_token_digest(value: str, algorithm: str) -> str:
+ """Build a self-describing digest for a high-entropy bearer token."""
+ normalized_algorithm = normalize_token_hash_algorithm(algorithm)
+ digest = hashlib.new(normalized_algorithm,
value.encode("utf-8")).hexdigest()
+ return f"{normalized_algorithm}:{digest}"
+
+
+def normalize_token_digest(value: str, *, setting: str) -> str:
+ """Validate and normalize a persisted self-describing token digest."""
+ if not isinstance(value, str) or not value:
+ raise ValueError(f"{setting} is required")
+ if value != value.strip():
+ raise ValueError(f"{setting} must not contain leading or trailing
whitespace")
+
+ algorithm, separator, digest = value.partition(":")
+ algorithm = normalize_token_hash_algorithm(
+ algorithm,
+ setting=f"{setting} algorithm",
+ )
+ expected_length = TOKEN_DIGEST_ALGORITHMS[algorithm]
+ if (
+ separator != ":"
+ or len(digest) != expected_length
+ or any(character not in "0123456789abcdefABCDEF" for character in
digest)
+ ):
+ raise ValueError(
+ f"{setting} must use {algorithm}: followed by "
+ f"{expected_length} hexadecimal characters"
+ )
+ return f"{algorithm}:{digest.lower()}"
+
+
def is_static_token_environment_variable(name: str) -> bool:
"""Return whether an environment variable defines a static bearer token."""
if not name.startswith("TOKEN_"):
diff --git a/test/security/test_token_digest_storage.py
b/test/security/test_token_digest_storage.py
new file mode 100644
index 0000000..071f579
--- /dev/null
+++ b/test/security/test_token_digest_storage.py
@@ -0,0 +1,340 @@
+# 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 json
+import os
+import stat
+from datetime import datetime
+from pathlib import Path
+
+import pytest
+
+from doris_mcp_server.auth.token_manager import TokenManager
+from doris_mcp_server.utils.config import (
+ AuthConfigError,
+ DorisConfig,
+ _mark_source,
+ normalize_effective_auth_config,
+)
+from doris_mcp_server.utils.secret_policy import (
+ build_token_digest,
+ is_static_token_environment_variable,
+)
+
+STATIC_TOKEN = "V4nK8qR2mT7xP5cL9sD3hF6jY1uB0eG4iW8aN2zQ"
+
+
+def _config(tmp_path: Path, *, algorithm: str = "sha256") -> DorisConfig:
+ config = DorisConfig()
+ config.security.token_file_path = str(tmp_path / "tokens.json")
+ 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)
+
+
[email protected]
+async def test_created_token_is_returned_once_and_persisted_as_digest(
+ tmp_path,
+ monkeypatch,
+):
+ _clear_static_token_environment(monkeypatch)
+ config = _config(tmp_path)
+ manager = TokenManager(config)
+ try:
+ raw_token = await manager.create_token(
+ "generated",
+ expires_hours=24,
+ description="one-time display",
+ )
+ token_path = Path(config.security.token_file_path)
+ token_text = token_path.read_text(encoding="utf-8")
+ token_data = json.loads(token_text)
+ stored = token_data["tokens"][0]
+
+ assert token_data["version"] == "2.0"
+ assert raw_token not in token_text
+ assert "token" not in stored
+ assert stored["token_digest"].startswith("sha256:")
+ assert len(stored["token_digest"]) == len("sha256:") + 64
+ assert stat.S_IMODE(token_path.stat().st_mode) == 0o600
+
+ listed = await manager.list_tokens()
+ assert raw_token not in json.dumps(listed)
+ assert "token_digest" not in listed[0]
+ assert (await manager.validate_token(raw_token)).is_valid is True
+ finally:
+ manager.stop_hot_reload()
+
+ reloaded = TokenManager(config)
+ try:
+ result = await reloaded.validate_token(raw_token)
+ assert result.is_valid is True
+ assert result.token_info.token_id == "generated"
+ assert result.token_info.description == "one-time display"
+ finally:
+ reloaded.stop_hot_reload()
+
+
[email protected]
+async def
test_legacy_plaintext_file_is_atomically_migrated_without_expiry_extension(
+ tmp_path,
+ monkeypatch,
+):
+ _clear_static_token_environment(monkeypatch)
+ token_path = tmp_path / "tokens.json"
+ token_path.write_text(
+ json.dumps(
+ {
+ "version": "1.0",
+ "token": STATIC_TOKEN,
+ "tokens": [
+ {
+ "token_id": "legacy",
+ "token": STATIC_TOKEN,
+ "legacy_raw_copy": STATIC_TOKEN,
+ "created_at": "2026-07-01T00:00:00Z",
+ "expires_hours": 720,
+ "description": "legacy deployment",
+ "is_active": True,
+ }
+ ],
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ config = _config(tmp_path)
+ manager = TokenManager(config)
+ try:
+ migrated_text = token_path.read_text(encoding="utf-8")
+ migrated = json.loads(migrated_text)
+ stored = migrated["tokens"][0]
+
+ assert migrated["version"] == "2.0"
+ assert STATIC_TOKEN not in migrated_text
+ assert "token" not in stored
+ assert stored["token_digest"] == build_token_digest(STATIC_TOKEN,
"sha256")
+ assert stored["created_at"] == "2026-07-01T00:00:00Z"
+ assert stored["expires_at"] == "2026-07-31T00:00:00Z"
+ assert stat.S_IMODE(token_path.stat().st_mode) == 0o600
+ assert list(tmp_path.glob(".tokens.json.*.tmp")) == []
+ finally:
+ manager.stop_hot_reload()
+
+ sha512_default = _config(tmp_path, algorithm="sha512")
+ reloaded = TokenManager(sha512_default)
+ try:
+ result = await reloaded.validate_token(STATIC_TOKEN)
+ assert result.is_valid is True
+ assert result.token_info.created_at == datetime(2026, 7, 1)
+ assert result.token_info.expires_at == datetime(2026, 7, 31)
+ finally:
+ reloaded.stop_hot_reload()
+
+
+def test_legacy_plaintext_file_fails_closed_when_migration_cannot_be_written(
+ tmp_path,
+ monkeypatch,
+):
+ _clear_static_token_environment(monkeypatch)
+ token_path = tmp_path / "tokens.json"
+ token_path.write_text(
+ json.dumps(
+ {
+ "version": "1.0",
+ "tokens": [
+ {
+ "token_id": "legacy",
+ "token": STATIC_TOKEN,
+ "is_active": True,
+ }
+ ],
+ }
+ ),
+ encoding="utf-8",
+ )
+ monkeypatch.setattr(
+ TokenManager,
+ "_atomic_write_token_file",
+ lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("read only")),
+ )
+
+ with pytest.raises(ValueError, match="digest-only storage"):
+ TokenManager(_config(tmp_path))
+ assert STATIC_TOKEN in token_path.read_text(encoding="utf-8")
+
+
[email protected]
+async def test_digest_file_bootstraps_static_auth_with_embedded_algorithm(
+ tmp_path,
+ monkeypatch,
+):
+ _clear_static_token_environment(monkeypatch)
+ token_path = tmp_path / "tokens.json"
+ token_path.write_text(
+ json.dumps(
+ {
+ "version": "2.0",
+ "tokens": [
+ {
+ "token_id": "sha512-record",
+ "token_digest": build_token_digest(STATIC_TOKEN,
"sha512"),
+ "created_at": "2026-07-29T00:00:00Z",
+ "expires_at": None,
+ "last_used": None,
+ "is_active": True,
+ }
+ ],
+ }
+ ),
+ encoding="utf-8",
+ )
+ config = _config(tmp_path, algorithm="sha256")
+ config.security.enable_token_auth = True
+ _mark_source(config, "enable_token_auth", "test")
+
+ normalize_effective_auth_config(config)
+ manager = TokenManager(config)
+ try:
+ assert (await manager.validate_token(STATIC_TOKEN)).is_valid is True
+ finally:
+ manager.stop_hot_reload()
+
+
[email protected](
+ "entry,error",
+ [
+ ({"token_id": "missing"}, "exactly one"),
+ (
+ {
+ "token_id": "both",
+ "token": STATIC_TOKEN,
+ "token_digest": build_token_digest(STATIC_TOKEN, "sha256"),
+ },
+ "exactly one",
+ ),
+ (
+ {"token_id": "invalid", "token_digest": "sha256:not-a-digest"},
+ "64 hexadecimal",
+ ),
+ ],
+)
+def test_static_auth_rejects_invalid_digest_records(
+ tmp_path,
+ monkeypatch,
+ entry,
+ error,
+):
+ _clear_static_token_environment(monkeypatch)
+ token_path = tmp_path / "tokens.json"
+ token_path.write_text(json.dumps({"tokens": [entry]}), encoding="utf-8")
+ config = _config(tmp_path)
+ config.security.enable_token_auth = True
+ _mark_source(config, "enable_token_auth", "test")
+
+ with pytest.raises(AuthConfigError, match=error):
+ normalize_effective_auth_config(config)
+
+
+def test_static_auth_rejects_unsupported_digest_algorithm(tmp_path):
+ config = _config(tmp_path, algorithm="md5")
+
+ with pytest.raises(AuthConfigError, match="TOKEN_HASH_ALGORITHM"):
+ normalize_effective_auth_config(config)
+
+
[email protected]
+async def test_create_rolls_back_when_digest_file_cannot_be_persisted(
+ tmp_path,
+ monkeypatch,
+):
+ _clear_static_token_environment(monkeypatch)
+ manager = TokenManager(_config(tmp_path))
+ monkeypatch.setattr(
+ manager,
+ "_atomic_write_token_file",
+ lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("disk full")),
+ )
+ try:
+ with pytest.raises(OSError, match="disk full"):
+ await manager.create_token("not-persisted",
custom_token=STATIC_TOKEN)
+ assert manager._tokens == {}
+ assert manager._token_ids == {}
+ assert manager._digest_algorithms == set()
+ assert (await manager.validate_token(STATIC_TOKEN)).is_valid is False
+ finally:
+ manager.stop_hot_reload()
+
+
[email protected]
+async def test_revoke_keeps_live_token_when_digest_file_cannot_be_persisted(
+ tmp_path,
+ monkeypatch,
+):
+ _clear_static_token_environment(monkeypatch)
+ manager = TokenManager(_config(tmp_path))
+ try:
+ raw_token = await manager.create_token("still-live")
+ monkeypatch.setattr(
+ manager,
+ "_remove_token_from_file",
+ lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("read
only")),
+ )
+
+ assert await manager.revoke_token("still-live") is False
+ assert (await manager.validate_token(raw_token)).is_valid is True
+ finally:
+ manager.stop_hot_reload()
+
+
[email protected]
+async def test_revoke_and_export_keep_every_record_digest_only(
+ tmp_path,
+ monkeypatch,
+):
+ _clear_static_token_environment(monkeypatch)
+ config = _config(tmp_path)
+ manager = TokenManager(config)
+ try:
+ first = await manager.create_token("first")
+ second = await manager.create_token("second")
+ assert await manager.revoke_token("first") is True
+
+ primary_text =
Path(config.security.token_file_path).read_text(encoding="utf-8")
+ primary = json.loads(primary_text)
+ assert first not in primary_text
+ assert second not in primary_text
+ assert [entry["token_id"] for entry in primary["tokens"]] == ["second"]
+ assert set(primary["tokens"][0]) >= {"token_id", "token_digest"}
+ assert "token" not in primary["tokens"][0]
+
+ export_path = tmp_path / "exported-tokens.json"
+ assert await manager.save_tokens_to_file(str(export_path)) is True
+ exported_text = export_path.read_text(encoding="utf-8")
+ exported = json.loads(exported_text)
+ assert first not in exported_text
+ assert second not in exported_text
+ assert exported["version"] == "2.0"
+ assert "token" not in exported["tokens"][0]
+ assert stat.S_IMODE(export_path.stat().st_mode) == 0o600
+ finally:
+ manager.stop_hot_reload()
diff --git a/tokens.json b/tokens.json
index aae0b8f..1ae66dd 100644
--- a/tokens.json
+++ b/tokens.json
@@ -1,10 +1,11 @@
{
- "version": "1.0",
- "description": "Credential-free Doris MCP Server token configuration
template",
+ "version": "2.0",
+ "description": "Credential-free Doris MCP Server digest-only token
configuration template",
"tokens": [],
"notes": [
"No usable credential is shipped with the server.",
- "Before enabling static token authentication, configure at least one
active token with 32 or more characters generated by a cryptographically secure
random generator.",
- "Do not commit deployment credentials to source control."
+ "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.",
+ "Do not commit deployment credentials or usable token digests to source
control."
]
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]