This is an automated email from the ASF dual-hosted git repository. FreeOnePlus pushed a commit to branch agent/sec-014-token-digest-storage in repository https://gitbox.apache.org/repos/asf/doris-mcp-server.git
commit 7821d5b1916adf31e0ec208e444e418f49e86c34 Author: FreeOnePlus <[email protected]> AuthorDate: Wed Jul 29 23:06:06 2026 +0800 fix: persist static tokens as digests --- .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]
