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 <noreply@anthropic.com>
This commit is contained in:
2026-02-06 03:37:35 +03:00
parent 5342cffac7
commit 71a0a6e6d1
6 changed files with 155 additions and 32 deletions

View File

@@ -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

View File

@@ -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"

View File

@@ -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)
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

View File

@@ -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)

View File

@@ -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(

View File

@@ -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: