From 71a0a6e6d14a9e6899b9d78ce9985afd9362fa7c Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 6 Feb 2026 03:37:35 +0300 Subject: [PATCH] Add multi-token authentication with client labels - Replace single api_token with api_tokens dict (label: token pairs) - Add context-aware logging to track which client made each request - Implement token label lookup with secure comparison - Add logging middleware to inject token labels into request context - Update logging format to display [label] in all log messages - Fix WebSocket authentication to use new multi-token system - Update CLI --show-token to display all tokens with labels - Update config generation to use api_tokens format - Update README with multi-token documentation - Update config.example.yaml with multiple token examples Benefits: - Easy identification of clients in logs (Home Assistant, mobile, web UI, etc.) - Per-client token management and revocation - Better security and auditability Example log output: 2026-02-06 03:36:20,806 - [home_assistant] - WebSocket client connected Co-Authored-By: Claude Sonnet 4.5 --- README.md | 40 ++++++++++++++++++++-- config.example.yaml | 8 +++-- media_server/auth.py | 65 ++++++++++++++++++++++++++---------- media_server/config.py | 10 +++--- media_server/main.py | 56 +++++++++++++++++++++++++++---- media_server/routes/media.py | 8 ++++- 6 files changed, 155 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 992c0cd..1dbc26f 100644 --- a/README.md +++ b/README.md @@ -140,11 +140,46 @@ Configuration file locations: ```yaml host: 0.0.0.0 port: 8765 -api_token: your-secret-token-here + +# API Tokens - Multiple tokens with labels for client identification +api_tokens: + home_assistant: "your-home-assistant-token-here" + mobile: "your-mobile-app-token-here" + web_ui: "your-web-ui-token-here" + poll_interval: 1.0 log_level: INFO ``` +### Authentication + +The media server supports multiple API tokens with friendly labels. This allows you to: +- Issue different tokens for different clients (Home Assistant, mobile apps, web UI, etc.) +- Identify which client is making requests in the server logs +- Revoke individual tokens without affecting other clients + +**Token labels** appear in all server logs, making it easy to track and debug client connections: + +``` +2026-02-06 03:36:20,806 - media_server.services.websocket_manager - [home_assistant] - INFO - WebSocket client connected +2026-02-06 03:28:24,258 - media_server.routes.scripts - [mobile] - INFO - Executing script: lock_screen +``` + +**Viewing your tokens:** +```bash +python -m media_server.main --show-token +``` + +Output: +``` +Config directory: C:\Users\...\AppData\Roaming\media-server + +API Tokens: + home_assistant B04zhGDjnxH6LIwxL3VOT0F4qORwaipD7LoDyeAG4EU + mobile xyz123... + web_ui abc456... +``` + ### Environment Variables All settings can be overridden with environment variables (prefix: `MEDIA_SERVER_`): @@ -152,10 +187,11 @@ All settings can be overridden with environment variables (prefix: `MEDIA_SERVER ```bash export MEDIA_SERVER_HOST=0.0.0.0 export MEDIA_SERVER_PORT=8765 -export MEDIA_SERVER_API_TOKEN=your-token export MEDIA_SERVER_LOG_LEVEL=DEBUG ``` +**Note:** For multi-token configuration, use the config.yaml file. Environment variables only support single-token mode. + ## API Reference ### Health Check diff --git a/config.example.yaml b/config.example.yaml index f3c2000..83b722d 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -2,8 +2,12 @@ # Copy this file to config.yaml and customize as needed. # A secure token will be auto-generated on first run if not specified. -# API Token (generate a secure random token) -api_token: "your-secure-token-here" +# API Tokens - Multiple tokens with friendly labels +# This allows you to identify which client is making requests in the logs +api_tokens: + home_assistant: "your-home-assistant-token-here" + mobile: "your-mobile-app-token-here" + web_ui: "your-web-ui-token-here" # Server settings host: "0.0.0.0" diff --git a/media_server/auth.py b/media_server/auth.py index 8b70090..b374a9f 100644 --- a/media_server/auth.py +++ b/media_server/auth.py @@ -1,5 +1,7 @@ """Authentication middleware and utilities.""" +import secrets +from contextvars import ContextVar from typing import Optional from fastapi import Depends, HTTPException, Query, Request, status @@ -9,6 +11,24 @@ from .config import settings security = HTTPBearer(auto_error=False) +# Context variable to store current request's token label +token_label_var: ContextVar[str] = ContextVar("token_label", default="unknown") + + +def get_token_label(token: str) -> Optional[str]: + """Get the label for a token. Returns None if token is invalid. + + Args: + token: The token to look up + + Returns: + The label for the token, or None if invalid + """ + for label, stored_token in settings.api_tokens.items(): + if secrets.compare_digest(stored_token, token): + return label + return None + async def verify_token( request: Request, @@ -21,7 +41,7 @@ async def verify_token( credentials: The bearer token credentials Returns: - The validated token + The token label Raises: HTTPException: If the token is missing or invalid @@ -33,14 +53,17 @@ async def verify_token( headers={"WWW-Authenticate": "Bearer"}, ) - if credentials.credentials != settings.api_token: + label = get_token_label(credentials.credentials) + if label is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication token", headers={"WWW-Authenticate": "Bearer"}, ) - return credentials.credentials + # Set label in context for logging + token_label_var.set(label) + return label class TokenAuth: @@ -54,7 +77,7 @@ class TokenAuth: request: Request, credentials: HTTPAuthorizationCredentials = Depends(security), ) -> str | None: - """Verify the token and return it or raise an exception.""" + """Verify the token and return the label or raise an exception.""" if credentials is None: if self.auto_error: raise HTTPException( @@ -64,7 +87,8 @@ class TokenAuth: ) return None - if credentials.credentials != settings.api_token: + label = get_token_label(credentials.credentials) + if label is None: if self.auto_error: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -73,7 +97,9 @@ class TokenAuth: ) return None - return credentials.credentials + # Set label in context for logging + token_label_var.set(label) + return label async def verify_token_or_query( @@ -89,23 +115,28 @@ async def verify_token_or_query( token: Token from query parameter Returns: - The validated token + The token label Raises: HTTPException: If the token is missing or invalid """ + label = None + # Try header first if credentials is not None: - if credentials.credentials == settings.api_token: - return credentials.credentials + label = get_token_label(credentials.credentials) # Try query parameter - if token is not None: - if token == settings.api_token: - return token + if label is None and token is not None: + label = get_token_label(token) - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Missing or invalid authentication token", - headers={"WWW-Authenticate": "Bearer"}, - ) + if label is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing or invalid authentication token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Set label in context for logging + token_label_var.set(label) + return label diff --git a/media_server/config.py b/media_server/config.py index f00acda..fe48842 100644 --- a/media_server/config.py +++ b/media_server/config.py @@ -46,9 +46,9 @@ class Settings(BaseSettings): port: int = Field(default=8765, description="Server port") # Authentication - api_token: str = Field( - default_factory=lambda: secrets.token_urlsafe(32), - description="API authentication token", + api_tokens: dict[str, str] = Field( + default_factory=lambda: {"default": secrets.token_urlsafe(32)}, + description="Named API tokens for access control (label: token pairs)", ) # Media controller settings @@ -128,7 +128,9 @@ def generate_default_config(path: Optional[Path] = None) -> Path: config = { "host": "0.0.0.0", "port": 8765, - "api_token": secrets.token_urlsafe(32), + "api_tokens": { + "default": secrets.token_urlsafe(32), + }, "poll_interval": 1.0, "log_level": "INFO", # Audio device to control (use GET /api/audio/devices to list available devices) diff --git a/media_server/main.py b/media_server/main.py index cee0d56..c9623bc 100644 --- a/media_server/main.py +++ b/media_server/main.py @@ -7,24 +7,38 @@ from contextlib import asynccontextmanager from pathlib import Path import uvicorn -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from . import __version__ +from .auth import get_token_label, token_label_var from .config import settings, generate_default_config, get_config_dir from .routes import audio_router, health_router, media_router, scripts_router from .services import get_media_controller from .services.websocket_manager import ws_manager +class TokenLabelFilter(logging.Filter): + """Add token label to log records.""" + + def filter(self, record): + record.token_label = token_label_var.get("unknown") + return True + + def setup_logging(): - """Configure application logging.""" + """Configure application logging with token labels.""" + # Create filter and handler + token_filter = TokenLabelFilter() + handler = logging.StreamHandler(sys.stdout) + handler.addFilter(token_filter) + logging.basicConfig( level=getattr(logging, settings.log_level.upper()), - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - handlers=[logging.StreamHandler(sys.stdout)], + format="%(asctime)s - %(name)s - [%(token_label)s] - %(levelname)s - %(message)s", + handlers=[handler], ) @@ -34,7 +48,10 @@ async def lifespan(app: FastAPI): setup_logging() logger = logging.getLogger(__name__) logger.info(f"Media Server starting on {settings.host}:{settings.port}") - logger.info(f"API Token: {settings.api_token[:8]}...") + + # Log all configured tokens + for label, token in settings.api_tokens.items(): + logger.info(f"API Token [{label}]: {token[:8]}...") # Start WebSocket status monitor controller = get_media_controller() @@ -66,6 +83,31 @@ def create_app() -> FastAPI: allow_headers=["*"], ) + # Add token logging middleware + @app.middleware("http") + async def token_logging_middleware(request: Request, call_next): + """Extract token label and set in context for logging.""" + token_label = "unknown" + + # Try Authorization header + auth_header = request.headers.get("authorization", "") + if auth_header.startswith("Bearer "): + token = auth_header[7:] + label = get_token_label(token) + if label: + token_label = label + + # Try query parameter (for artwork endpoint) + elif "token" in request.query_params: + token = request.query_params["token"] + label = get_token_label(token) + if label: + token_label = label + + token_label_var.set(token_label) + response = await call_next(request) + return response + # Register routers app.include_router(audio_router) app.include_router(health_router) @@ -122,8 +164,10 @@ def main(): return if args.show_token: - print(f"API Token: {settings.api_token}") print(f"Config directory: {get_config_dir()}") + print(f"\nAPI Tokens:") + for label, token in settings.api_tokens.items(): + print(f" {label:20} {token}") return uvicorn.run( diff --git a/media_server/routes/media.py b/media_server/routes/media.py index 6c4a766..c5afdf7 100644 --- a/media_server/routes/media.py +++ b/media_server/routes/media.py @@ -282,10 +282,16 @@ async def websocket_endpoint( - {"type": "get_status"} - Request current status """ # Verify token - if token != settings.api_token: + from ..auth import get_token_label, token_label_var + + label = get_token_label(token) if token else None + if label is None: await websocket.close(code=4001, reason="Invalid authentication token") return + # Set label in context for logging + token_label_var.set(label) + await ws_manager.connect(websocket) try: