Skip to content

Commit b264918

Browse files
julienldclaude
andauthored
feat(oauth): auto-persist encryption key and auto-detect url (#532)
* feat(oauth): auto-persist encryption key to ~/.ha-mcp/oauth_key Users no longer need to manually generate OAUTH_ENCRYPTION_KEY. The key is automatically generated on first run and persisted to ~/.ha-mcp/oauth_key, surviving server restarts. Key priority: 1. OAUTH_ENCRYPTION_KEY env var (advanced users/multi-instance) 2. ~/.ha-mcp/oauth_key (auto-generated, persists) 3. Temporary key (dev/testing only) Benefits: - Zero configuration for users - Tokens survive restarts automatically - Secure file permissions (0600) - Multi-instance support via file copy or env var - Actual security = HA LLAT revocation (OAuth token is just container) Added tests for key persistence and env var override. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * docs: remove OAUTH_ENCRYPTION_KEY from setup instructions Key is now auto-generated and persisted to ~/.ha-mcp/oauth_key. Users no longer need to manually generate or set the encryption key. Updated: - Removed OAUTH_ENCRYPTION_KEY from Docker/uvx examples - Removed from environment variables table - Updated FAQ to explain automatic persistence - Added multi-instance deployment instructions * feat(oauth): auto-detect base URL from incoming requests Users no longer need to set MCP_BASE_URL. The server automatically detects its public URL from request headers (X-Forwarded-Host, X-Forwarded-Proto) when using tunnels or reverse proxies. How it works: 1. MCP_BASE_URL env var (if set) - Manual override 2. Auto-detect from first request - Uses proxy headers 3. Cache detected URL - Reused for subsequent requests Benefits: - Zero configuration for typical users - Works automatically with Cloudflare Tunnel, ngrok, etc. - Respects X-Forwarded-* headers from reverse proxies - Manual override still available for advanced setups Changes: - Made base_url optional in HomeAssistantOAuthProvider - Added _get_base_url(request) method for auto-detection - Updated all base_url usage to support auto-detection - Updated __main__.py to not require MCP_BASE_URL - Updated docs to remove MCP_BASE_URL from setup Tests: - Added test_base_url_auto_detection - Verifies detection from headers - Added test_base_url_configured_takes_precedence - Ensures override works - All 43 OAuth tests pass ✅ Example: # Before (required MCP_BASE_URL) docker run -e MCP_BASE_URL=https://tunnel.com ha-mcp-oauth # After (auto-detected) docker run -p 8086:8086 ha-mcp-oauth # Just access via tunnel - URL detected automatically! Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 7445cb3 commit b264918

4 files changed

Lines changed: 216 additions & 47 deletions

File tree

docs/OAUTH.md

Lines changed: 15 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -27,26 +27,21 @@ OAuth authentication allows users to enter their Home Assistant credentials via
2727
```bash
2828
docker run -d --name ha-mcp-oauth \
2929
-p 8086:8086 \
30-
-e MCP_BASE_URL=https://your-tunnel.com \
31-
-e OAUTH_ENCRYPTION_KEY=$(python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())") \
3230
ghcr.io/homeassistant-ai/ha-mcp:latest \
3331
ha-mcp-oauth
3432
```
3533

3634
**uvx:**
3735
```bash
38-
MCP_BASE_URL=https://your-tunnel.com \
39-
OAUTH_ENCRYPTION_KEY=$(python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())") \
4036
uvx ha-mcp@latest ha-mcp-oauth
4137
```
4238

43-
### 2. Environment Variables
39+
### 2. Environment Variables (All Optional!)
4440

45-
| Variable | Description | Example |
41+
| Variable | Description | Default |
4642
|----------|-------------|---------|
47-
| `MCP_BASE_URL` | Your public domain (no path) | `https://your-tunnel.com` |
48-
| `OAUTH_ENCRYPTION_KEY` | 32-byte key for token encryption | Generate with command above |
49-
| `MCP_PORT` | Server port (optional) | `8086` (default) |
43+
| `MCP_PORT` | Server port | `8086` |
44+
| `MCP_BASE_URL` | Public URL (auto-detected if not set) | Detected from request headers |
5045

5146
### 3. Expose with HTTPS
5247

@@ -73,17 +68,14 @@ For production, set up a [persistent Cloudflare Tunnel](https://developers.cloud
7368

7469
### "404 Not Found" when connecting
7570

76-
**Problem:** `MCP_BASE_URL` includes `/mcp` at the end.
71+
Make sure you're using the correct URL in Claude.ai:
7772

78-
```bash
79-
# ❌ Wrong
80-
MCP_BASE_URL=https://your-tunnel.com/mcp
81-
82-
# ✅ Correct
83-
MCP_BASE_URL=https://your-tunnel.com
73+
```
74+
✅ Correct: https://your-tunnel.com/mcp
75+
❌ Wrong: https://your-tunnel.com
8476
```
8577

86-
Then use `https://your-tunnel.com/mcp` in Claude.ai.
78+
The server auto-detects its base URL from incoming requests, so you don't need to configure anything - just use `your-tunnel/mcp` in Claude.ai.
8779

8880
### "Invalid credentials" on consent form
8981

@@ -97,18 +89,17 @@ Verify your Long-Lived Access Token:
9789
- Generate fresh token in HA: Profile → Security → Long-lived access tokens
9890
- Copy the complete token
9991

100-
### Session expires after server restart
92+
### Do tokens persist across server restarts?
10193

102-
Set a persistent `OAUTH_ENCRYPTION_KEY`. Without it, tokens are invalidated when the server restarts.
94+
**Yes!** The encryption key is automatically saved to `~/.ha-mcp/oauth_key` and reused on restart.
10395

96+
**For multi-instance deployments**, copy the key file to other servers:
10497
```bash
105-
# Generate key
106-
python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
107-
108-
# Use it
109-
docker run -e OAUTH_ENCRYPTION_KEY=your-key-here ...
98+
scp ~/.ha-mcp/oauth_key server2:~/.ha-mcp/
11099
```
111100

101+
Or use the `OAUTH_ENCRYPTION_KEY` environment variable to share the same key across all instances.
102+
112103
### Can I use OAuth with Home Assistant OS?
113104

114105
No. The ha-mcp add-on doesn't support OAuth mode.

src/ha_mcp/__main__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -642,7 +642,7 @@ def main_oauth() -> None:
642642
Environment:
643643
- MCP_PORT (optional, default: 8086)
644644
- MCP_SECRET_PATH (optional, default: "/mcp")
645-
- MCP_BASE_URL (optional, default: http://localhost:{MCP_PORT})
645+
- MCP_BASE_URL (optional, auto-detected from incoming requests)
646646
- LOG_LEVEL (optional, default: INFO)
647647
648648
Note: HOMEASSISTANT_URL and HOMEASSISTANT_TOKEN are NOT required in this mode.
@@ -662,7 +662,7 @@ def main_oauth() -> None:
662662

663663
port = int(os.getenv("MCP_PORT", "8086"))
664664
path = os.getenv("MCP_SECRET_PATH", "/mcp")
665-
base_url = os.getenv("MCP_BASE_URL", f"http://localhost:{port}")
665+
base_url = os.getenv("MCP_BASE_URL") # Optional - will be auto-detected if not set
666666

667667
# Set up signal handlers
668668
_setup_signal_handlers()

src/ha_mcp/auth/provider.py

Lines changed: 109 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ class HomeAssistantOAuthProvider(OAuthProvider):
8080

8181
def __init__(
8282
self,
83-
base_url: AnyHttpUrl | str,
83+
base_url: AnyHttpUrl | str | None = None,
8484
issuer_url: AnyHttpUrl | str | None = None,
8585
service_documentation_url: AnyHttpUrl | str | None = None,
8686
client_registration_options: ClientRegistrationOptions | None = None,
@@ -91,7 +91,7 @@ def __init__(
9191
Initialize the Home Assistant OAuth provider.
9292
9393
Args:
94-
base_url: The public URL of this MCP server
94+
base_url: The public URL of this MCP server (auto-detected if not provided)
9595
issuer_url: The issuer URL for OAuth metadata (defaults to base_url)
9696
service_documentation_url: URL to service documentation
9797
client_registration_options: Options for client registration
@@ -138,28 +138,65 @@ def __init__(
138138
self._encryption_key = self._get_or_create_encryption_key()
139139
self._cipher = Fernet(self._encryption_key)
140140

141-
logger.info(f"HomeAssistantOAuthProvider initialized with base_url={base_url}")
141+
# Auto-detected base URL (cached from first request)
142+
self._detected_base_url: str | None = None
143+
144+
if base_url:
145+
logger.info(f"HomeAssistantOAuthProvider initialized with base_url={base_url}")
146+
else:
147+
logger.info("HomeAssistantOAuthProvider initialized (base_url will be auto-detected)")
142148

143149
def _get_or_create_encryption_key(self) -> bytes:
144150
"""
145-
Get encryption key from environment or generate a new one.
151+
Get encryption key from environment, file, or generate a new one.
146152
147-
For production: Set OAUTH_ENCRYPTION_KEY environment variable
148-
For dev: A key is generated (tokens won't survive restarts)
153+
Priority:
154+
1. OAUTH_ENCRYPTION_KEY environment variable (for advanced users)
155+
2. Persistent key file at ~/.ha-mcp/oauth_key (auto-generated)
156+
3. Generate temporary key (dev/testing only)
149157
"""
158+
# Check environment variable first (highest priority)
150159
key_str = os.getenv("OAUTH_ENCRYPTION_KEY")
151160
if key_str:
152161
logger.info("Using OAUTH_ENCRYPTION_KEY from environment")
153162
return key_str.encode()
154-
else:
155-
# Generate a new key (tokens won't survive restart)
156-
key = Fernet.generate_key()
163+
164+
# Try to load from persistent file
165+
from pathlib import Path
166+
key_file = Path.home() / ".ha-mcp" / "oauth_key"
167+
168+
if key_file.exists():
169+
try:
170+
key_bytes = key_file.read_bytes()
171+
# Validate it's a proper Fernet key
172+
Fernet(key_bytes)
173+
logger.info(f"Using persistent OAuth key from {key_file}")
174+
return key_bytes
175+
except Exception as e:
176+
logger.warning(
177+
f"Failed to load OAuth key from {key_file}: {e}. "
178+
"Generating new key."
179+
)
180+
181+
# Generate new key and persist it
182+
key = Fernet.generate_key()
183+
try:
184+
key_file.parent.mkdir(parents=True, exist_ok=True)
185+
key_file.write_bytes(key)
186+
# Set restrictive permissions (owner read/write only)
187+
os.chmod(key_file, 0o600)
188+
logger.info(
189+
f"Generated new OAuth encryption key and saved to {key_file}. "
190+
"This key will persist across server restarts. "
191+
"To share across multiple instances, copy this file or set OAUTH_ENCRYPTION_KEY."
192+
)
193+
except Exception as e:
157194
logger.warning(
158-
"No OAUTH_ENCRYPTION_KEY set - generated temporary key. "
159-
"Tokens will be invalid after server restart. "
160-
"Set OAUTH_ENCRYPTION_KEY for persistence."
195+
f"Failed to persist OAuth key to {key_file}: {e}. "
196+
"Using temporary key - tokens will be invalid after restart."
161197
)
162-
return key
198+
199+
return key
163200

164201
def _encrypt_credentials(self, ha_url: str, ha_token: str) -> str:
165202
"""Encrypt HA credentials into a token string."""
@@ -180,6 +217,55 @@ def _decrypt_credentials(self, token: str) -> tuple[str, str] | None:
180217
logger.debug(f"Failed to decrypt token: {e}")
181218
return None
182219

220+
def _get_base_url(self, request: Request | None = None) -> str:
221+
"""
222+
Get the base URL, auto-detecting from request if not configured.
223+
224+
Args:
225+
request: Starlette request object for auto-detection
226+
227+
Returns:
228+
Base URL string
229+
"""
230+
# Use configured base_url if available
231+
if self.base_url:
232+
return str(self.base_url).rstrip('/')
233+
234+
# Use cached detected URL if available
235+
if self._detected_base_url:
236+
return self._detected_base_url
237+
238+
# Auto-detect from request
239+
if request:
240+
# Get protocol from X-Forwarded-Proto or request scheme
241+
proto = request.headers.get(
242+
"X-Forwarded-Proto",
243+
"https" if request.url.scheme == "https" else "http"
244+
)
245+
246+
# Get host from X-Forwarded-Host or Host header
247+
host = request.headers.get(
248+
"X-Forwarded-Host",
249+
request.headers.get("Host", request.url.netloc)
250+
)
251+
252+
# Remove any path components (we only want the origin)
253+
base = f"{proto}://{host}"
254+
255+
# Cache the detected URL
256+
self._detected_base_url = base
257+
logger.info(f"Auto-detected base URL from request: {base}")
258+
259+
return base
260+
261+
# Fallback to localhost (shouldn't happen in production)
262+
logger.warning(
263+
"No base_url configured and no request available for auto-detection. "
264+
"Using http://localhost:8086 as fallback. "
265+
"Set MCP_BASE_URL environment variable for production."
266+
)
267+
return "http://localhost:8086"
268+
183269
def get_routes(self, mcp_path: str | None = None) -> list[Route]:
184270
"""
185271
Get OAuth routes including custom consent form routes.
@@ -201,9 +287,12 @@ async def enhanced_metadata_handler(request: Request) -> Response:
201287
"""Enhanced OAuth metadata handler with Claude.ai compatibility."""
202288
from mcp.server.auth.routes import build_metadata
203289

290+
# Get base URL (configured or auto-detected)
291+
base = self._get_base_url(request)
292+
204293
# Get base metadata from MCP SDK
205294
metadata = build_metadata(
206-
issuer_url=self.base_url, # type: ignore[arg-type]
295+
issuer_url=AnyHttpUrl(base),
207296
service_documentation_url=AnyHttpUrl("https://github.com/homeassistant-ai/ha-mcp"),
208297
client_registration_options=self.client_registration_options or {}, # type: ignore[arg-type]
209298
revocation_options=self.revocation_options or {}, # type: ignore[arg-type]
@@ -346,8 +435,8 @@ async def authorize(
346435
}
347436

348437
# Build consent form URL
349-
assert self.base_url is not None
350-
consent_url = f"{str(self.base_url).rstrip('/')}/consent?txn_id={txn_id}"
438+
base = self._get_base_url()
439+
consent_url = f"{base}/consent?txn_id={txn_id}"
351440

352441
logger.debug(f"Redirecting to consent form: {consent_url}")
353442
return consent_url
@@ -435,15 +524,15 @@ async def _consent_post(self, request: Request) -> Response:
435524

436525
if not ha_url or not ha_token:
437526
# Redirect back to form with error
438-
assert self.base_url is not None
527+
base = self._get_base_url(request)
439528
error_params = urlencode(
440529
{
441530
"txn_id": txn_id,
442531
"error": "Please provide both Home Assistant URL and access token.",
443532
}
444533
)
445534
return RedirectResponse(
446-
f"{str(self.base_url).rstrip('/')}/consent?{error_params}",
535+
f"{base}/consent?{error_params}",
447536
status_code=303,
448537
)
449538

@@ -452,15 +541,15 @@ async def _consent_post(self, request: Request) -> Response:
452541
str(ha_url), str(ha_token)
453542
)
454543
if validation_error:
455-
assert self.base_url is not None
544+
base = self._get_base_url(request)
456545
error_params = urlencode(
457546
{
458547
"txn_id": txn_id,
459548
"error": validation_error,
460549
}
461550
)
462551
return RedirectResponse(
463-
f"{str(self.base_url).rstrip('/')}/consent?{error_params}",
552+
f"{base}/consent?{error_params}",
464553
status_code=303,
465554
)
466555

0 commit comments

Comments
 (0)