ha-mcp-dev 6.3.1.dev137__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. ha_mcp/__init__.py +51 -0
  2. ha_mcp/__main__.py +753 -0
  3. ha_mcp/_pypi_marker +0 -0
  4. ha_mcp/auth/__init__.py +10 -0
  5. ha_mcp/auth/consent_form.py +501 -0
  6. ha_mcp/auth/provider.py +786 -0
  7. ha_mcp/client/__init__.py +10 -0
  8. ha_mcp/client/rest_client.py +924 -0
  9. ha_mcp/client/websocket_client.py +656 -0
  10. ha_mcp/client/websocket_listener.py +340 -0
  11. ha_mcp/config.py +175 -0
  12. ha_mcp/errors.py +399 -0
  13. ha_mcp/py.typed +0 -0
  14. ha_mcp/resources/card_types.json +48 -0
  15. ha_mcp/resources/dashboard_guide.md +573 -0
  16. ha_mcp/server.py +189 -0
  17. ha_mcp/smoke_test.py +108 -0
  18. ha_mcp/tools/__init__.py +11 -0
  19. ha_mcp/tools/backup.py +457 -0
  20. ha_mcp/tools/device_control.py +687 -0
  21. ha_mcp/tools/enhanced.py +191 -0
  22. ha_mcp/tools/helpers.py +184 -0
  23. ha_mcp/tools/registry.py +199 -0
  24. ha_mcp/tools/smart_search.py +797 -0
  25. ha_mcp/tools/tools_addons.py +343 -0
  26. ha_mcp/tools/tools_areas.py +478 -0
  27. ha_mcp/tools/tools_blueprints.py +279 -0
  28. ha_mcp/tools/tools_bug_report.py +570 -0
  29. ha_mcp/tools/tools_calendar.py +391 -0
  30. ha_mcp/tools/tools_camera.py +149 -0
  31. ha_mcp/tools/tools_config_automations.py +508 -0
  32. ha_mcp/tools/tools_config_dashboards.py +1502 -0
  33. ha_mcp/tools/tools_config_entry_flow.py +223 -0
  34. ha_mcp/tools/tools_config_helpers.py +925 -0
  35. ha_mcp/tools/tools_config_info.py +258 -0
  36. ha_mcp/tools/tools_config_scripts.py +267 -0
  37. ha_mcp/tools/tools_entities.py +76 -0
  38. ha_mcp/tools/tools_filesystem.py +589 -0
  39. ha_mcp/tools/tools_groups.py +306 -0
  40. ha_mcp/tools/tools_hacs.py +787 -0
  41. ha_mcp/tools/tools_history.py +714 -0
  42. ha_mcp/tools/tools_integrations.py +297 -0
  43. ha_mcp/tools/tools_labels.py +713 -0
  44. ha_mcp/tools/tools_mcp_component.py +305 -0
  45. ha_mcp/tools/tools_registry.py +1115 -0
  46. ha_mcp/tools/tools_resources.py +622 -0
  47. ha_mcp/tools/tools_search.py +573 -0
  48. ha_mcp/tools/tools_service.py +211 -0
  49. ha_mcp/tools/tools_services.py +352 -0
  50. ha_mcp/tools/tools_system.py +363 -0
  51. ha_mcp/tools/tools_todo.py +530 -0
  52. ha_mcp/tools/tools_traces.py +496 -0
  53. ha_mcp/tools/tools_updates.py +476 -0
  54. ha_mcp/tools/tools_utility.py +519 -0
  55. ha_mcp/tools/tools_voice_assistant.py +345 -0
  56. ha_mcp/tools/tools_zones.py +387 -0
  57. ha_mcp/tools/util_helpers.py +199 -0
  58. ha_mcp/utils/__init__.py +30 -0
  59. ha_mcp/utils/domain_handlers.py +380 -0
  60. ha_mcp/utils/fuzzy_search.py +339 -0
  61. ha_mcp/utils/operation_manager.py +442 -0
  62. ha_mcp/utils/usage_logger.py +290 -0
  63. ha_mcp_dev-6.3.1.dev137.dist-info/METADATA +229 -0
  64. ha_mcp_dev-6.3.1.dev137.dist-info/RECORD +71 -0
  65. ha_mcp_dev-6.3.1.dev137.dist-info/WHEEL +5 -0
  66. ha_mcp_dev-6.3.1.dev137.dist-info/entry_points.txt +7 -0
  67. ha_mcp_dev-6.3.1.dev137.dist-info/licenses/LICENSE +21 -0
  68. ha_mcp_dev-6.3.1.dev137.dist-info/top_level.txt +2 -0
  69. tests/__init__.py +1 -0
  70. tests/test_constants.py +15 -0
  71. tests/test_env_manager.py +336 -0
@@ -0,0 +1,786 @@
1
+ """
2
+ Home Assistant OAuth 2.1 Provider.
3
+
4
+ This module implements OAuth 2.1 authentication with Dynamic Client Registration (DCR)
5
+ for Home Assistant MCP Server. Users authenticate via a consent form where they
6
+ provide their Home Assistant URL and Long-Lived Access Token (LLAT).
7
+ """
8
+
9
+ import logging
10
+ import os
11
+ import secrets
12
+ import time
13
+ from typing import Any
14
+ from urllib.parse import urlencode
15
+
16
+ import httpx
17
+ from cryptography.fernet import Fernet
18
+ from fastmcp.server.auth.auth import AccessToken # FastMCP version has claims field
19
+ from mcp.server.auth.provider import (
20
+ AuthorizationCode,
21
+ AuthorizationParams,
22
+ AuthorizeError,
23
+ RefreshToken,
24
+ TokenError,
25
+ construct_redirect_uri,
26
+ )
27
+ from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
28
+ from pydantic import AnyHttpUrl
29
+ from starlette.requests import Request
30
+ from starlette.responses import HTMLResponse, RedirectResponse, Response
31
+ from starlette.routing import Route
32
+
33
+ from fastmcp.server.auth.auth import (
34
+ ClientRegistrationOptions,
35
+ OAuthProvider,
36
+ RevocationOptions,
37
+ )
38
+
39
+ from .consent_form import create_consent_html, create_error_html
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ # Token expiration times
44
+ AUTH_CODE_EXPIRY_SECONDS = 5 * 60 # 5 minutes
45
+ ACCESS_TOKEN_EXPIRY_SECONDS = 60 * 60 # 1 hour
46
+ REFRESH_TOKEN_EXPIRY_SECONDS = 7 * 24 * 60 * 60 # 7 days
47
+
48
+
49
+ class HomeAssistantCredentials:
50
+ """Stores Home Assistant credentials for a client."""
51
+
52
+ def __init__(self, ha_url: str, ha_token: str):
53
+ self.ha_url = ha_url.rstrip("/")
54
+ self.ha_token = ha_token
55
+ self.validated_at = time.time()
56
+
57
+ def to_dict(self) -> dict[str, Any]:
58
+ """Convert to dictionary for storage."""
59
+ return {
60
+ "ha_url": self.ha_url,
61
+ "ha_token": self.ha_token,
62
+ "validated_at": self.validated_at,
63
+ }
64
+
65
+
66
+ class HomeAssistantOAuthProvider(OAuthProvider):
67
+ """
68
+ OAuth 2.1 provider for Home Assistant MCP Server.
69
+
70
+ This provider implements the full OAuth 2.1 flow with:
71
+ - Dynamic Client Registration (DCR)
72
+ - PKCE support
73
+ - Custom consent form for collecting HA credentials
74
+ - Token management with refresh token support
75
+
76
+ The consent form collects the user's Home Assistant URL and
77
+ Long-Lived Access Token, validates them, and stores them
78
+ securely for subsequent API calls.
79
+ """
80
+
81
+ def __init__(
82
+ self,
83
+ base_url: AnyHttpUrl | str,
84
+ issuer_url: AnyHttpUrl | str | None = None,
85
+ service_documentation_url: AnyHttpUrl | str | None = None,
86
+ client_registration_options: ClientRegistrationOptions | None = None,
87
+ revocation_options: RevocationOptions | None = None,
88
+ required_scopes: list[str] | None = None,
89
+ ):
90
+ """
91
+ Initialize the Home Assistant OAuth provider.
92
+
93
+ Args:
94
+ base_url: The public URL of this MCP server
95
+ issuer_url: The issuer URL for OAuth metadata (defaults to base_url)
96
+ service_documentation_url: URL to service documentation
97
+ client_registration_options: Options for client registration
98
+ revocation_options: Options for token revocation
99
+ required_scopes: Scopes required for all requests
100
+ """
101
+ # Enable DCR by default
102
+ if client_registration_options is None:
103
+ client_registration_options = ClientRegistrationOptions(
104
+ enabled=True,
105
+ valid_scopes=["homeassistant", "mcp"],
106
+ )
107
+
108
+ # Enable revocation by default
109
+ if revocation_options is None:
110
+ revocation_options = RevocationOptions(enabled=True)
111
+
112
+ super().__init__(
113
+ base_url=base_url,
114
+ issuer_url=issuer_url,
115
+ service_documentation_url=service_documentation_url,
116
+ client_registration_options=client_registration_options,
117
+ revocation_options=revocation_options,
118
+ required_scopes=required_scopes,
119
+ )
120
+
121
+ # In-memory storage
122
+ self.clients: dict[str, OAuthClientInformationFull] = {}
123
+ self.auth_codes: dict[str, AuthorizationCode] = {}
124
+ self.access_tokens: dict[str, AccessToken] = {}
125
+ self.refresh_tokens: dict[str, RefreshToken] = {}
126
+
127
+ # Home Assistant credentials storage (keyed by client_id)
128
+ self.ha_credentials: dict[str, HomeAssistantCredentials] = {}
129
+
130
+ # Pending authorization requests (for consent form flow)
131
+ self.pending_authorizations: dict[str, dict[str, Any]] = {}
132
+
133
+ # Token mapping for revocation
134
+ self._access_to_refresh_map: dict[str, str] = {}
135
+ self._refresh_to_access_map: dict[str, str] = {}
136
+
137
+ # Initialize encryption key for stateless tokens
138
+ self._encryption_key = self._get_or_create_encryption_key()
139
+ self._cipher = Fernet(self._encryption_key)
140
+
141
+ logger.info(f"HomeAssistantOAuthProvider initialized with base_url={base_url}")
142
+
143
+ def _get_or_create_encryption_key(self) -> bytes:
144
+ """
145
+ Get encryption key from environment or generate a new one.
146
+
147
+ For production: Set OAUTH_ENCRYPTION_KEY environment variable
148
+ For dev: A key is generated (tokens won't survive restarts)
149
+ """
150
+ key_str = os.getenv("OAUTH_ENCRYPTION_KEY")
151
+ if key_str:
152
+ logger.info("Using OAUTH_ENCRYPTION_KEY from environment")
153
+ return key_str.encode()
154
+ else:
155
+ # Generate a new key (tokens won't survive restart)
156
+ key = Fernet.generate_key()
157
+ 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."
161
+ )
162
+ return key
163
+
164
+ def _encrypt_credentials(self, ha_url: str, ha_token: str) -> str:
165
+ """Encrypt HA credentials into a token string."""
166
+ credentials_str = f"{ha_url}|{ha_token}"
167
+ encrypted = self._cipher.encrypt(credentials_str.encode())
168
+ return encrypted.decode()
169
+
170
+ def _decrypt_credentials(self, token: str) -> tuple[str, str] | None:
171
+ """Decrypt token to get HA credentials. Returns (ha_url, ha_token) or None."""
172
+ try:
173
+ decrypted = self._cipher.decrypt(token.encode())
174
+ credentials_str = decrypted.decode()
175
+ parts = credentials_str.split("|", 1)
176
+ if len(parts) == 2:
177
+ return parts[0], parts[1]
178
+ return None
179
+ except Exception as e:
180
+ logger.debug(f"Failed to decrypt token: {e}")
181
+ return None
182
+
183
+ def get_routes(self, mcp_path: str | None = None) -> list[Route]:
184
+ """
185
+ Get OAuth routes including custom consent form routes.
186
+
187
+ This extends the base OAuth routes with:
188
+ - GET /authorize - Shows the consent form
189
+ - POST /authorize - Handles consent form submission
190
+ - Custom /.well-known/oauth-authorization-server with enhanced metadata
191
+ """
192
+ # Get base OAuth routes
193
+ routes = super().get_routes(mcp_path)
194
+
195
+ # Override the well-known metadata route to include fields needed by Claude.ai
196
+ # The MCP SDK omits critical fields like response_modes_supported and
197
+ # the "none" token_endpoint_auth_method that public clients with PKCE require
198
+ from starlette.responses import JSONResponse
199
+
200
+ async def enhanced_metadata_handler(request: Request) -> Response:
201
+ """Enhanced OAuth metadata handler with Claude.ai compatibility."""
202
+ from mcp.server.auth.routes import build_metadata
203
+
204
+ # Get base metadata from MCP SDK
205
+ metadata = build_metadata(
206
+ issuer_url=self.base_url, # type: ignore[arg-type]
207
+ service_documentation_url=AnyHttpUrl("https://github.com/homeassistant-ai/ha-mcp"),
208
+ client_registration_options=self.client_registration_options or {}, # type: ignore[arg-type]
209
+ revocation_options=self.revocation_options or {}, # type: ignore[arg-type]
210
+ )
211
+
212
+ # Convert to dict and enhance with missing fields
213
+ # Use mode='json' to serialize AnyHttpUrl objects to strings
214
+ metadata_dict = metadata.model_dump(mode='json', exclude_none=True)
215
+
216
+ # Add response_modes_supported (required by some OAuth clients)
217
+ metadata_dict["response_modes_supported"] = ["query"]
218
+
219
+ # Add "none" auth method for public clients with PKCE (used by Claude.ai)
220
+ if "token_endpoint_auth_methods_supported" in metadata_dict:
221
+ if "none" not in metadata_dict["token_endpoint_auth_methods_supported"]:
222
+ metadata_dict["token_endpoint_auth_methods_supported"].append("none")
223
+
224
+ # Also add "none" to revocation endpoint auth methods
225
+ if "revocation_endpoint_auth_methods_supported" in metadata_dict:
226
+ if "none" not in metadata_dict["revocation_endpoint_auth_methods_supported"]:
227
+ metadata_dict["revocation_endpoint_auth_methods_supported"].append("none")
228
+
229
+ return JSONResponse(content=metadata_dict)
230
+
231
+ # Replace the well-known metadata route
232
+ enhanced_routes = []
233
+ for route in routes:
234
+ if (
235
+ isinstance(route, Route)
236
+ and route.path == "/.well-known/oauth-authorization-server"
237
+ ):
238
+ from mcp.server.auth.routes import cors_middleware
239
+ enhanced_routes.append(
240
+ Route(
241
+ path="/.well-known/oauth-authorization-server",
242
+ endpoint=cors_middleware(
243
+ enhanced_metadata_handler, ["GET", "OPTIONS"]
244
+ ),
245
+ methods=["GET", "OPTIONS"],
246
+ )
247
+ )
248
+ else:
249
+ enhanced_routes.append(route)
250
+
251
+ # Add consent form routes (these override the default authorize behavior)
252
+ consent_routes = [
253
+ Route("/consent", endpoint=self._consent_get, methods=["GET"]),
254
+ Route("/consent", endpoint=self._consent_post, methods=["POST"]),
255
+ ]
256
+
257
+ enhanced_routes.extend(consent_routes)
258
+ return enhanced_routes
259
+
260
+ async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
261
+ """Retrieve client information by ID."""
262
+ return self.clients.get(client_id)
263
+
264
+ async def register_client(self, client_info: OAuthClientInformationFull) -> None:
265
+ """Register a new OAuth client."""
266
+ # Validate scopes if configured
267
+ if (
268
+ client_info.scope is not None
269
+ and self.client_registration_options is not None
270
+ and self.client_registration_options.valid_scopes is not None
271
+ ):
272
+ requested_scopes = set(client_info.scope.split())
273
+ valid_scopes = set(self.client_registration_options.valid_scopes)
274
+ invalid_scopes = requested_scopes - valid_scopes
275
+ if invalid_scopes:
276
+ raise ValueError(
277
+ f"Requested scopes are not valid: {', '.join(invalid_scopes)}"
278
+ )
279
+
280
+ if client_info.client_id is None:
281
+ raise ValueError("client_id is required for client registration")
282
+
283
+ self.clients[client_info.client_id] = client_info
284
+ logger.info(f"Registered OAuth client: {client_info.client_id}")
285
+
286
+ async def authorize(
287
+ self, client: OAuthClientInformationFull, params: AuthorizationParams
288
+ ) -> str:
289
+ """
290
+ Handle authorization request by redirecting to consent form.
291
+
292
+ Instead of immediately issuing an auth code, we redirect to our
293
+ consent form where users enter their HA credentials.
294
+ """
295
+ if client.client_id is None:
296
+ raise AuthorizeError(
297
+ error="invalid_client",
298
+ error_description="Client ID is required",
299
+ )
300
+
301
+ if client.client_id not in self.clients:
302
+ raise AuthorizeError(
303
+ error="unauthorized_client",
304
+ error_description=f"Client '{client.client_id}' not registered.",
305
+ )
306
+
307
+ # Generate a unique transaction ID for this authorization
308
+ txn_id = secrets.token_urlsafe(32)
309
+
310
+ # Store the authorization parameters for the consent form
311
+ self.pending_authorizations[txn_id] = {
312
+ "client_id": client.client_id,
313
+ "client_name": client.client_name,
314
+ "redirect_uri": str(params.redirect_uri),
315
+ "state": params.state,
316
+ "scopes": params.scopes or [],
317
+ "code_challenge": params.code_challenge,
318
+ "redirect_uri_provided_explicitly": params.redirect_uri_provided_explicitly,
319
+ "created_at": time.time(),
320
+ }
321
+
322
+ # Build consent form URL
323
+ assert self.base_url is not None
324
+ consent_url = f"{str(self.base_url).rstrip('/')}/consent?txn_id={txn_id}"
325
+
326
+ logger.debug(f"Redirecting to consent form: {consent_url}")
327
+ return consent_url
328
+
329
+ async def _consent_get(self, request: Request) -> Response:
330
+ """Handle GET request to consent form."""
331
+ txn_id = request.query_params.get("txn_id")
332
+ error_message = request.query_params.get("error")
333
+
334
+ if not txn_id:
335
+ return HTMLResponse(
336
+ create_error_html(
337
+ "invalid_request",
338
+ "Missing transaction ID. Please start the authorization flow again.",
339
+ ),
340
+ status_code=400,
341
+ )
342
+
343
+ pending = self.pending_authorizations.get(txn_id)
344
+ if not pending:
345
+ return HTMLResponse(
346
+ create_error_html(
347
+ "invalid_request",
348
+ "Authorization request expired or not found. Please try again.",
349
+ ),
350
+ status_code=400,
351
+ )
352
+
353
+ # Check if authorization request is expired (5 minutes)
354
+ if time.time() - pending["created_at"] > 300:
355
+ del self.pending_authorizations[txn_id]
356
+ return HTMLResponse(
357
+ create_error_html(
358
+ "expired_request",
359
+ "Authorization request has expired. Please start over.",
360
+ ),
361
+ status_code=400,
362
+ )
363
+
364
+ html = create_consent_html(
365
+ client_id=pending["client_id"],
366
+ client_name=pending.get("client_name"),
367
+ redirect_uri=pending["redirect_uri"],
368
+ state=pending.get("state", ""),
369
+ scopes=pending.get("scopes", []),
370
+ error_message=error_message,
371
+ )
372
+
373
+ # Add txn_id as hidden field
374
+ html = html.replace(
375
+ '<input type="hidden" name="client_id"',
376
+ f'<input type="hidden" name="txn_id" value="{txn_id}">\n <input type="hidden" name="client_id"',
377
+ )
378
+
379
+ return HTMLResponse(html)
380
+
381
+ async def _consent_post(self, request: Request) -> Response:
382
+ """Handle POST request from consent form."""
383
+ logger.info("📝 === CONSENT FORM POST RECEIVED ===")
384
+ form = await request.form()
385
+
386
+ txn_id = form.get("txn_id")
387
+ ha_url = form.get("ha_url")
388
+ ha_token = form.get("ha_token")
389
+ logger.info(f"📝 Form data: txn_id={txn_id}, ha_url={ha_url}, has_token={ha_token is not None}")
390
+
391
+ if not txn_id:
392
+ return HTMLResponse(
393
+ create_error_html(
394
+ "invalid_request",
395
+ "Missing transaction ID.",
396
+ ),
397
+ status_code=400,
398
+ )
399
+
400
+ pending = self.pending_authorizations.get(str(txn_id))
401
+ if not pending:
402
+ return HTMLResponse(
403
+ create_error_html(
404
+ "invalid_request",
405
+ "Authorization request expired or not found.",
406
+ ),
407
+ status_code=400,
408
+ )
409
+
410
+ if not ha_url or not ha_token:
411
+ # Redirect back to form with error
412
+ assert self.base_url is not None
413
+ error_params = urlencode(
414
+ {
415
+ "txn_id": txn_id,
416
+ "error": "Please provide both Home Assistant URL and access token.",
417
+ }
418
+ )
419
+ return RedirectResponse(
420
+ f"{str(self.base_url).rstrip('/')}/consent?{error_params}",
421
+ status_code=303,
422
+ )
423
+
424
+ # Validate HA credentials
425
+ validation_error = await self._validate_ha_credentials(
426
+ str(ha_url), str(ha_token)
427
+ )
428
+ if validation_error:
429
+ assert self.base_url is not None
430
+ error_params = urlencode(
431
+ {
432
+ "txn_id": txn_id,
433
+ "error": validation_error,
434
+ }
435
+ )
436
+ return RedirectResponse(
437
+ f"{str(self.base_url).rstrip('/')}/consent?{error_params}",
438
+ status_code=303,
439
+ )
440
+
441
+ # Store validated credentials
442
+ client_id = pending["client_id"]
443
+ self.ha_credentials[client_id] = HomeAssistantCredentials(
444
+ ha_url=str(ha_url),
445
+ ha_token=str(ha_token),
446
+ )
447
+ logger.info(f"✅ Stored HA credentials for client {client_id}: {str(ha_url)}")
448
+
449
+ # Generate authorization code
450
+ auth_code_value = f"ha_auth_code_{secrets.token_hex(16)}"
451
+ expires_at = time.time() + AUTH_CODE_EXPIRY_SECONDS
452
+
453
+ scopes_list = pending.get("scopes", [])
454
+ if isinstance(scopes_list, str):
455
+ scopes_list = scopes_list.split()
456
+
457
+ auth_code = AuthorizationCode(
458
+ code=auth_code_value,
459
+ client_id=client_id,
460
+ redirect_uri=AnyHttpUrl(pending["redirect_uri"]),
461
+ redirect_uri_provided_explicitly=pending.get(
462
+ "redirect_uri_provided_explicitly", True
463
+ ),
464
+ scopes=scopes_list,
465
+ expires_at=expires_at,
466
+ code_challenge=pending.get("code_challenge"),
467
+ )
468
+ self.auth_codes[auth_code_value] = auth_code
469
+
470
+ # Clean up pending authorization
471
+ del self.pending_authorizations[str(txn_id)]
472
+
473
+ # Redirect back to client with auth code
474
+ redirect_uri = construct_redirect_uri(
475
+ pending["redirect_uri"],
476
+ code=auth_code_value,
477
+ state=pending.get("state"),
478
+ )
479
+
480
+ logger.info(f"Authorization successful for client {client_id}")
481
+ return RedirectResponse(redirect_uri, status_code=303)
482
+
483
+ async def _validate_ha_credentials(self, ha_url: str, ha_token: str) -> str | None:
484
+ """
485
+ Validate Home Assistant credentials by making a test API call.
486
+
487
+ Args:
488
+ ha_url: Home Assistant URL
489
+ ha_token: Long-Lived Access Token
490
+
491
+ Returns:
492
+ Error message if validation failed, None if successful
493
+ """
494
+ try:
495
+ ha_url = ha_url.rstrip("/")
496
+
497
+ async with httpx.AsyncClient(timeout=10.0) as client:
498
+ response = await client.get(
499
+ f"{ha_url}/api/config",
500
+ headers={
501
+ "Authorization": f"Bearer {ha_token}",
502
+ "Content-Type": "application/json",
503
+ },
504
+ )
505
+
506
+ if response.status_code == 401:
507
+ return "Invalid access token. Please check your Long-Lived Access Token."
508
+
509
+ if response.status_code == 403:
510
+ return "Access forbidden. The token may not have sufficient permissions."
511
+
512
+ if response.status_code >= 400:
513
+ return f"Failed to connect to Home Assistant: HTTP {response.status_code}"
514
+
515
+ # Verify we got a valid config response
516
+ try:
517
+ config = response.json()
518
+ if "location_name" not in config and "version" not in config:
519
+ return "Invalid response from Home Assistant. Please check the URL."
520
+ except Exception:
521
+ return "Invalid response format from Home Assistant."
522
+
523
+ logger.info(
524
+ f"Validated HA credentials for {config.get('location_name', 'Unknown')}"
525
+ )
526
+ return None
527
+
528
+ except httpx.ConnectError:
529
+ return "Could not connect to Home Assistant. Please check the URL and ensure Home Assistant is accessible."
530
+ except httpx.TimeoutException:
531
+ return "Connection to Home Assistant timed out. Please check the URL."
532
+ except Exception as e:
533
+ logger.error(f"Error validating HA credentials: {e}")
534
+ return f"Failed to validate credentials: {str(e)}"
535
+
536
+ async def load_authorization_code(
537
+ self, client: OAuthClientInformationFull, authorization_code: str
538
+ ) -> AuthorizationCode | None:
539
+ """Load authorization code from storage."""
540
+ auth_code_obj = self.auth_codes.get(authorization_code)
541
+ if auth_code_obj:
542
+ if auth_code_obj.client_id != client.client_id:
543
+ return None
544
+ if auth_code_obj.expires_at < time.time():
545
+ del self.auth_codes[authorization_code]
546
+ return None
547
+ return auth_code_obj
548
+ return None
549
+
550
+ async def exchange_authorization_code(
551
+ self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
552
+ ) -> OAuthToken:
553
+ """Exchange authorization code for access and refresh tokens."""
554
+ if authorization_code.code not in self.auth_codes:
555
+ raise TokenError(
556
+ "invalid_grant", "Authorization code not found or already used."
557
+ )
558
+
559
+ # Consume the auth code
560
+ del self.auth_codes[authorization_code.code]
561
+
562
+ if client.client_id is None:
563
+ raise TokenError("invalid_client", "Client ID is required")
564
+
565
+ # Generate tokens
566
+ access_token_value = f"ha_access_{secrets.token_hex(32)}"
567
+ refresh_token_value = f"ha_refresh_{secrets.token_hex(32)}"
568
+
569
+ access_token_expires_at = int(time.time() + ACCESS_TOKEN_EXPIRY_SECONDS)
570
+ refresh_token_expires_at = int(time.time() + REFRESH_TOKEN_EXPIRY_SECONDS)
571
+
572
+ # Get HA credentials for this client to encrypt in token
573
+ ha_credentials = self.ha_credentials.get(client.client_id)
574
+ if not ha_credentials:
575
+ raise TokenError(
576
+ "server_error",
577
+ f"No Home Assistant credentials found for client {client.client_id}",
578
+ )
579
+
580
+ # STATELESS TOKEN: Encrypt HA credentials directly into the access token
581
+ # No server-side storage needed - token contains everything
582
+ access_token_value = self._encrypt_credentials(
583
+ ha_credentials.ha_url, ha_credentials.ha_token
584
+ )
585
+
586
+ # Still use random string for refresh token (less sensitive, can be in memory)
587
+ # Refresh tokens are less frequently used and don't need to carry credentials
588
+ self.refresh_tokens[refresh_token_value] = RefreshToken(
589
+ token=refresh_token_value,
590
+ client_id=client.client_id,
591
+ scopes=authorization_code.scopes,
592
+ expires_at=refresh_token_expires_at,
593
+ )
594
+
595
+ # Map for revocation (refresh token only, access token is stateless)
596
+ self._refresh_to_access_map[refresh_token_value] = client.client_id or ""
597
+
598
+ # Clean up temporary credentials storage (no longer needed after token issued)
599
+ if client.client_id in self.ha_credentials:
600
+ del self.ha_credentials[client.client_id]
601
+
602
+ logger.info(f"Issued encrypted stateless access token for client {client.client_id}")
603
+
604
+ return OAuthToken(
605
+ access_token=access_token_value,
606
+ token_type="Bearer",
607
+ expires_in=ACCESS_TOKEN_EXPIRY_SECONDS,
608
+ refresh_token=refresh_token_value,
609
+ scope=(
610
+ " ".join(authorization_code.scopes)
611
+ if authorization_code.scopes
612
+ else None
613
+ ),
614
+ )
615
+
616
+ async def load_refresh_token(
617
+ self, client: OAuthClientInformationFull, refresh_token: str
618
+ ) -> RefreshToken | None:
619
+ """Load refresh token from storage."""
620
+ token_obj = self.refresh_tokens.get(refresh_token)
621
+ if token_obj:
622
+ if token_obj.client_id != client.client_id:
623
+ return None
624
+ if token_obj.expires_at is not None and token_obj.expires_at < time.time():
625
+ self._revoke_internal(refresh_token_str=token_obj.token)
626
+ return None
627
+ return token_obj
628
+ return None
629
+
630
+ async def exchange_refresh_token(
631
+ self,
632
+ client: OAuthClientInformationFull,
633
+ refresh_token: RefreshToken,
634
+ scopes: list[str],
635
+ ) -> OAuthToken:
636
+ """Exchange refresh token for new access token."""
637
+ # Validate scopes
638
+ original_scopes = set(refresh_token.scopes)
639
+ requested_scopes = set(scopes)
640
+ if not requested_scopes.issubset(original_scopes):
641
+ raise TokenError(
642
+ "invalid_scope",
643
+ "Requested scopes exceed those authorized by the refresh token.",
644
+ )
645
+
646
+ if client.client_id is None:
647
+ raise TokenError("invalid_client", "Client ID is required")
648
+
649
+ # Preserve claims from old access token before revoking
650
+ old_access_token_str = self._refresh_to_access_map.get(refresh_token.token)
651
+ old_claims = {}
652
+ if old_access_token_str and old_access_token_str in self.access_tokens:
653
+ old_access_token = self.access_tokens[old_access_token_str]
654
+ old_claims = old_access_token.claims or {}
655
+
656
+ # Revoke old tokens
657
+ self._revoke_internal(refresh_token_str=refresh_token.token)
658
+
659
+ # Issue new tokens
660
+ new_access_token_value = f"ha_access_{secrets.token_hex(32)}"
661
+ new_refresh_token_value = f"ha_refresh_{secrets.token_hex(32)}"
662
+
663
+ access_token_expires_at = int(time.time() + ACCESS_TOKEN_EXPIRY_SECONDS)
664
+ refresh_token_expires_at = int(time.time() + REFRESH_TOKEN_EXPIRY_SECONDS)
665
+
666
+ # Preserve HA credentials in new access token claims
667
+ self.access_tokens[new_access_token_value] = AccessToken(
668
+ token=new_access_token_value,
669
+ client_id=client.client_id,
670
+ scopes=scopes,
671
+ expires_at=access_token_expires_at,
672
+ claims=old_claims, # Preserve HA credentials across token refresh
673
+ )
674
+
675
+ self.refresh_tokens[new_refresh_token_value] = RefreshToken(
676
+ token=new_refresh_token_value,
677
+ client_id=client.client_id,
678
+ scopes=scopes,
679
+ expires_at=refresh_token_expires_at,
680
+ )
681
+
682
+ self._access_to_refresh_map[new_access_token_value] = new_refresh_token_value
683
+ self._refresh_to_access_map[new_refresh_token_value] = new_access_token_value
684
+
685
+ return OAuthToken(
686
+ access_token=new_access_token_value,
687
+ token_type="Bearer",
688
+ expires_in=ACCESS_TOKEN_EXPIRY_SECONDS,
689
+ refresh_token=new_refresh_token_value,
690
+ scope=" ".join(scopes) if scopes else None,
691
+ )
692
+
693
+ async def load_access_token(self, token: str) -> AccessToken | None:
694
+ """
695
+ Load and validate access token.
696
+
697
+ STATELESS: Decrypts token to extract HA credentials.
698
+ No server-side storage needed - token is self-contained.
699
+ """
700
+
701
+ # Decrypt token to get HA credentials
702
+ credentials = self._decrypt_credentials(token)
703
+ if not credentials:
704
+ return None
705
+
706
+ ha_url, ha_token = credentials
707
+
708
+ # Create AccessToken object with decrypted credentials in claims
709
+ # No expiry check - tokens don't expire (encryption key rotation handles security)
710
+ return AccessToken(
711
+ token=token,
712
+ client_id="decrypted", # We don't store client_id in token
713
+ scopes=["homeassistant", "mcp"],
714
+ expires_at=None, # Stateless tokens don't expire
715
+ claims={
716
+ "ha_url": ha_url,
717
+ "ha_token": ha_token,
718
+ },
719
+ )
720
+
721
+ async def verify_token(self, token: str) -> AccessToken | None:
722
+ """Verify bearer token and return access info if valid."""
723
+ return await self.load_access_token(token)
724
+
725
+ def _revoke_internal(
726
+ self, access_token_str: str | None = None, refresh_token_str: str | None = None
727
+ ) -> None:
728
+ """Internal helper to remove tokens and their associations."""
729
+ if access_token_str:
730
+ if access_token_str in self.access_tokens:
731
+ del self.access_tokens[access_token_str]
732
+
733
+ associated_refresh = self._access_to_refresh_map.pop(access_token_str, None)
734
+ if associated_refresh:
735
+ if associated_refresh in self.refresh_tokens:
736
+ del self.refresh_tokens[associated_refresh]
737
+ self._refresh_to_access_map.pop(associated_refresh, None)
738
+
739
+ if refresh_token_str:
740
+ if refresh_token_str in self.refresh_tokens:
741
+ del self.refresh_tokens[refresh_token_str]
742
+
743
+ associated_access = self._refresh_to_access_map.pop(refresh_token_str, None)
744
+ if associated_access:
745
+ if associated_access in self.access_tokens:
746
+ del self.access_tokens[associated_access]
747
+ self._access_to_refresh_map.pop(associated_access, None)
748
+
749
+ async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
750
+ """Revoke an access or refresh token."""
751
+ if isinstance(token, AccessToken):
752
+ self._revoke_internal(access_token_str=token.token)
753
+ elif isinstance(token, RefreshToken):
754
+ self._revoke_internal(refresh_token_str=token.token)
755
+
756
+ def get_ha_credentials(self, client_id: str) -> HomeAssistantCredentials | None:
757
+ """
758
+ Get Home Assistant credentials for a client.
759
+
760
+ This is used by the MCP server to get the HA URL and token
761
+ for making API calls on behalf of the authenticated user.
762
+
763
+ Args:
764
+ client_id: The OAuth client ID
765
+
766
+ Returns:
767
+ HomeAssistantCredentials if found, None otherwise
768
+ """
769
+ return self.ha_credentials.get(client_id)
770
+
771
+ def get_ha_credentials_for_token(
772
+ self, access_token: str
773
+ ) -> HomeAssistantCredentials | None:
774
+ """
775
+ Get Home Assistant credentials for an access token.
776
+
777
+ Args:
778
+ access_token: The access token string
779
+
780
+ Returns:
781
+ HomeAssistantCredentials if found, None otherwise
782
+ """
783
+ token_obj = self.access_tokens.get(access_token)
784
+ if token_obj and token_obj.client_id:
785
+ return self.ha_credentials.get(token_obj.client_id)
786
+ return None