diff --git a/.env.example b/.env.example index 1dee38e..09adc31 100644 --- a/.env.example +++ b/.env.example @@ -34,4 +34,31 @@ UNRAID_MAX_RECONNECT_ATTEMPTS=10 # Optional: Custom log file path for subscription auto-start diagnostics # Defaults to standard log if not specified -# UNRAID_AUTOSTART_LOG_PATH=/custom/path/to/autostart.log \ No newline at end of file +# UNRAID_AUTOSTART_LOG_PATH=/custom/path/to/autostart.log + +# Google OAuth Protection (Optional) +# ----------------------------------- +# Protects the MCP HTTP server — clients must authenticate with Google before calling tools. +# Requires streamable-http or sse transport (not stdio). +# +# Setup: +# 1. Google Cloud Console → APIs & Services → Credentials +# 2. Create OAuth 2.0 Client ID (Web application) +# 3. Authorized redirect URIs: /auth/callback +# 4. Copy Client ID and Client Secret below +# +# UNRAID_MCP_BASE_URL: Public URL clients use to reach THIS server (for redirect URIs). +# Examples: +# http://10.1.0.2:6970 (LAN) +# http://100.x.x.x:6970 (Tailscale) +# https://mcp.yourdomain.com (reverse proxy) +# +# UNRAID_MCP_JWT_SIGNING_KEY: Stable secret for signing FastMCP JWT tokens. +# Generate once: python3 -c "import secrets; print(secrets.token_hex(32))" +# NEVER change after first use — all client sessions will be invalidated. +# +# Leave GOOGLE_CLIENT_ID empty to disable OAuth (server runs unprotected). +# GOOGLE_CLIENT_ID= +# GOOGLE_CLIENT_SECRET= +# UNRAID_MCP_BASE_URL=http://10.1.0.2:6970 +# UNRAID_MCP_JWT_SIGNING_KEY= \ No newline at end of file diff --git a/tests/test_auth_settings.py b/tests/test_auth_settings.py new file mode 100644 index 0000000..1d9c417 --- /dev/null +++ b/tests/test_auth_settings.py @@ -0,0 +1,84 @@ +"""Tests for Google OAuth settings loading.""" + +import importlib + + +def _reload_settings(monkeypatch, overrides: dict) -> object: + """Reload settings module with given env vars set.""" + for k, v in overrides.items(): + monkeypatch.setenv(k, v) + import unraid_mcp.config.settings as mod + + importlib.reload(mod) + return mod + + +def test_google_auth_defaults_to_empty(monkeypatch): + """Google auth vars default to empty string when not set.""" + monkeypatch.delenv("GOOGLE_CLIENT_ID", raising=False) + monkeypatch.delenv("GOOGLE_CLIENT_SECRET", raising=False) + monkeypatch.delenv("UNRAID_MCP_BASE_URL", raising=False) + monkeypatch.delenv("UNRAID_MCP_JWT_SIGNING_KEY", raising=False) + mod = _reload_settings(monkeypatch, {}) + assert mod.GOOGLE_CLIENT_ID == "" + assert mod.GOOGLE_CLIENT_SECRET == "" + assert mod.UNRAID_MCP_BASE_URL == "" + assert mod.UNRAID_MCP_JWT_SIGNING_KEY == "" + + +def test_google_auth_reads_env_vars(monkeypatch): + """Google auth vars are read from environment.""" + mod = _reload_settings( + monkeypatch, + { + "GOOGLE_CLIENT_ID": "test-client-id.apps.googleusercontent.com", + "GOOGLE_CLIENT_SECRET": "GOCSPX-test-secret", + "UNRAID_MCP_BASE_URL": "http://10.1.0.2:6970", + "UNRAID_MCP_JWT_SIGNING_KEY": "a" * 32, + }, + ) + assert mod.GOOGLE_CLIENT_ID == "test-client-id.apps.googleusercontent.com" + assert mod.GOOGLE_CLIENT_SECRET == "GOCSPX-test-secret" + assert mod.UNRAID_MCP_BASE_URL == "http://10.1.0.2:6970" + assert mod.UNRAID_MCP_JWT_SIGNING_KEY == "a" * 32 + + +def test_google_auth_enabled_requires_both_vars(monkeypatch): + """is_google_auth_configured() requires both client_id and client_secret.""" + # Only client_id — not configured + mod = _reload_settings( + monkeypatch, + { + "GOOGLE_CLIENT_ID": "test-id", + "GOOGLE_CLIENT_SECRET": "", + "UNRAID_MCP_BASE_URL": "http://10.1.0.2:6970", + }, + ) + monkeypatch.delenv("GOOGLE_CLIENT_SECRET", raising=False) + importlib.reload(mod) + assert not mod.is_google_auth_configured() + + # Both set — configured + mod2 = _reload_settings( + monkeypatch, + { + "GOOGLE_CLIENT_ID": "test-id", + "GOOGLE_CLIENT_SECRET": "test-secret", + "UNRAID_MCP_BASE_URL": "http://10.1.0.2:6970", + }, + ) + assert mod2.is_google_auth_configured() + + +def test_google_auth_requires_base_url(monkeypatch): + """is_google_auth_configured() is False when base_url is missing.""" + mod = _reload_settings( + monkeypatch, + { + "GOOGLE_CLIENT_ID": "test-id", + "GOOGLE_CLIENT_SECRET": "test-secret", + }, + ) + monkeypatch.delenv("UNRAID_MCP_BASE_URL", raising=False) + importlib.reload(mod) + assert not mod.is_google_auth_configured() diff --git a/unraid_mcp/config/settings.py b/unraid_mcp/config/settings.py index 9075ec9..b7ae4a0 100644 --- a/unraid_mcp/config/settings.py +++ b/unraid_mcp/config/settings.py @@ -76,6 +76,28 @@ elif raw_verify_ssl in ["true", "1", "yes"]: else: # Path to CA bundle UNRAID_VERIFY_SSL = raw_verify_ssl +# Google OAuth Configuration (Optional) +# ------------------------------------- +# When set, the MCP HTTP server requires Google login before tool calls. +# UNRAID_MCP_BASE_URL must match the public URL clients use to reach this server. +# Google Cloud Console → Credentials → Authorized redirect URIs: +# Add: /auth/callback +GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "") +GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET", "") +UNRAID_MCP_BASE_URL = os.getenv("UNRAID_MCP_BASE_URL", "") + +# JWT signing key for FastMCP OAuth tokens. +# MUST be set to a stable secret so tokens survive server restarts. +# Generate once: python3 -c "import secrets; print(secrets.token_hex(32))" +# Never change this value — all existing tokens will be invalidated. +UNRAID_MCP_JWT_SIGNING_KEY = os.getenv("UNRAID_MCP_JWT_SIGNING_KEY", "") + + +def is_google_auth_configured() -> bool: + """Return True when all required Google OAuth vars are present.""" + return bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET and UNRAID_MCP_BASE_URL) + + # Logging Configuration LOG_LEVEL_STR = os.getenv("UNRAID_MCP_LOG_LEVEL", "INFO").upper() LOG_FILE_NAME = os.getenv("UNRAID_MCP_LOG_FILE", "unraid-mcp.log") @@ -155,6 +177,9 @@ def get_config_summary() -> dict[str, Any]: "log_file": str(LOG_FILE_PATH), "config_valid": is_valid, "missing_config": missing if not is_valid else None, + "google_auth_enabled": is_google_auth_configured(), + "google_auth_base_url": UNRAID_MCP_BASE_URL if is_google_auth_configured() else None, + "jwt_signing_key_configured": bool(UNRAID_MCP_JWT_SIGNING_KEY), }