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.
- ha_mcp/__init__.py +51 -0
- ha_mcp/__main__.py +753 -0
- ha_mcp/_pypi_marker +0 -0
- ha_mcp/auth/__init__.py +10 -0
- ha_mcp/auth/consent_form.py +501 -0
- ha_mcp/auth/provider.py +786 -0
- ha_mcp/client/__init__.py +10 -0
- ha_mcp/client/rest_client.py +924 -0
- ha_mcp/client/websocket_client.py +656 -0
- ha_mcp/client/websocket_listener.py +340 -0
- ha_mcp/config.py +175 -0
- ha_mcp/errors.py +399 -0
- ha_mcp/py.typed +0 -0
- ha_mcp/resources/card_types.json +48 -0
- ha_mcp/resources/dashboard_guide.md +573 -0
- ha_mcp/server.py +189 -0
- ha_mcp/smoke_test.py +108 -0
- ha_mcp/tools/__init__.py +11 -0
- ha_mcp/tools/backup.py +457 -0
- ha_mcp/tools/device_control.py +687 -0
- ha_mcp/tools/enhanced.py +191 -0
- ha_mcp/tools/helpers.py +184 -0
- ha_mcp/tools/registry.py +199 -0
- ha_mcp/tools/smart_search.py +797 -0
- ha_mcp/tools/tools_addons.py +343 -0
- ha_mcp/tools/tools_areas.py +478 -0
- ha_mcp/tools/tools_blueprints.py +279 -0
- ha_mcp/tools/tools_bug_report.py +570 -0
- ha_mcp/tools/tools_calendar.py +391 -0
- ha_mcp/tools/tools_camera.py +149 -0
- ha_mcp/tools/tools_config_automations.py +508 -0
- ha_mcp/tools/tools_config_dashboards.py +1502 -0
- ha_mcp/tools/tools_config_entry_flow.py +223 -0
- ha_mcp/tools/tools_config_helpers.py +925 -0
- ha_mcp/tools/tools_config_info.py +258 -0
- ha_mcp/tools/tools_config_scripts.py +267 -0
- ha_mcp/tools/tools_entities.py +76 -0
- ha_mcp/tools/tools_filesystem.py +589 -0
- ha_mcp/tools/tools_groups.py +306 -0
- ha_mcp/tools/tools_hacs.py +787 -0
- ha_mcp/tools/tools_history.py +714 -0
- ha_mcp/tools/tools_integrations.py +297 -0
- ha_mcp/tools/tools_labels.py +713 -0
- ha_mcp/tools/tools_mcp_component.py +305 -0
- ha_mcp/tools/tools_registry.py +1115 -0
- ha_mcp/tools/tools_resources.py +622 -0
- ha_mcp/tools/tools_search.py +573 -0
- ha_mcp/tools/tools_service.py +211 -0
- ha_mcp/tools/tools_services.py +352 -0
- ha_mcp/tools/tools_system.py +363 -0
- ha_mcp/tools/tools_todo.py +530 -0
- ha_mcp/tools/tools_traces.py +496 -0
- ha_mcp/tools/tools_updates.py +476 -0
- ha_mcp/tools/tools_utility.py +519 -0
- ha_mcp/tools/tools_voice_assistant.py +345 -0
- ha_mcp/tools/tools_zones.py +387 -0
- ha_mcp/tools/util_helpers.py +199 -0
- ha_mcp/utils/__init__.py +30 -0
- ha_mcp/utils/domain_handlers.py +380 -0
- ha_mcp/utils/fuzzy_search.py +339 -0
- ha_mcp/utils/operation_manager.py +442 -0
- ha_mcp/utils/usage_logger.py +290 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/METADATA +229 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/RECORD +71 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/WHEEL +5 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/entry_points.txt +7 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/licenses/LICENSE +21 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/test_constants.py +15 -0
- tests/test_env_manager.py +336 -0
ha_mcp/auth/provider.py
ADDED
|
@@ -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
|