dimplex-controller 0.8.0__tar.gz → 0.9.0__tar.gz
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.
- {dimplex_controller-0.8.0 → dimplex_controller-0.9.0}/PKG-INFO +1 -1
- {dimplex_controller-0.8.0 → dimplex_controller-0.9.0}/dimplex_controller/__init__.py +14 -1
- dimplex_controller-0.9.0/dimplex_controller/auth.py +476 -0
- dimplex_controller-0.9.0/dimplex_controller/exceptions.py +183 -0
- {dimplex_controller-0.8.0 → dimplex_controller-0.9.0}/pyproject.toml +1 -1
- dimplex_controller-0.8.0/dimplex_controller/auth.py +0 -398
- dimplex_controller-0.8.0/dimplex_controller/exceptions.py +0 -22
- {dimplex_controller-0.8.0 → dimplex_controller-0.9.0}/LICENSE +0 -0
- {dimplex_controller-0.8.0 → dimplex_controller-0.9.0}/README.md +0 -0
- {dimplex_controller-0.8.0 → dimplex_controller-0.9.0}/dimplex_controller/client.py +0 -0
- {dimplex_controller-0.8.0 → dimplex_controller-0.9.0}/dimplex_controller/const.py +0 -0
- {dimplex_controller-0.8.0 → dimplex_controller-0.9.0}/dimplex_controller/models.py +0 -0
- {dimplex_controller-0.8.0 → dimplex_controller-0.9.0}/dimplex_controller/py.typed +0 -0
- {dimplex_controller-0.8.0 → dimplex_controller-0.9.0}/dimplex_controller/telemetry.py +0 -0
|
@@ -2,7 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
from .auth import TokenBundle
|
|
4
4
|
from .client import DimplexControl
|
|
5
|
-
from .exceptions import
|
|
5
|
+
from .exceptions import (
|
|
6
|
+
DimplexApiError,
|
|
7
|
+
DimplexAuthError,
|
|
8
|
+
DimplexAuthInvalidCredentialsError,
|
|
9
|
+
DimplexAuthInvalidGrantError,
|
|
10
|
+
DimplexAuthParseError,
|
|
11
|
+
DimplexAuthTransientError,
|
|
12
|
+
DimplexConnectionError,
|
|
13
|
+
DimplexError,
|
|
14
|
+
)
|
|
6
15
|
from .models import (
|
|
7
16
|
Appliance,
|
|
8
17
|
ApplianceModeFlag,
|
|
@@ -48,5 +57,9 @@ __all__ = [
|
|
|
48
57
|
"DimplexError",
|
|
49
58
|
"DimplexApiError",
|
|
50
59
|
"DimplexAuthError",
|
|
60
|
+
"DimplexAuthInvalidGrantError",
|
|
61
|
+
"DimplexAuthInvalidCredentialsError",
|
|
62
|
+
"DimplexAuthParseError",
|
|
63
|
+
"DimplexAuthTransientError",
|
|
51
64
|
"DimplexConnectionError",
|
|
52
65
|
]
|
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Awaitable, Callable
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import parse_qs, urlencode, urlparse
|
|
13
|
+
|
|
14
|
+
import aiohttp
|
|
15
|
+
|
|
16
|
+
from .const import (
|
|
17
|
+
AUTH_URL,
|
|
18
|
+
B2C_POLICY,
|
|
19
|
+
CLIENT_ID,
|
|
20
|
+
HTTP_OK,
|
|
21
|
+
REDIRECT_URI,
|
|
22
|
+
SCOPE,
|
|
23
|
+
)
|
|
24
|
+
from .exceptions import (
|
|
25
|
+
DimplexAuthError,
|
|
26
|
+
DimplexAuthInvalidCredentialsError,
|
|
27
|
+
DimplexAuthInvalidGrantError,
|
|
28
|
+
DimplexAuthParseError,
|
|
29
|
+
DimplexAuthTransientError,
|
|
30
|
+
classify_oauth_token_error,
|
|
31
|
+
oauth_error_summary,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_LOGGER = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
TokenListener = Callable[["TokenBundle"], Awaitable[None] | None]
|
|
37
|
+
|
|
38
|
+
# Max redirect hops while capturing the MSAL auth-code redirect.
|
|
39
|
+
_MAX_REDIRECT_HOPS = 20
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class TokenBundle:
|
|
44
|
+
"""Public, serialisable snapshot of auth tokens."""
|
|
45
|
+
|
|
46
|
+
access_token: str | None = None
|
|
47
|
+
refresh_token: str | None = None
|
|
48
|
+
expires_at: float = 0.0
|
|
49
|
+
|
|
50
|
+
def as_dict(self) -> dict[str, Any]:
|
|
51
|
+
"""Return a plain dict suitable for JSON / config-entry storage."""
|
|
52
|
+
return {
|
|
53
|
+
"access_token": self.access_token,
|
|
54
|
+
"refresh_token": self.refresh_token,
|
|
55
|
+
"expires_at": self.expires_at,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_mapping(cls, data: dict[str, Any] | None) -> TokenBundle:
|
|
60
|
+
"""Build a bundle from a dict-like token store."""
|
|
61
|
+
if not data:
|
|
62
|
+
return cls()
|
|
63
|
+
return cls(
|
|
64
|
+
access_token=data.get("access_token"),
|
|
65
|
+
refresh_token=data.get("refresh_token"),
|
|
66
|
+
expires_at=float(data.get("expires_at") or 0),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AuthManager:
|
|
71
|
+
"""Manages authentication for Dimplex Control."""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
session: aiohttp.ClientSession,
|
|
76
|
+
token_data: dict[str, Any] | TokenBundle | None = None,
|
|
77
|
+
*,
|
|
78
|
+
on_token_update: TokenListener | None = None,
|
|
79
|
+
):
|
|
80
|
+
"""Initialize the auth manager."""
|
|
81
|
+
self._session = session
|
|
82
|
+
self._on_token_update = on_token_update
|
|
83
|
+
bundle = token_data if isinstance(token_data, TokenBundle) else TokenBundle.from_mapping(token_data)
|
|
84
|
+
self._access_token: str | None = bundle.access_token
|
|
85
|
+
self._refresh_token: str | None = bundle.refresh_token
|
|
86
|
+
self._expires_at: float = bundle.expires_at
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def is_authenticated(self) -> bool:
|
|
90
|
+
"""Check if we have a valid access token."""
|
|
91
|
+
return self._access_token is not None and time.time() < self._expires_at
|
|
92
|
+
|
|
93
|
+
def export_tokens(self) -> TokenBundle:
|
|
94
|
+
"""Return the current token snapshot (safe for persistence)."""
|
|
95
|
+
return TokenBundle(
|
|
96
|
+
access_token=self._access_token,
|
|
97
|
+
refresh_token=self._refresh_token,
|
|
98
|
+
expires_at=self._expires_at,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def apply_tokens(self, bundle: TokenBundle | dict[str, Any]) -> None:
|
|
102
|
+
"""Replace in-memory tokens from a :class:`TokenBundle` or dict."""
|
|
103
|
+
if not isinstance(bundle, TokenBundle):
|
|
104
|
+
bundle = TokenBundle.from_mapping(bundle)
|
|
105
|
+
self._access_token = bundle.access_token
|
|
106
|
+
self._refresh_token = bundle.refresh_token
|
|
107
|
+
self._expires_at = bundle.expires_at
|
|
108
|
+
|
|
109
|
+
async def get_access_token(self) -> str:
|
|
110
|
+
"""Get a valid access token, refreshing if necessary."""
|
|
111
|
+
if not self._refresh_token:
|
|
112
|
+
raise DimplexAuthInvalidGrantError(
|
|
113
|
+
"No refresh token available. User must authenticate first.",
|
|
114
|
+
code="missing_refresh_token",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if self.is_authenticated:
|
|
118
|
+
assert self._access_token is not None
|
|
119
|
+
return self._access_token
|
|
120
|
+
|
|
121
|
+
# Token expired or missing, try refresh
|
|
122
|
+
await self.refresh_tokens()
|
|
123
|
+
if not self._access_token:
|
|
124
|
+
raise DimplexAuthInvalidGrantError(
|
|
125
|
+
"Token refresh succeeded but no access token was returned.",
|
|
126
|
+
code="missing_access_token",
|
|
127
|
+
)
|
|
128
|
+
return self._access_token
|
|
129
|
+
|
|
130
|
+
async def refresh_tokens(self) -> None:
|
|
131
|
+
"""Refresh the access token using the refresh token."""
|
|
132
|
+
_LOGGER.debug("Refreshing access token")
|
|
133
|
+
payload = {
|
|
134
|
+
"client_id": CLIENT_ID,
|
|
135
|
+
"grant_type": "refresh_token",
|
|
136
|
+
"refresh_token": self._refresh_token,
|
|
137
|
+
"scope": SCOPE,
|
|
138
|
+
"client_info": "1",
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
async with self._session.post(f"{AUTH_URL}/token", data=payload) as resp:
|
|
143
|
+
if resp.status != HTTP_OK:
|
|
144
|
+
text = await resp.text()
|
|
145
|
+
_LOGGER.error(
|
|
146
|
+
"Failed to refresh token: HTTP %s (%s)",
|
|
147
|
+
resp.status,
|
|
148
|
+
_safe_error_summary(text),
|
|
149
|
+
)
|
|
150
|
+
raise classify_oauth_token_error(resp.status, text)
|
|
151
|
+
|
|
152
|
+
data = await resp.json()
|
|
153
|
+
await self._update_tokens(data)
|
|
154
|
+
except DimplexAuthError:
|
|
155
|
+
raise
|
|
156
|
+
except aiohttp.ClientError as exc:
|
|
157
|
+
raise DimplexAuthTransientError(
|
|
158
|
+
f"Network error while refreshing token: {type(exc).__name__}",
|
|
159
|
+
details=str(exc)[:200],
|
|
160
|
+
) from exc
|
|
161
|
+
|
|
162
|
+
async def _update_tokens(self, data: dict[str, Any]) -> None:
|
|
163
|
+
"""Update internal token state from API response."""
|
|
164
|
+
self._access_token = data.get("access_token")
|
|
165
|
+
# Some refresh responses omit a new refresh token — keep the old one.
|
|
166
|
+
if data.get("refresh_token"):
|
|
167
|
+
self._refresh_token = data.get("refresh_token")
|
|
168
|
+
expires_in = data.get("expires_in", 3600)
|
|
169
|
+
self._expires_at = time.time() + float(expires_in) - 60 # Buffer 60s
|
|
170
|
+
if self._on_token_update is not None:
|
|
171
|
+
result = self._on_token_update(self.export_tokens())
|
|
172
|
+
if inspect.isawaitable(result):
|
|
173
|
+
await result
|
|
174
|
+
|
|
175
|
+
def save_tokens(self, file_path: str) -> None:
|
|
176
|
+
"""Save current tokens to a JSON file."""
|
|
177
|
+
data = self.export_tokens().as_dict()
|
|
178
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
179
|
+
json.dump(data, f, indent=2)
|
|
180
|
+
_LOGGER.info("Tokens saved to %s", file_path)
|
|
181
|
+
|
|
182
|
+
@classmethod
|
|
183
|
+
def load_tokens(cls, file_path: str) -> dict[str, Any] | None:
|
|
184
|
+
"""Load tokens from a JSON file."""
|
|
185
|
+
if not os.path.exists(file_path):
|
|
186
|
+
return None
|
|
187
|
+
try:
|
|
188
|
+
with open(file_path, encoding="utf-8") as f:
|
|
189
|
+
data = json.load(f)
|
|
190
|
+
if isinstance(data, dict):
|
|
191
|
+
return data
|
|
192
|
+
return None
|
|
193
|
+
except Exception as e:
|
|
194
|
+
_LOGGER.error("Failed to load tokens from %s: %s", file_path, type(e).__name__)
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
def get_login_url(self) -> str:
|
|
198
|
+
"""Generate the login URL for the user to visit."""
|
|
199
|
+
params = {
|
|
200
|
+
"client_id": CLIENT_ID,
|
|
201
|
+
"response_type": "code",
|
|
202
|
+
"redirect_uri": REDIRECT_URI,
|
|
203
|
+
"scope": SCOPE,
|
|
204
|
+
"response_mode": "query",
|
|
205
|
+
}
|
|
206
|
+
return f"{AUTH_URL}/authorize?{urlencode(params)}"
|
|
207
|
+
|
|
208
|
+
async def exchange_code(self, code: str) -> None:
|
|
209
|
+
"""Exchange authorization code for tokens."""
|
|
210
|
+
payload = {
|
|
211
|
+
"client_id": CLIENT_ID,
|
|
212
|
+
"grant_type": "authorization_code",
|
|
213
|
+
"code": code,
|
|
214
|
+
"redirect_uri": REDIRECT_URI,
|
|
215
|
+
"scope": SCOPE,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
_LOGGER.debug("Exchanging authorization code for tokens")
|
|
219
|
+
try:
|
|
220
|
+
async with self._session.post(f"{AUTH_URL}/token", data=payload) as resp:
|
|
221
|
+
if resp.status != HTTP_OK:
|
|
222
|
+
text = await resp.text()
|
|
223
|
+
_LOGGER.error(
|
|
224
|
+
"Token exchange failed: HTTP %s (%s)",
|
|
225
|
+
resp.status,
|
|
226
|
+
_safe_error_summary(text),
|
|
227
|
+
)
|
|
228
|
+
raise classify_oauth_token_error(resp.status, text)
|
|
229
|
+
|
|
230
|
+
data = await resp.json()
|
|
231
|
+
await self._update_tokens(data)
|
|
232
|
+
except DimplexAuthError:
|
|
233
|
+
raise
|
|
234
|
+
except aiohttp.ClientError as exc:
|
|
235
|
+
raise DimplexAuthTransientError(
|
|
236
|
+
f"Network error while exchanging code: {type(exc).__name__}",
|
|
237
|
+
details=str(exc)[:200],
|
|
238
|
+
) from exc
|
|
239
|
+
|
|
240
|
+
@staticmethod
|
|
241
|
+
def _build_cookie_header(cookie_jar: Any, url: str) -> str:
|
|
242
|
+
"""Build an unquoted Cookie header from an aiohttp cookie jar.
|
|
243
|
+
|
|
244
|
+
Python's http.cookies wraps values containing +, /, or = in
|
|
245
|
+
double-quotes, but Azure AD B2C expects raw unquoted values.
|
|
246
|
+
"""
|
|
247
|
+
filtered = cookie_jar.filter_cookies(url)
|
|
248
|
+
return "; ".join(f"{m.key}={m.value}" for m in filtered.values())
|
|
249
|
+
|
|
250
|
+
@staticmethod
|
|
251
|
+
def _parse_b2c_login_page(html: str, page_url: str) -> dict[str, str]:
|
|
252
|
+
"""Extract B2C form fields from the login page HTML.
|
|
253
|
+
|
|
254
|
+
Returns a dict with csrf, tx, p, post_url, confirmed_url.
|
|
255
|
+
Raises DimplexAuthParseError if required fields cannot be found.
|
|
256
|
+
"""
|
|
257
|
+
csrf_match = re.search(r'"csrf"\s*:\s*"([^"]+)"', html)
|
|
258
|
+
if not csrf_match:
|
|
259
|
+
raise DimplexAuthParseError("Could not find CSRF token in B2C login page")
|
|
260
|
+
csrf = csrf_match.group(1)
|
|
261
|
+
|
|
262
|
+
tx_match = re.search(r'"transId"\s*:\s*"([^"]+)"', html)
|
|
263
|
+
if not tx_match:
|
|
264
|
+
raise DimplexAuthParseError("Could not find transId in B2C login page")
|
|
265
|
+
tx = tx_match.group(1)
|
|
266
|
+
|
|
267
|
+
# Build base URL by stripping the authorize endpoint.
|
|
268
|
+
# The B2C login page URL contains /tfp/{tenant}/{policy}/oauth2/v2.0/authorize
|
|
269
|
+
# and may redirect to /{tenant}/{policy}/oauth2/v2.0/authorize
|
|
270
|
+
if "/oauth2/v2.0/authorize" in page_url:
|
|
271
|
+
base_url = page_url.split("/oauth2/v2.0/authorize")[0]
|
|
272
|
+
else:
|
|
273
|
+
parsed = urlparse(page_url)
|
|
274
|
+
base_url = f"{parsed.scheme}://{parsed.netloc}/gdhvb2c.onmicrosoft.com/{B2C_POLICY}"
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
"csrf": csrf,
|
|
278
|
+
"tx": tx,
|
|
279
|
+
"p": B2C_POLICY,
|
|
280
|
+
"post_url": f"{base_url}/SelfAsserted?tx={tx}&p={B2C_POLICY}",
|
|
281
|
+
"confirmed_url": f"{base_url}/api/CombinedSigninAndSignup/confirmed",
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async def headless_login(self, email: str, password: str) -> None:
|
|
285
|
+
"""Perform a headless login via Azure AD B2C to obtain tokens.
|
|
286
|
+
|
|
287
|
+
Uses direct HTTP credential submission so users don't need to
|
|
288
|
+
manually extract auth codes from browser network traffic.
|
|
289
|
+
"""
|
|
290
|
+
jar = aiohttp.CookieJar(unsafe=True)
|
|
291
|
+
start_url = self.get_login_url()
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
async with aiohttp.ClientSession(cookie_jar=jar) as session:
|
|
295
|
+
# Step 1: GET the auth URI, follow redirects to B2C login page
|
|
296
|
+
_LOGGER.debug("Fetching B2C login page")
|
|
297
|
+
try:
|
|
298
|
+
async with session.get(start_url, allow_redirects=True) as resp:
|
|
299
|
+
login_html = await resp.text()
|
|
300
|
+
page_url = str(resp.url)
|
|
301
|
+
if resp.status != HTTP_OK:
|
|
302
|
+
if resp.status >= 500:
|
|
303
|
+
raise DimplexAuthTransientError(
|
|
304
|
+
f"B2C login page returned HTTP {resp.status}",
|
|
305
|
+
status=resp.status,
|
|
306
|
+
)
|
|
307
|
+
raise DimplexAuthError(
|
|
308
|
+
f"B2C login page returned HTTP {resp.status}",
|
|
309
|
+
status=resp.status,
|
|
310
|
+
reauth_required=False,
|
|
311
|
+
)
|
|
312
|
+
except aiohttp.ClientError as exc:
|
|
313
|
+
raise DimplexAuthTransientError(
|
|
314
|
+
f"Network error fetching B2C login page: {type(exc).__name__}",
|
|
315
|
+
details=str(exc)[:200],
|
|
316
|
+
) from exc
|
|
317
|
+
|
|
318
|
+
# Step 2: Parse the login page for CSRF, transaction ID, policy
|
|
319
|
+
fields = self._parse_b2c_login_page(login_html, page_url)
|
|
320
|
+
_LOGGER.debug(
|
|
321
|
+
"Parsed B2C login page (csrf_len=%s tx_len=%s p=%s)",
|
|
322
|
+
len(fields["csrf"]),
|
|
323
|
+
len(fields["tx"]),
|
|
324
|
+
fields["p"],
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# Step 3: POST credentials to SelfAsserted endpoint
|
|
328
|
+
post_data = {
|
|
329
|
+
"request_type": "RESPONSE",
|
|
330
|
+
"email": email,
|
|
331
|
+
"password": password,
|
|
332
|
+
}
|
|
333
|
+
parsed_page = urlparse(page_url)
|
|
334
|
+
origin = f"{parsed_page.scheme}://{parsed_page.netloc}"
|
|
335
|
+
post_headers = {
|
|
336
|
+
"X-CSRF-TOKEN": fields["csrf"],
|
|
337
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
338
|
+
"Referer": page_url,
|
|
339
|
+
"Origin": origin,
|
|
340
|
+
"Accept": "application/json, text/javascript, */*; q=0.01",
|
|
341
|
+
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
# Build an unquoted Cookie header — aiohttp wraps values
|
|
345
|
+
# containing +/= in double-quotes, but B2C requires raw values.
|
|
346
|
+
cookie_header = self._build_cookie_header(jar, fields["post_url"])
|
|
347
|
+
post_headers["Cookie"] = cookie_header
|
|
348
|
+
_LOGGER.debug("Submitting credentials to SelfAsserted endpoint")
|
|
349
|
+
|
|
350
|
+
# Use DummyCookieJar so POST response cookies aren't
|
|
351
|
+
# re-injected with quoted values on the next request.
|
|
352
|
+
async with aiohttp.ClientSession(
|
|
353
|
+
cookie_jar=aiohttp.DummyCookieJar(),
|
|
354
|
+
) as raw_session:
|
|
355
|
+
try:
|
|
356
|
+
async with raw_session.post(
|
|
357
|
+
fields["post_url"],
|
|
358
|
+
data=post_data,
|
|
359
|
+
headers=post_headers,
|
|
360
|
+
allow_redirects=False,
|
|
361
|
+
) as resp:
|
|
362
|
+
body = await resp.text()
|
|
363
|
+
if resp.status != HTTP_OK:
|
|
364
|
+
if resp.status >= 500:
|
|
365
|
+
raise DimplexAuthTransientError(
|
|
366
|
+
f"Credential submission returned HTTP {resp.status}",
|
|
367
|
+
status=resp.status,
|
|
368
|
+
)
|
|
369
|
+
raise DimplexAuthError(
|
|
370
|
+
f"Credential submission returned HTTP {resp.status}",
|
|
371
|
+
status=resp.status,
|
|
372
|
+
)
|
|
373
|
+
try:
|
|
374
|
+
resp_data = json.loads(body)
|
|
375
|
+
if str(resp_data.get("status")) == "400":
|
|
376
|
+
raise DimplexAuthInvalidCredentialsError("Invalid email or password")
|
|
377
|
+
except json.JSONDecodeError:
|
|
378
|
+
pass
|
|
379
|
+
|
|
380
|
+
# Merge POST response cookies into the cookie header
|
|
381
|
+
cookies: dict[str, str] = {}
|
|
382
|
+
for part in cookie_header.split("; "):
|
|
383
|
+
if "=" in part:
|
|
384
|
+
n, v = part.split("=", 1)
|
|
385
|
+
cookies[n] = v
|
|
386
|
+
for raw_sc in resp.headers.getall("Set-Cookie", []):
|
|
387
|
+
sc_pair = raw_sc.split(";", 1)[0]
|
|
388
|
+
if "=" in sc_pair:
|
|
389
|
+
n, v = sc_pair.split("=", 1)
|
|
390
|
+
cookies[n] = v
|
|
391
|
+
cookie_header = "; ".join(f"{n}={v}" for n, v in cookies.items())
|
|
392
|
+
except DimplexAuthError:
|
|
393
|
+
raise
|
|
394
|
+
except aiohttp.ClientError as exc:
|
|
395
|
+
raise DimplexAuthTransientError(
|
|
396
|
+
f"Network error submitting credentials: {type(exc).__name__}",
|
|
397
|
+
details=str(exc)[:200],
|
|
398
|
+
) from exc
|
|
399
|
+
|
|
400
|
+
# Step 4: GET the confirmed endpoint and follow redirects
|
|
401
|
+
confirmed_qs = f"rememberMe=false&csrf_token={fields['csrf']}&tx={fields['tx']}&p={fields['p']}"
|
|
402
|
+
next_url: str = fields["confirmed_url"] + "?" + confirmed_qs
|
|
403
|
+
confirmed_headers = {"Cookie": cookie_header}
|
|
404
|
+
|
|
405
|
+
for _ in range(_MAX_REDIRECT_HOPS):
|
|
406
|
+
_LOGGER.debug("Following B2C redirect hop")
|
|
407
|
+
try:
|
|
408
|
+
async with raw_session.get(
|
|
409
|
+
next_url,
|
|
410
|
+
headers=confirmed_headers,
|
|
411
|
+
allow_redirects=False,
|
|
412
|
+
) as resp:
|
|
413
|
+
resp_body = await resp.text()
|
|
414
|
+
if resp.status in (301, 302, 303, 307, 308):
|
|
415
|
+
location = resp.headers.get("Location", "")
|
|
416
|
+
if not location:
|
|
417
|
+
raise DimplexAuthParseError("Redirect without Location header")
|
|
418
|
+
if location.startswith(REDIRECT_URI) and (
|
|
419
|
+
len(location) == len(REDIRECT_URI) or location[len(REDIRECT_URI)] in ("?", "/")
|
|
420
|
+
):
|
|
421
|
+
parsed = urlparse(location)
|
|
422
|
+
query = parse_qs(parsed.query)
|
|
423
|
+
code = query.get("code", [""])[0]
|
|
424
|
+
if not code:
|
|
425
|
+
raise DimplexAuthParseError("Redirect URL missing auth code")
|
|
426
|
+
await self.exchange_code(code)
|
|
427
|
+
return
|
|
428
|
+
if not location.startswith("http"):
|
|
429
|
+
location = (
|
|
430
|
+
f"{parsed_page.scheme}://{parsed_page.netloc}" + location
|
|
431
|
+
if location.startswith("/")
|
|
432
|
+
else location
|
|
433
|
+
)
|
|
434
|
+
next_url = location
|
|
435
|
+
continue
|
|
436
|
+
if resp.status == HTTP_OK:
|
|
437
|
+
redirect_match = re.search(
|
|
438
|
+
rf"({re.escape(REDIRECT_URI)}\?[^\s\"'<]+)",
|
|
439
|
+
resp_body,
|
|
440
|
+
)
|
|
441
|
+
if redirect_match:
|
|
442
|
+
parsed = urlparse(redirect_match.group(1))
|
|
443
|
+
query = parse_qs(parsed.query)
|
|
444
|
+
code = query.get("code", [""])[0]
|
|
445
|
+
if code:
|
|
446
|
+
await self.exchange_code(code)
|
|
447
|
+
return
|
|
448
|
+
raise DimplexAuthParseError("Reached 200 response without finding redirect URL")
|
|
449
|
+
if resp.status >= 500:
|
|
450
|
+
raise DimplexAuthTransientError(
|
|
451
|
+
f"Unexpected HTTP {resp.status} during redirect chain",
|
|
452
|
+
status=resp.status,
|
|
453
|
+
)
|
|
454
|
+
raise DimplexAuthError(
|
|
455
|
+
f"Unexpected HTTP {resp.status} during redirect chain",
|
|
456
|
+
status=resp.status,
|
|
457
|
+
)
|
|
458
|
+
except DimplexAuthError:
|
|
459
|
+
raise
|
|
460
|
+
except aiohttp.ClientError as exc:
|
|
461
|
+
raise DimplexAuthTransientError(
|
|
462
|
+
f"Network error during redirect chain: {type(exc).__name__}",
|
|
463
|
+
details=str(exc)[:200],
|
|
464
|
+
) from exc
|
|
465
|
+
|
|
466
|
+
raise DimplexAuthTransientError(
|
|
467
|
+
"Exceeded maximum redirect hops without capturing auth code",
|
|
468
|
+
code="redirect_exhausted",
|
|
469
|
+
)
|
|
470
|
+
except DimplexAuthError:
|
|
471
|
+
raise
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _safe_error_summary(body: str) -> str:
|
|
475
|
+
"""Summarise an OAuth error body without echoing secrets."""
|
|
476
|
+
return oauth_error_summary(body)
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Exceptions for Dimplex Controller."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DimplexError(Exception):
|
|
10
|
+
"""Base exception for Dimplex Controller."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DimplexAuthError(DimplexError):
|
|
14
|
+
"""Authentication or token lifecycle failure.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
code: Stable machine-readable identifier (e.g. ``invalid_grant``).
|
|
18
|
+
reauth_required: Caller should prompt for credentials / full login again.
|
|
19
|
+
transient: Failure may succeed on retry (network, 5xx, rate limit).
|
|
20
|
+
status: Optional HTTP status from the auth endpoint.
|
|
21
|
+
details: Optional short server message (never a full token).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
code: str = "auth_error"
|
|
25
|
+
reauth_required: bool = False
|
|
26
|
+
transient: bool = False
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
message: str,
|
|
31
|
+
*,
|
|
32
|
+
code: str | None = None,
|
|
33
|
+
reauth_required: bool | None = None,
|
|
34
|
+
transient: bool | None = None,
|
|
35
|
+
status: int | None = None,
|
|
36
|
+
details: str | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
super().__init__(message)
|
|
39
|
+
if code is not None:
|
|
40
|
+
self.code = code
|
|
41
|
+
if reauth_required is not None:
|
|
42
|
+
self.reauth_required = reauth_required
|
|
43
|
+
if transient is not None:
|
|
44
|
+
self.transient = transient
|
|
45
|
+
self.status = status
|
|
46
|
+
self.details = details
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DimplexAuthInvalidGrantError(DimplexAuthError):
|
|
50
|
+
"""Refresh token or authorization code rejected; full re-login required."""
|
|
51
|
+
|
|
52
|
+
code = "invalid_grant"
|
|
53
|
+
reauth_required = True
|
|
54
|
+
transient = False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class DimplexAuthInvalidCredentialsError(DimplexAuthError):
|
|
58
|
+
"""Email/password rejected by B2C."""
|
|
59
|
+
|
|
60
|
+
code = "invalid_credentials"
|
|
61
|
+
reauth_required = True
|
|
62
|
+
transient = False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class DimplexAuthParseError(DimplexAuthError):
|
|
66
|
+
"""Could not parse B2C HTML or OAuth redirect payloads."""
|
|
67
|
+
|
|
68
|
+
code = "parse_error"
|
|
69
|
+
reauth_required = False
|
|
70
|
+
transient = False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class DimplexAuthTransientError(DimplexAuthError):
|
|
74
|
+
"""Temporary auth infrastructure failure; retry may help."""
|
|
75
|
+
|
|
76
|
+
code = "transient"
|
|
77
|
+
reauth_required = False
|
|
78
|
+
transient = True
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class DimplexApiError(DimplexError):
|
|
82
|
+
"""Exception for API errors."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, status: int, message: str):
|
|
85
|
+
self.status = status
|
|
86
|
+
self.message = message
|
|
87
|
+
super().__init__(f"API Error {status}: {message}")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class DimplexConnectionError(DimplexError):
|
|
91
|
+
"""Exception for connection errors."""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def classify_oauth_token_error(status: int, body: str) -> DimplexAuthError:
|
|
95
|
+
"""Map an OAuth token-endpoint failure to a structured :class:`DimplexAuthError`.
|
|
96
|
+
|
|
97
|
+
Callers can use ``error.reauth_required`` vs ``error.transient`` to decide
|
|
98
|
+
whether to open a reauth flow or retry later.
|
|
99
|
+
"""
|
|
100
|
+
error_code, description = _parse_oauth_error_body(body)
|
|
101
|
+
detail = description or _truncate(body, 200)
|
|
102
|
+
|
|
103
|
+
if status >= 500 or status == 429:
|
|
104
|
+
return DimplexAuthTransientError(
|
|
105
|
+
f"Auth endpoint temporarily unavailable (HTTP {status})",
|
|
106
|
+
status=status,
|
|
107
|
+
details=detail,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if error_code in {"invalid_grant", "invalid_token", "expired_token"}:
|
|
111
|
+
return DimplexAuthInvalidGrantError(
|
|
112
|
+
f"Refresh/authorization grant rejected: {error_code}",
|
|
113
|
+
status=status,
|
|
114
|
+
details=detail,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if error_code in {"invalid_client", "unauthorized_client"}:
|
|
118
|
+
return DimplexAuthInvalidGrantError(
|
|
119
|
+
f"OAuth client rejected: {error_code}",
|
|
120
|
+
status=status,
|
|
121
|
+
details=detail,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if status in {400, 401, 403}:
|
|
125
|
+
# Treat remaining client errors as needing a fresh login rather than a silent retry.
|
|
126
|
+
return DimplexAuthInvalidGrantError(
|
|
127
|
+
f"Token request failed (HTTP {status})",
|
|
128
|
+
status=status,
|
|
129
|
+
details=detail,
|
|
130
|
+
code=error_code or "invalid_grant",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
return DimplexAuthError(
|
|
134
|
+
f"Token request failed (HTTP {status})",
|
|
135
|
+
code=error_code or "auth_error",
|
|
136
|
+
status=status,
|
|
137
|
+
details=detail,
|
|
138
|
+
reauth_required=False,
|
|
139
|
+
transient=False,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _parse_oauth_error_body(body: str) -> tuple[str | None, str | None]:
|
|
144
|
+
"""Extract ``error`` / ``error_description`` from an OAuth error JSON body."""
|
|
145
|
+
text = (body or "").strip()
|
|
146
|
+
if not text:
|
|
147
|
+
return None, None
|
|
148
|
+
try:
|
|
149
|
+
data: Any = json.loads(text)
|
|
150
|
+
except json.JSONDecodeError:
|
|
151
|
+
return None, _truncate(text, 200)
|
|
152
|
+
if not isinstance(data, dict):
|
|
153
|
+
return None, _truncate(text, 200)
|
|
154
|
+
error = data.get("error")
|
|
155
|
+
description = data.get("error_description") or data.get("error_codes")
|
|
156
|
+
error_s = str(error) if error is not None else None
|
|
157
|
+
if isinstance(description, list):
|
|
158
|
+
description_s = ", ".join(str(x) for x in description)
|
|
159
|
+
elif description is not None:
|
|
160
|
+
description_s = str(description)
|
|
161
|
+
else:
|
|
162
|
+
description_s = None
|
|
163
|
+
return error_s, _truncate(description_s, 200) if description_s else None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def oauth_error_summary(body: str) -> str:
|
|
167
|
+
"""Human-readable OAuth error summary safe for logs (no raw tokens)."""
|
|
168
|
+
error_code, description = _parse_oauth_error_body(body)
|
|
169
|
+
parts = [p for p in (error_code, description) if p]
|
|
170
|
+
if parts:
|
|
171
|
+
return " — ".join(parts)
|
|
172
|
+
if not body:
|
|
173
|
+
return "empty body"
|
|
174
|
+
return f"non-json body ({len(body)} bytes)"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _truncate(value: str | None, limit: int) -> str | None:
|
|
178
|
+
if value is None:
|
|
179
|
+
return None
|
|
180
|
+
value = value.replace("\n", " ").strip()
|
|
181
|
+
if len(value) <= limit:
|
|
182
|
+
return value
|
|
183
|
+
return value[: limit - 3] + "..."
|
|
@@ -1,398 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import logging
|
|
5
|
-
import os
|
|
6
|
-
import re
|
|
7
|
-
import time
|
|
8
|
-
from collections.abc import Awaitable, Callable
|
|
9
|
-
from dataclasses import dataclass
|
|
10
|
-
from typing import Any
|
|
11
|
-
from urllib.parse import parse_qs, urlparse
|
|
12
|
-
|
|
13
|
-
import aiohttp
|
|
14
|
-
|
|
15
|
-
from .const import (
|
|
16
|
-
AUTH_URL,
|
|
17
|
-
B2C_POLICY,
|
|
18
|
-
CLIENT_ID,
|
|
19
|
-
HTTP_OK,
|
|
20
|
-
REDIRECT_URI,
|
|
21
|
-
SCOPE,
|
|
22
|
-
)
|
|
23
|
-
from .exceptions import DimplexAuthError
|
|
24
|
-
|
|
25
|
-
_LOGGER = logging.getLogger(__name__)
|
|
26
|
-
|
|
27
|
-
TokenListener = Callable[["TokenBundle"], Awaitable[None] | None]
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
@dataclass(frozen=True, slots=True)
|
|
31
|
-
class TokenBundle:
|
|
32
|
-
"""Public, serialisable snapshot of auth tokens."""
|
|
33
|
-
|
|
34
|
-
access_token: str | None = None
|
|
35
|
-
refresh_token: str | None = None
|
|
36
|
-
expires_at: float = 0.0
|
|
37
|
-
|
|
38
|
-
def as_dict(self) -> dict[str, Any]:
|
|
39
|
-
"""Return a plain dict suitable for JSON / config-entry storage."""
|
|
40
|
-
return {
|
|
41
|
-
"access_token": self.access_token,
|
|
42
|
-
"refresh_token": self.refresh_token,
|
|
43
|
-
"expires_at": self.expires_at,
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
@classmethod
|
|
47
|
-
def from_mapping(cls, data: dict[str, Any] | None) -> TokenBundle:
|
|
48
|
-
"""Build a bundle from a dict-like token store."""
|
|
49
|
-
if not data:
|
|
50
|
-
return cls()
|
|
51
|
-
return cls(
|
|
52
|
-
access_token=data.get("access_token"),
|
|
53
|
-
refresh_token=data.get("refresh_token"),
|
|
54
|
-
expires_at=float(data.get("expires_at") or 0),
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
class AuthManager:
|
|
59
|
-
"""Manages authentication for Dimplex Control."""
|
|
60
|
-
|
|
61
|
-
def __init__(
|
|
62
|
-
self,
|
|
63
|
-
session: aiohttp.ClientSession,
|
|
64
|
-
token_data: dict[str, Any] | TokenBundle | None = None,
|
|
65
|
-
*,
|
|
66
|
-
on_token_update: TokenListener | None = None,
|
|
67
|
-
):
|
|
68
|
-
"""Initialize the auth manager."""
|
|
69
|
-
self._session = session
|
|
70
|
-
self._on_token_update = on_token_update
|
|
71
|
-
bundle = token_data if isinstance(token_data, TokenBundle) else TokenBundle.from_mapping(token_data)
|
|
72
|
-
self._access_token: str | None = bundle.access_token
|
|
73
|
-
self._refresh_token: str | None = bundle.refresh_token
|
|
74
|
-
self._expires_at: float = bundle.expires_at
|
|
75
|
-
|
|
76
|
-
@property
|
|
77
|
-
def is_authenticated(self) -> bool:
|
|
78
|
-
"""Check if we have a valid access token."""
|
|
79
|
-
return self._access_token is not None and time.time() < self._expires_at
|
|
80
|
-
|
|
81
|
-
def export_tokens(self) -> TokenBundle:
|
|
82
|
-
"""Return the current token snapshot (safe for persistence)."""
|
|
83
|
-
return TokenBundle(
|
|
84
|
-
access_token=self._access_token,
|
|
85
|
-
refresh_token=self._refresh_token,
|
|
86
|
-
expires_at=self._expires_at,
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
def apply_tokens(self, bundle: TokenBundle | dict[str, Any]) -> None:
|
|
90
|
-
"""Replace in-memory tokens from a :class:`TokenBundle` or dict."""
|
|
91
|
-
if not isinstance(bundle, TokenBundle):
|
|
92
|
-
bundle = TokenBundle.from_mapping(bundle)
|
|
93
|
-
self._access_token = bundle.access_token
|
|
94
|
-
self._refresh_token = bundle.refresh_token
|
|
95
|
-
self._expires_at = bundle.expires_at
|
|
96
|
-
|
|
97
|
-
async def get_access_token(self) -> str:
|
|
98
|
-
"""Get a valid access token, refreshing if necessary."""
|
|
99
|
-
if not self._refresh_token:
|
|
100
|
-
raise DimplexAuthError("No refresh token available. User must authenticate first.")
|
|
101
|
-
|
|
102
|
-
if self.is_authenticated:
|
|
103
|
-
assert self._access_token is not None
|
|
104
|
-
return self._access_token
|
|
105
|
-
|
|
106
|
-
# Token expired or missing, try refresh
|
|
107
|
-
await self.refresh_tokens()
|
|
108
|
-
if not self._access_token:
|
|
109
|
-
raise DimplexAuthError("Token refresh succeeded but no access token was returned.")
|
|
110
|
-
return self._access_token
|
|
111
|
-
|
|
112
|
-
async def refresh_tokens(self) -> None:
|
|
113
|
-
"""Refresh the access token using the refresh token."""
|
|
114
|
-
_LOGGER.debug("Refreshing access token")
|
|
115
|
-
payload = {
|
|
116
|
-
"client_id": CLIENT_ID,
|
|
117
|
-
"grant_type": "refresh_token",
|
|
118
|
-
"refresh_token": self._refresh_token,
|
|
119
|
-
"scope": SCOPE,
|
|
120
|
-
"client_info": "1",
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
async with self._session.post(f"{AUTH_URL}/token", data=payload) as resp:
|
|
124
|
-
if resp.status != HTTP_OK:
|
|
125
|
-
text = await resp.text()
|
|
126
|
-
_LOGGER.error("Failed to refresh token: %s", text)
|
|
127
|
-
raise DimplexAuthError(f"Failed to refresh token: {resp.status} - {text}")
|
|
128
|
-
|
|
129
|
-
data = await resp.json()
|
|
130
|
-
self._update_tokens(data)
|
|
131
|
-
|
|
132
|
-
def _update_tokens(self, data: dict[str, Any]) -> None:
|
|
133
|
-
"""Update internal token state from API response."""
|
|
134
|
-
self._access_token = data.get("access_token")
|
|
135
|
-
# Some refresh responses omit a new refresh token — keep the old one.
|
|
136
|
-
if data.get("refresh_token"):
|
|
137
|
-
self._refresh_token = data.get("refresh_token")
|
|
138
|
-
expires_in = data.get("expires_in", 3600)
|
|
139
|
-
self._expires_at = time.time() + float(expires_in) - 60 # Buffer 60s
|
|
140
|
-
if self._on_token_update is not None:
|
|
141
|
-
result = self._on_token_update(self.export_tokens())
|
|
142
|
-
if hasattr(result, "__await__"):
|
|
143
|
-
# Listener may be async; fire-and-forget is unsafe here — callers
|
|
144
|
-
# should prefer sync listeners or schedule from the callback.
|
|
145
|
-
pass
|
|
146
|
-
|
|
147
|
-
def save_tokens(self, file_path: str) -> None:
|
|
148
|
-
"""Save current tokens to a JSON file."""
|
|
149
|
-
data = self.export_tokens().as_dict()
|
|
150
|
-
with open(file_path, "w", encoding="utf-8") as f:
|
|
151
|
-
json.dump(data, f, indent=2)
|
|
152
|
-
_LOGGER.info("Tokens saved to %s", file_path)
|
|
153
|
-
|
|
154
|
-
@classmethod
|
|
155
|
-
def load_tokens(cls, file_path: str) -> dict[str, Any] | None:
|
|
156
|
-
"""Load tokens from a JSON file."""
|
|
157
|
-
if not os.path.exists(file_path):
|
|
158
|
-
return None
|
|
159
|
-
try:
|
|
160
|
-
with open(file_path, encoding="utf-8") as f:
|
|
161
|
-
data = json.load(f)
|
|
162
|
-
if isinstance(data, dict):
|
|
163
|
-
return data
|
|
164
|
-
return None
|
|
165
|
-
except Exception as e:
|
|
166
|
-
_LOGGER.error("Failed to load tokens from %s: %s", file_path, e)
|
|
167
|
-
return None
|
|
168
|
-
|
|
169
|
-
def get_login_url(self) -> str:
|
|
170
|
-
"""Generate the login URL for the user to visit."""
|
|
171
|
-
# Note: This is a simplified URL generation.
|
|
172
|
-
# In a real app, we might need state, nonce, code_challenge (PKCE).
|
|
173
|
-
# Based on logs, iOS app uses standard OAuth2.
|
|
174
|
-
# Constructing a URL for manual copy-paste might be tricky if it strictly requires a custom scheme redirect.
|
|
175
|
-
# But we can try the standard authorize endpoint.
|
|
176
|
-
|
|
177
|
-
params = {
|
|
178
|
-
"client_id": CLIENT_ID,
|
|
179
|
-
"response_type": "code",
|
|
180
|
-
"redirect_uri": REDIRECT_URI,
|
|
181
|
-
"scope": SCOPE,
|
|
182
|
-
"response_mode": "query",
|
|
183
|
-
}
|
|
184
|
-
from urllib.parse import urlencode
|
|
185
|
-
|
|
186
|
-
return f"{AUTH_URL}/authorize?{urlencode(params)}"
|
|
187
|
-
|
|
188
|
-
async def exchange_code(self, code: str) -> None:
|
|
189
|
-
"""Exchange authorization code for tokens."""
|
|
190
|
-
payload = {
|
|
191
|
-
"client_id": CLIENT_ID,
|
|
192
|
-
"grant_type": "authorization_code",
|
|
193
|
-
"code": code,
|
|
194
|
-
"redirect_uri": REDIRECT_URI,
|
|
195
|
-
"scope": SCOPE,
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
_LOGGER.info(f"Exchanging code for tokens at {AUTH_URL}/token")
|
|
199
|
-
client_id = payload["client_id"]
|
|
200
|
-
redirect_uri = payload["redirect_uri"]
|
|
201
|
-
code_preview = code[:10]
|
|
202
|
-
_LOGGER.info(f"Payload: client_id={client_id}, redirect_uri={redirect_uri}, " f"code={code_preview}...")
|
|
203
|
-
|
|
204
|
-
async with self._session.post(f"{AUTH_URL}/token", data=payload) as resp:
|
|
205
|
-
_LOGGER.info(f"Token exchange response status: {resp.status}")
|
|
206
|
-
if resp.status != HTTP_OK:
|
|
207
|
-
text = await resp.text()
|
|
208
|
-
_LOGGER.error(f"Token exchange failed: {text}")
|
|
209
|
-
raise DimplexAuthError(f"Failed to exchange code: {text}")
|
|
210
|
-
|
|
211
|
-
data = await resp.json()
|
|
212
|
-
self._update_tokens(data)
|
|
213
|
-
|
|
214
|
-
@staticmethod
|
|
215
|
-
def _build_cookie_header(cookie_jar, url: str) -> str:
|
|
216
|
-
"""Build an unquoted Cookie header from an aiohttp cookie jar.
|
|
217
|
-
|
|
218
|
-
Python's http.cookies wraps values containing +, /, or = in
|
|
219
|
-
double-quotes, but Azure AD B2C expects raw unquoted values.
|
|
220
|
-
"""
|
|
221
|
-
filtered = cookie_jar.filter_cookies(url)
|
|
222
|
-
return "; ".join(f"{m.key}={m.value}" for m in filtered.values())
|
|
223
|
-
|
|
224
|
-
@staticmethod
|
|
225
|
-
def _parse_b2c_login_page(html: str, page_url: str) -> dict:
|
|
226
|
-
"""Extract B2C form fields from the login page HTML.
|
|
227
|
-
|
|
228
|
-
Returns a dict with csrf, tx, p, post_url, confirmed_url.
|
|
229
|
-
Raises DimplexAuthError if required fields cannot be found.
|
|
230
|
-
"""
|
|
231
|
-
csrf_match = re.search(r'"csrf"\s*:\s*"([^"]+)"', html)
|
|
232
|
-
if not csrf_match:
|
|
233
|
-
raise DimplexAuthError("Could not find CSRF token in B2C login page")
|
|
234
|
-
csrf = csrf_match.group(1)
|
|
235
|
-
|
|
236
|
-
tx_match = re.search(r'"transId"\s*:\s*"([^"]+)"', html)
|
|
237
|
-
if not tx_match:
|
|
238
|
-
raise DimplexAuthError("Could not find transId in B2C login page")
|
|
239
|
-
tx = tx_match.group(1)
|
|
240
|
-
|
|
241
|
-
# Build base URL by stripping the authorize endpoint.
|
|
242
|
-
# The B2C login page URL contains /tfp/{tenant}/{policy}/oauth2/v2.0/authorize
|
|
243
|
-
# and may redirect to /{tenant}/{policy}/oauth2/v2.0/authorize
|
|
244
|
-
if "/oauth2/v2.0/authorize" in page_url:
|
|
245
|
-
base_url = page_url.split("/oauth2/v2.0/authorize")[0]
|
|
246
|
-
else:
|
|
247
|
-
parsed = urlparse(page_url)
|
|
248
|
-
base_url = f"{parsed.scheme}://{parsed.netloc}" f"/gdhvb2c.onmicrosoft.com/{B2C_POLICY}"
|
|
249
|
-
|
|
250
|
-
return {
|
|
251
|
-
"csrf": csrf,
|
|
252
|
-
"tx": tx,
|
|
253
|
-
"p": B2C_POLICY,
|
|
254
|
-
"post_url": f"{base_url}/SelfAsserted?tx={tx}&p={B2C_POLICY}",
|
|
255
|
-
"confirmed_url": (f"{base_url}/api/CombinedSigninAndSignup/confirmed"),
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
async def headless_login(self, email: str, password: str) -> None:
|
|
259
|
-
"""Perform a headless login via Azure AD B2C to obtain tokens.
|
|
260
|
-
|
|
261
|
-
Uses direct HTTP credential submission so users don't need to
|
|
262
|
-
manually extract auth codes from browser network traffic.
|
|
263
|
-
"""
|
|
264
|
-
jar = aiohttp.CookieJar(unsafe=True)
|
|
265
|
-
start_url = self.get_login_url()
|
|
266
|
-
|
|
267
|
-
async with aiohttp.ClientSession(cookie_jar=jar) as session:
|
|
268
|
-
# Step 1: GET the auth URI, follow redirects to B2C login page
|
|
269
|
-
_LOGGER.debug("Fetching B2C login page: %s", start_url)
|
|
270
|
-
async with session.get(start_url, allow_redirects=True) as resp:
|
|
271
|
-
login_html = await resp.text()
|
|
272
|
-
page_url = str(resp.url)
|
|
273
|
-
if resp.status != HTTP_OK:
|
|
274
|
-
raise DimplexAuthError(f"B2C login page returned HTTP {resp.status}")
|
|
275
|
-
|
|
276
|
-
# Step 2: Parse the login page for CSRF, transaction ID, policy
|
|
277
|
-
fields = self._parse_b2c_login_page(login_html, page_url)
|
|
278
|
-
_LOGGER.debug(
|
|
279
|
-
"Parsed B2C login page: csrf=%s... tx=%s... p=%s",
|
|
280
|
-
fields["csrf"][:16],
|
|
281
|
-
fields["tx"][:40],
|
|
282
|
-
fields["p"],
|
|
283
|
-
)
|
|
284
|
-
|
|
285
|
-
# Step 3: POST credentials to SelfAsserted endpoint
|
|
286
|
-
post_data = {
|
|
287
|
-
"request_type": "RESPONSE",
|
|
288
|
-
"email": email,
|
|
289
|
-
"password": password,
|
|
290
|
-
}
|
|
291
|
-
parsed_page = urlparse(page_url)
|
|
292
|
-
origin = f"{parsed_page.scheme}://{parsed_page.netloc}"
|
|
293
|
-
post_headers = {
|
|
294
|
-
"X-CSRF-TOKEN": fields["csrf"],
|
|
295
|
-
"X-Requested-With": "XMLHttpRequest",
|
|
296
|
-
"Referer": page_url,
|
|
297
|
-
"Origin": origin,
|
|
298
|
-
"Accept": "application/json, text/javascript, */*; q=0.01",
|
|
299
|
-
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
# Build an unquoted Cookie header — aiohttp wraps values
|
|
303
|
-
# containing +/= in double-quotes, but B2C requires raw values.
|
|
304
|
-
cookie_header = self._build_cookie_header(jar, fields["post_url"])
|
|
305
|
-
post_headers["Cookie"] = cookie_header
|
|
306
|
-
_LOGGER.debug("Submitting credentials to %s", fields["post_url"])
|
|
307
|
-
|
|
308
|
-
# Use DummyCookieJar so POST response cookies aren't
|
|
309
|
-
# re-injected with quoted values on the next request.
|
|
310
|
-
async with aiohttp.ClientSession(
|
|
311
|
-
cookie_jar=aiohttp.DummyCookieJar(),
|
|
312
|
-
) as raw_session:
|
|
313
|
-
async with raw_session.post(
|
|
314
|
-
fields["post_url"],
|
|
315
|
-
data=post_data,
|
|
316
|
-
headers=post_headers,
|
|
317
|
-
allow_redirects=False,
|
|
318
|
-
) as resp:
|
|
319
|
-
body = await resp.text()
|
|
320
|
-
if resp.status != HTTP_OK:
|
|
321
|
-
raise DimplexAuthError(f"Credential submission returned HTTP {resp.status}")
|
|
322
|
-
try:
|
|
323
|
-
resp_data = json.loads(body)
|
|
324
|
-
if str(resp_data.get("status")) == "400":
|
|
325
|
-
raise DimplexAuthError("Invalid email or password")
|
|
326
|
-
except json.JSONDecodeError:
|
|
327
|
-
pass
|
|
328
|
-
|
|
329
|
-
# Merge POST response cookies into the cookie header
|
|
330
|
-
cookies: dict[str, str] = {}
|
|
331
|
-
for part in cookie_header.split("; "):
|
|
332
|
-
if "=" in part:
|
|
333
|
-
n, v = part.split("=", 1)
|
|
334
|
-
cookies[n] = v
|
|
335
|
-
for raw_sc in resp.headers.getall("Set-Cookie", []):
|
|
336
|
-
sc_pair = raw_sc.split(";", 1)[0]
|
|
337
|
-
if "=" in sc_pair:
|
|
338
|
-
n, v = sc_pair.split("=", 1)
|
|
339
|
-
cookies[n] = v
|
|
340
|
-
cookie_header = "; ".join(f"{n}={v}" for n, v in cookies.items())
|
|
341
|
-
|
|
342
|
-
# Step 4: GET the confirmed endpoint and follow redirects
|
|
343
|
-
confirmed_qs = (
|
|
344
|
-
f"rememberMe=false" f"&csrf_token={fields['csrf']}" f"&tx={fields['tx']}" f"&p={fields['p']}"
|
|
345
|
-
)
|
|
346
|
-
next_url: str = fields["confirmed_url"] + "?" + confirmed_qs
|
|
347
|
-
confirmed_headers = {"Cookie": cookie_header}
|
|
348
|
-
|
|
349
|
-
for _ in range(20): # max redirect hops
|
|
350
|
-
_LOGGER.debug("Following redirect: %s", next_url[:120])
|
|
351
|
-
async with raw_session.get(
|
|
352
|
-
next_url,
|
|
353
|
-
headers=confirmed_headers,
|
|
354
|
-
allow_redirects=False,
|
|
355
|
-
) as resp:
|
|
356
|
-
resp_body = await resp.text()
|
|
357
|
-
if resp.status in (301, 302, 303, 307, 308):
|
|
358
|
-
location = resp.headers.get("Location", "")
|
|
359
|
-
if not location:
|
|
360
|
-
raise DimplexAuthError("Redirect without Location header")
|
|
361
|
-
if location.startswith(REDIRECT_URI) and (
|
|
362
|
-
len(location) == len(REDIRECT_URI) or location[len(REDIRECT_URI)] in ("?", "/")
|
|
363
|
-
):
|
|
364
|
-
_LOGGER.debug(
|
|
365
|
-
"Captured redirect with code: %s...",
|
|
366
|
-
location[:120],
|
|
367
|
-
)
|
|
368
|
-
parsed = urlparse(location)
|
|
369
|
-
query = parse_qs(parsed.query)
|
|
370
|
-
code = query.get("code", [""])[0]
|
|
371
|
-
if not code:
|
|
372
|
-
raise DimplexAuthError("Redirect URL missing auth code")
|
|
373
|
-
await self.exchange_code(code)
|
|
374
|
-
return
|
|
375
|
-
if not location.startswith("http"):
|
|
376
|
-
location = (
|
|
377
|
-
f"{parsed_page.scheme}://{parsed_page.netloc}" + location
|
|
378
|
-
if location.startswith("/")
|
|
379
|
-
else location
|
|
380
|
-
)
|
|
381
|
-
next_url = location
|
|
382
|
-
continue
|
|
383
|
-
if resp.status == HTTP_OK:
|
|
384
|
-
redirect_match = re.search(
|
|
385
|
-
rf"({re.escape(REDIRECT_URI)}\?[^\s\"'<]+)",
|
|
386
|
-
resp_body,
|
|
387
|
-
)
|
|
388
|
-
if redirect_match:
|
|
389
|
-
parsed = urlparse(redirect_match.group(1))
|
|
390
|
-
query = parse_qs(parsed.query)
|
|
391
|
-
code = query.get("code", [""])[0]
|
|
392
|
-
if code:
|
|
393
|
-
await self.exchange_code(code)
|
|
394
|
-
return
|
|
395
|
-
raise DimplexAuthError("Reached 200 response without finding redirect URL")
|
|
396
|
-
raise DimplexAuthError(f"Unexpected HTTP {resp.status} during redirect chain")
|
|
397
|
-
|
|
398
|
-
raise DimplexAuthError("Exceeded maximum redirect hops without capturing auth code")
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
"""Exceptions for Dimplex Controller."""
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
class DimplexError(Exception):
|
|
5
|
-
"""Base exception for Dimplex Controller."""
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class DimplexAuthError(DimplexError):
|
|
9
|
-
"""Exception for authentication errors."""
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class DimplexApiError(DimplexError):
|
|
13
|
-
"""Exception for API errors."""
|
|
14
|
-
|
|
15
|
-
def __init__(self, status: int, message: str):
|
|
16
|
-
self.status = status
|
|
17
|
-
self.message = message
|
|
18
|
-
super().__init__(f"API Error {status}: {message}")
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
class DimplexConnectionError(DimplexError):
|
|
22
|
-
"""Exception for connection errors."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|