vi-api-client 6.1.1__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.
vi_api_client/auth.py ADDED
@@ -0,0 +1,365 @@
1
+ """Authentication module for Viessmann API."""
2
+
3
+ import asyncio
4
+ import base64
5
+ import hashlib
6
+ import json
7
+ import logging
8
+ import secrets
9
+ import time
10
+ from abc import ABC, abstractmethod
11
+ from collections.abc import Mapping
12
+ from math import isfinite
13
+ from pathlib import Path
14
+ from types import TracebackType
15
+ from typing import Any, Self
16
+ from urllib.parse import urlencode
17
+
18
+ import aiohttp
19
+
20
+ from ._types import JsonValue
21
+ from .const import DEFAULT_SCOPES, ENDPOINT_AUTHORIZE, ENDPOINT_TOKEN
22
+ from .credentials import CredentialDocument
23
+ from .exceptions import ViAuthError, ViConnectionError, ViResponseError
24
+ from .validation import is_json_number, validate_json_value
25
+
26
+ _LOGGER = logging.getLogger(__name__)
27
+
28
+ # Renew tokens this long before they expire so they cannot expire mid-request.
29
+ _TOKEN_EXPIRY_MARGIN_SECONDS = 60
30
+
31
+
32
+ class AbstractAuth(ABC):
33
+ """Abstract class to make authenticated requests."""
34
+
35
+ def __init__(self, websession: aiohttp.ClientSession | None = None) -> None:
36
+ """Initialize the auth with an optional externally managed session."""
37
+ self._websession = websession
38
+ self._owns_websession = False
39
+
40
+ @property
41
+ def websession(self) -> aiohttp.ClientSession | None:
42
+ """Return the session configured at construction or created lazily."""
43
+ return self._websession
44
+
45
+ async def __aenter__(self) -> Self:
46
+ """Enter the authentication context."""
47
+ return self
48
+
49
+ async def __aexit__(
50
+ self,
51
+ exc_type: type[BaseException] | None,
52
+ exc_value: BaseException | None,
53
+ traceback: TracebackType | None,
54
+ ) -> None:
55
+ """Close resources owned by the authentication provider."""
56
+ await self.async_close()
57
+
58
+ @abstractmethod
59
+ async def async_get_access_token(self) -> str:
60
+ """Return a valid access token."""
61
+ # Abstract method bodies never execute in concrete subclasses.
62
+ pass # pragma: no cover
63
+
64
+ async def _async_get_websession(self) -> aiohttp.ClientSession:
65
+ """Return an available session, creating an owned one when needed."""
66
+ if self._websession is None:
67
+ self._websession = aiohttp.ClientSession()
68
+ self._owns_websession = True
69
+ return self._websession
70
+
71
+ async def async_close(self) -> None:
72
+ """Close the web session only when it was created internally."""
73
+ if not self._owns_websession or self._websession is None:
74
+ return
75
+
76
+ if not self._websession.closed:
77
+ await self._websession.close()
78
+ self._websession = None
79
+ self._owns_websession = False
80
+
81
+ async def request(
82
+ self, method: str, url: str, **kwargs: Any
83
+ ) -> aiohttp.ClientResponse:
84
+ """Make an authenticated request.
85
+
86
+ Exceptions raised by the authentication provider propagate unchanged.
87
+
88
+ Raises:
89
+ ViConnectionError: If the HTTP request cannot be made.
90
+ """
91
+ access_token = await self.async_get_access_token()
92
+
93
+ headers = kwargs.get("headers", {}).copy()
94
+ headers["Authorization"] = f"Bearer {access_token}"
95
+ kwargs["headers"] = headers
96
+
97
+ websession = await self._async_get_websession()
98
+ try:
99
+ return await websession.request(method, url, **kwargs)
100
+ except (TimeoutError, aiohttp.ClientError) as error:
101
+ raise ViConnectionError(f"Network error: {error}") from error
102
+
103
+
104
+ class OAuth(AbstractAuth):
105
+ """OAuth2 implementation for standalone usage."""
106
+
107
+ def __init__(
108
+ self,
109
+ client_id: str,
110
+ redirect_uri: str,
111
+ token_file: Path | str,
112
+ websession: aiohttp.ClientSession | None = None,
113
+ scope: str = DEFAULT_SCOPES,
114
+ ) -> None:
115
+ """Initialize OAuth.
116
+
117
+ If websession is None, a session is created lazily. Use the auth provider
118
+ as an async context manager or call `async_close` to release it.
119
+ The token file is not read here; it is loaded in a worker thread on
120
+ the first token request so construction never blocks the event loop.
121
+
122
+ Args:
123
+ client_id: OAuth client ID.
124
+ redirect_uri: Redirect URI for authentication flow.
125
+ token_file: Path to file for storing tokens.
126
+ websession: Optional aiohttp ClientSession.
127
+ scope: OAuth scopes (default: default scopes).
128
+ """
129
+ super().__init__(websession)
130
+ self.client_id = client_id
131
+ self.redirect_uri = redirect_uri
132
+ self.token_file: Path = Path(token_file)
133
+ self._credential_document = CredentialDocument(self.token_file)
134
+ self.scope = scope
135
+ self._token_info: dict[str, JsonValue] | None = None
136
+ self._token_load_lock = asyncio.Lock()
137
+ self._pkce_verifier: str | None = None
138
+ self._refresh_task: asyncio.Task[None] | None = None
139
+
140
+ def get_authorization_url(self) -> str:
141
+ """Return the login URL and remember its PKCE verifier.
142
+
143
+ The verifier is required by `async_exchange_code_for_tokens`, so call
144
+ both methods on the same instance.
145
+ """
146
+ self._pkce_verifier, code_challenge = _generate_pkce_pair()
147
+
148
+ params = {
149
+ "client_id": self.client_id,
150
+ "redirect_uri": self.redirect_uri,
151
+ "response_type": "code",
152
+ "scope": self.scope,
153
+ "code_challenge": code_challenge,
154
+ "code_challenge_method": "S256",
155
+ }
156
+
157
+ return f"{ENDPOINT_AUTHORIZE}?{urlencode(params)}"
158
+
159
+ async def _async_token_info(self) -> dict[str, JsonValue]:
160
+ """Return the stored tokens, loading the token file once in a thread.
161
+
162
+ Raises:
163
+ ViAuthError: If the token file cannot be read or is malformed.
164
+ """
165
+ if self._token_info is None:
166
+ async with self._token_load_lock:
167
+ if self._token_info is None:
168
+ self._token_info = await asyncio.to_thread(
169
+ self._credential_document.read
170
+ )
171
+ return self._token_info
172
+
173
+ async def _async_update_tokens(self, token_data: object) -> None:
174
+ """Update internal token state and save it in a worker thread."""
175
+ validated_token_data = _validate_token_response(token_data)
176
+ token_info = await self._async_token_info()
177
+ token_info.update(validated_token_data)
178
+
179
+ expires_in = validated_token_data.get("expires_in")
180
+ if is_json_number(expires_in):
181
+ token_info["expires_at"] = time.time() + expires_in
182
+
183
+ await asyncio.to_thread(self._credential_document.update, dict(token_info))
184
+
185
+ async def _async_request_tokens(
186
+ self, form_data: Mapping[str, JsonValue], action: str
187
+ ) -> None:
188
+ """Post a token request and store the returned tokens.
189
+
190
+ Raises:
191
+ ViAuthError: If the token endpoint rejects the request or returns
192
+ an invalid token response.
193
+ ViConnectionError: If the token request cannot be made.
194
+ """
195
+ websession = await self._async_get_websession()
196
+ try:
197
+ async with websession.post(ENDPOINT_TOKEN, data=form_data) as response:
198
+ if response.status != 200:
199
+ raise ViAuthError(f"Failed to {action}: {await response.text()}")
200
+ await self._async_update_tokens(await _read_token_response(response))
201
+ except (TimeoutError, aiohttp.ClientError) as error:
202
+ raise ViConnectionError(f"Network error: {error}") from error
203
+
204
+ async def async_exchange_code_for_tokens(self, code: str) -> None:
205
+ """Exchange an authorization code for tokens and store them.
206
+
207
+ Raises:
208
+ ViAuthError: If `get_authorization_url` was not called first or the
209
+ token endpoint rejects the code.
210
+ ViConnectionError: If the token endpoint cannot be reached.
211
+ """
212
+ if not self._pkce_verifier:
213
+ raise ViAuthError(
214
+ "PKCE Verifier missing. Did you call get_authorization_url()?"
215
+ )
216
+
217
+ form_data = {
218
+ "client_id": self.client_id,
219
+ "grant_type": "authorization_code",
220
+ "redirect_uri": self.redirect_uri,
221
+ "code": code,
222
+ "code_verifier": self._pkce_verifier,
223
+ }
224
+ await self._async_request_tokens(form_data, "fetch token")
225
+ _LOGGER.debug("Exchanged authorization code for tokens")
226
+
227
+ async def _async_refresh_access_token(self) -> None:
228
+ """Refresh the access token through the token endpoint."""
229
+ token_info = await self._async_token_info()
230
+ refresh_token = token_info.get("refresh_token")
231
+ if not refresh_token:
232
+ raise ViAuthError("No refresh token available.")
233
+
234
+ form_data = {
235
+ "client_id": self.client_id,
236
+ "grant_type": "refresh_token",
237
+ "refresh_token": refresh_token,
238
+ }
239
+ _LOGGER.debug("Refreshing access token")
240
+ await self._async_request_tokens(form_data, "refresh token")
241
+ _LOGGER.debug(
242
+ "Access token refreshed (expires_in=%s)", token_info.get("expires_in")
243
+ )
244
+
245
+ async def async_refresh_access_token(self) -> None:
246
+ """Refresh the access token, sharing an in-flight refresh per instance.
247
+
248
+ Raises:
249
+ ViAuthError: If the refresh token is unavailable or rejected.
250
+ ViConnectionError: If the token endpoint cannot be reached.
251
+ """
252
+ refresh_task = self._refresh_task
253
+ if refresh_task is None or refresh_task.done():
254
+ refresh_task = asyncio.create_task(self._async_refresh_access_token())
255
+ self._refresh_task = refresh_task
256
+
257
+ try:
258
+ await asyncio.shield(refresh_task)
259
+ finally:
260
+ if refresh_task.done() and self._refresh_task is refresh_task:
261
+ self._refresh_task = None
262
+
263
+ async def async_close(self) -> None:
264
+ """Let an in-flight refresh settle before closing an owned session."""
265
+ refresh_task = self._refresh_task
266
+ if refresh_task is not None:
267
+ try:
268
+ await asyncio.shield(refresh_task)
269
+ except (
270
+ ViAuthError,
271
+ ViConnectionError,
272
+ aiohttp.ClientError,
273
+ OSError,
274
+ TimeoutError,
275
+ ValueError,
276
+ ):
277
+ _LOGGER.exception("Token refresh failed while closing authentication")
278
+ finally:
279
+ if refresh_task.done() and self._refresh_task is refresh_task:
280
+ self._refresh_task = None
281
+
282
+ await super().async_close()
283
+
284
+ async def async_get_access_token(self) -> str:
285
+ """Return a valid access token, refreshing it shortly before expiry.
286
+
287
+ Raises:
288
+ ViAuthError: If no usable token is stored or the refresh fails.
289
+ ViConnectionError: If a needed refresh cannot reach the token
290
+ endpoint.
291
+ """
292
+ token_info = await self._async_token_info()
293
+ if not token_info:
294
+ raise ViAuthError("No tokens loaded. Please authenticate first.")
295
+
296
+ expires_at = token_info.get("expires_at")
297
+ if (
298
+ is_json_number(expires_at)
299
+ and time.time() < expires_at - _TOKEN_EXPIRY_MARGIN_SECONDS
300
+ ):
301
+ return _access_token_value(token_info)
302
+
303
+ if "refresh_token" in token_info:
304
+ await self.async_refresh_access_token()
305
+ return _access_token_value(token_info)
306
+
307
+ # Without the offline_access scope there is no refresh token to renew with.
308
+ _LOGGER.warning(
309
+ "No refresh token available; using possibly expired access token"
310
+ )
311
+ return _access_token_value(token_info)
312
+
313
+
314
+ def _generate_pkce_pair() -> tuple[str, str]:
315
+ """Return a PKCE code verifier and its S256 code challenge (RFC 7636)."""
316
+ # 96 random bytes encode to 128 URL-safe characters, the RFC's maximum.
317
+ verifier = secrets.token_urlsafe(96)
318
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
319
+ challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
320
+ return verifier, challenge
321
+
322
+
323
+ def _access_token_value(token_info: Mapping[str, JsonValue]) -> str:
324
+ """Return the validated current access token."""
325
+ access_token = token_info.get("access_token")
326
+ if not isinstance(access_token, str) or not access_token:
327
+ raise ViAuthError("No valid access token available.")
328
+ return access_token
329
+
330
+
331
+ def _validate_token_response(token_data: object) -> dict[str, JsonValue]:
332
+ """Validate a successful OAuth token response before updating state."""
333
+ try:
334
+ validated_token_data = validate_json_value(token_data, path="Token response")
335
+ except ViResponseError as error:
336
+ raise ViAuthError("Token response contains invalid JSON data") from error
337
+ if not isinstance(validated_token_data, dict):
338
+ raise ViAuthError("Token response must be a JSON object")
339
+
340
+ access_token = validated_token_data.get("access_token")
341
+ if not isinstance(access_token, str) or not access_token:
342
+ raise ViAuthError("Token response access_token must be a non-empty string")
343
+ for field_name in ("refresh_token", "token_type"):
344
+ value = validated_token_data.get(field_name)
345
+ if value is not None and not isinstance(value, str):
346
+ raise ViAuthError(f"Token response {field_name} must be a string")
347
+ expires_in = validated_token_data.get("expires_in")
348
+ if expires_in is not None and (
349
+ not is_json_number(expires_in) or not isfinite(expires_in) or expires_in < 0
350
+ ):
351
+ raise ViAuthError(
352
+ "Token response expires_in must be a non-negative finite number"
353
+ )
354
+ return validated_token_data
355
+
356
+
357
+ async def _read_token_response(response: aiohttp.ClientResponse) -> object:
358
+ """Read a successful token response as JSON with library-owned errors."""
359
+ try:
360
+ return await response.json()
361
+ except aiohttp.ClientConnectionError, aiohttp.ClientPayloadError:
362
+ # An incomplete body is a network failure, which the caller reports.
363
+ raise
364
+ except (aiohttp.ClientError, json.JSONDecodeError, UnicodeDecodeError) as error:
365
+ raise ViAuthError("Token response contains invalid JSON data") from error