dimplex-controller 0.2.0__tar.gz → 0.3.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.2.0 → dimplex_controller-0.3.0}/PKG-INFO +1 -1
- dimplex_controller-0.3.0/dimplex_controller/auth.py +330 -0
- {dimplex_controller-0.2.0 → dimplex_controller-0.3.0}/dimplex_controller/const.py +1 -0
- {dimplex_controller-0.2.0 → dimplex_controller-0.3.0}/pyproject.toml +1 -1
- dimplex_controller-0.2.0/dimplex_controller/auth.py +0 -304
- {dimplex_controller-0.2.0 → dimplex_controller-0.3.0}/LICENSE +0 -0
- {dimplex_controller-0.2.0 → dimplex_controller-0.3.0}/README.md +0 -0
- {dimplex_controller-0.2.0 → dimplex_controller-0.3.0}/dimplex_controller/__init__.py +0 -0
- {dimplex_controller-0.2.0 → dimplex_controller-0.3.0}/dimplex_controller/client.py +0 -0
- {dimplex_controller-0.2.0 → dimplex_controller-0.3.0}/dimplex_controller/exceptions.py +0 -0
- {dimplex_controller-0.2.0 → dimplex_controller-0.3.0}/dimplex_controller/models.py +0 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
from typing import Dict, Optional
|
|
7
|
+
from urllib.parse import parse_qs, urlparse
|
|
8
|
+
|
|
9
|
+
import aiohttp
|
|
10
|
+
|
|
11
|
+
from .const import (
|
|
12
|
+
AUTH_URL,
|
|
13
|
+
B2C_POLICY,
|
|
14
|
+
CLIENT_ID,
|
|
15
|
+
HTTP_OK,
|
|
16
|
+
REDIRECT_URI,
|
|
17
|
+
SCOPE,
|
|
18
|
+
)
|
|
19
|
+
from .exceptions import DimplexAuthError
|
|
20
|
+
|
|
21
|
+
_LOGGER = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AuthManager:
|
|
25
|
+
"""Manages authentication for Dimplex Control."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, session: aiohttp.ClientSession, token_data: Optional[Dict] = None):
|
|
28
|
+
"""Initialize the auth manager."""
|
|
29
|
+
self._session = session
|
|
30
|
+
self._access_token: Optional[str] = token_data.get("access_token") if token_data else None
|
|
31
|
+
self._refresh_token: Optional[str] = token_data.get("refresh_token") if token_data else None
|
|
32
|
+
self._expires_at: float = token_data.get("expires_at", 0) if token_data else 0
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def is_authenticated(self) -> bool:
|
|
36
|
+
"""Check if we have a valid access token."""
|
|
37
|
+
return self._access_token is not None and time.time() < self._expires_at
|
|
38
|
+
|
|
39
|
+
async def get_access_token(self) -> str:
|
|
40
|
+
"""Get a valid access token, refreshing if necessary."""
|
|
41
|
+
if not self._refresh_token:
|
|
42
|
+
raise DimplexAuthError("No refresh token available. User must authenticate first.")
|
|
43
|
+
|
|
44
|
+
if self.is_authenticated:
|
|
45
|
+
return self._access_token
|
|
46
|
+
|
|
47
|
+
# Token expired or missing, try refresh
|
|
48
|
+
await self.refresh_tokens()
|
|
49
|
+
return self._access_token
|
|
50
|
+
|
|
51
|
+
async def refresh_tokens(self) -> None:
|
|
52
|
+
"""Refresh the access token using the refresh token."""
|
|
53
|
+
_LOGGER.debug("Refreshing access token")
|
|
54
|
+
payload = {
|
|
55
|
+
"client_id": CLIENT_ID,
|
|
56
|
+
"grant_type": "refresh_token",
|
|
57
|
+
"refresh_token": self._refresh_token,
|
|
58
|
+
"scope": SCOPE,
|
|
59
|
+
"client_info": "1",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async with self._session.post(f"{AUTH_URL}/token", data=payload) as resp:
|
|
63
|
+
if resp.status != HTTP_OK:
|
|
64
|
+
text = await resp.text()
|
|
65
|
+
_LOGGER.error("Failed to refresh token: %s", text)
|
|
66
|
+
raise DimplexAuthError(f"Failed to refresh token: {resp.status} - {text}")
|
|
67
|
+
|
|
68
|
+
data = await resp.json()
|
|
69
|
+
self._update_tokens(data)
|
|
70
|
+
|
|
71
|
+
def _update_tokens(self, data: Dict) -> None:
|
|
72
|
+
"""Update internal token state from API response."""
|
|
73
|
+
self._access_token = data.get("access_token")
|
|
74
|
+
self._refresh_token = data.get("refresh_token")
|
|
75
|
+
expires_in = data.get("expires_in", 3600)
|
|
76
|
+
self._expires_at = time.time() + expires_in - 60 # Buffer 60s
|
|
77
|
+
|
|
78
|
+
def get_login_url(self) -> str:
|
|
79
|
+
"""Generate the login URL for the user to visit."""
|
|
80
|
+
# Note: This is a simplified URL generation.
|
|
81
|
+
# In a real app, we might need state, nonce, code_challenge (PKCE).
|
|
82
|
+
# Based on logs, iOS app uses standard OAuth2.
|
|
83
|
+
# Constructing a URL for manual copy-paste might be tricky if it strictly requires a custom scheme redirect.
|
|
84
|
+
# But we can try the standard authorize endpoint.
|
|
85
|
+
|
|
86
|
+
params = {
|
|
87
|
+
"client_id": CLIENT_ID,
|
|
88
|
+
"response_type": "code",
|
|
89
|
+
"redirect_uri": REDIRECT_URI,
|
|
90
|
+
"scope": SCOPE,
|
|
91
|
+
"response_mode": "query",
|
|
92
|
+
}
|
|
93
|
+
from urllib.parse import urlencode
|
|
94
|
+
|
|
95
|
+
return f"{AUTH_URL}/authorize?{urlencode(params)}"
|
|
96
|
+
|
|
97
|
+
async def exchange_code(self, code: str) -> None:
|
|
98
|
+
"""Exchange authorization code for tokens."""
|
|
99
|
+
payload = {
|
|
100
|
+
"client_id": CLIENT_ID,
|
|
101
|
+
"grant_type": "authorization_code",
|
|
102
|
+
"code": code,
|
|
103
|
+
"redirect_uri": REDIRECT_URI,
|
|
104
|
+
"scope": SCOPE,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
_LOGGER.info(f"Exchanging code for tokens at {AUTH_URL}/token")
|
|
108
|
+
client_id = payload["client_id"]
|
|
109
|
+
redirect_uri = payload["redirect_uri"]
|
|
110
|
+
code_preview = code[:10]
|
|
111
|
+
_LOGGER.info(f"Payload: client_id={client_id}, redirect_uri={redirect_uri}, " f"code={code_preview}...")
|
|
112
|
+
|
|
113
|
+
async with self._session.post(f"{AUTH_URL}/token", data=payload) as resp:
|
|
114
|
+
_LOGGER.info(f"Token exchange response status: {resp.status}")
|
|
115
|
+
if resp.status != HTTP_OK:
|
|
116
|
+
text = await resp.text()
|
|
117
|
+
_LOGGER.error(f"Token exchange failed: {text}")
|
|
118
|
+
raise DimplexAuthError(f"Failed to exchange code: {text}")
|
|
119
|
+
|
|
120
|
+
data = await resp.json()
|
|
121
|
+
self._update_tokens(data)
|
|
122
|
+
|
|
123
|
+
@staticmethod
|
|
124
|
+
def _build_cookie_header(cookie_jar, url: str) -> str:
|
|
125
|
+
"""Build an unquoted Cookie header from an aiohttp cookie jar.
|
|
126
|
+
|
|
127
|
+
Python's http.cookies wraps values containing +, /, or = in
|
|
128
|
+
double-quotes, but Azure AD B2C expects raw unquoted values.
|
|
129
|
+
"""
|
|
130
|
+
filtered = cookie_jar.filter_cookies(url)
|
|
131
|
+
return "; ".join(f"{m.key}={m.value}" for m in filtered.values())
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def _parse_b2c_login_page(html: str, page_url: str) -> dict:
|
|
135
|
+
"""Extract B2C form fields from the login page HTML.
|
|
136
|
+
|
|
137
|
+
Returns a dict with csrf, tx, p, post_url, confirmed_url.
|
|
138
|
+
Raises DimplexAuthError if required fields cannot be found.
|
|
139
|
+
"""
|
|
140
|
+
csrf_match = re.search(r'"csrf"\s*:\s*"([^"]+)"', html)
|
|
141
|
+
if not csrf_match:
|
|
142
|
+
raise DimplexAuthError("Could not find CSRF token in B2C login page")
|
|
143
|
+
csrf = csrf_match.group(1)
|
|
144
|
+
|
|
145
|
+
tx_match = re.search(r'"transId"\s*:\s*"([^"]+)"', html)
|
|
146
|
+
if not tx_match:
|
|
147
|
+
raise DimplexAuthError("Could not find transId in B2C login page")
|
|
148
|
+
tx = tx_match.group(1)
|
|
149
|
+
|
|
150
|
+
# Build base URL by stripping the authorize endpoint.
|
|
151
|
+
# The B2C login page URL contains /tfp/{tenant}/{policy}/oauth2/v2.0/authorize
|
|
152
|
+
# and may redirect to /{tenant}/{policy}/oauth2/v2.0/authorize
|
|
153
|
+
if "/oauth2/v2.0/authorize" in page_url:
|
|
154
|
+
base_url = page_url.split("/oauth2/v2.0/authorize")[0]
|
|
155
|
+
else:
|
|
156
|
+
parsed = urlparse(page_url)
|
|
157
|
+
base_url = f"{parsed.scheme}://{parsed.netloc}" f"/gdhvb2c.onmicrosoft.com/{B2C_POLICY}"
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
"csrf": csrf,
|
|
161
|
+
"tx": tx,
|
|
162
|
+
"p": B2C_POLICY,
|
|
163
|
+
"post_url": f"{base_url}/SelfAsserted?tx={tx}&p={B2C_POLICY}",
|
|
164
|
+
"confirmed_url": (f"{base_url}/api/CombinedSigninAndSignup/confirmed"),
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async def headless_login(self, email: str, password: str) -> None:
|
|
168
|
+
"""Perform a headless login via Azure AD B2C to obtain tokens.
|
|
169
|
+
|
|
170
|
+
Uses direct HTTP credential submission so users don't need to
|
|
171
|
+
manually extract auth codes from browser network traffic.
|
|
172
|
+
"""
|
|
173
|
+
jar = aiohttp.CookieJar(unsafe=True)
|
|
174
|
+
start_url = self.get_login_url()
|
|
175
|
+
|
|
176
|
+
async with aiohttp.ClientSession(cookie_jar=jar) as session:
|
|
177
|
+
# Step 1: GET the auth URI, follow redirects to B2C login page
|
|
178
|
+
_LOGGER.debug("Fetching B2C login page: %s", start_url)
|
|
179
|
+
async with session.get(start_url, allow_redirects=True) as resp:
|
|
180
|
+
login_html = await resp.text()
|
|
181
|
+
page_url = str(resp.url)
|
|
182
|
+
if resp.status != HTTP_OK:
|
|
183
|
+
raise DimplexAuthError(f"B2C login page returned HTTP {resp.status}")
|
|
184
|
+
|
|
185
|
+
# Step 2: Parse the login page for CSRF, transaction ID, policy
|
|
186
|
+
fields = self._parse_b2c_login_page(login_html, page_url)
|
|
187
|
+
_LOGGER.debug(
|
|
188
|
+
"Parsed B2C login page: csrf=%s... tx=%s... p=%s",
|
|
189
|
+
fields["csrf"][:16],
|
|
190
|
+
fields["tx"][:40],
|
|
191
|
+
fields["p"],
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Step 3: POST credentials to SelfAsserted endpoint
|
|
195
|
+
post_data = {
|
|
196
|
+
"request_type": "RESPONSE",
|
|
197
|
+
"email": email,
|
|
198
|
+
"password": password,
|
|
199
|
+
}
|
|
200
|
+
parsed_page = urlparse(page_url)
|
|
201
|
+
origin = f"{parsed_page.scheme}://{parsed_page.netloc}"
|
|
202
|
+
post_headers = {
|
|
203
|
+
"X-CSRF-TOKEN": fields["csrf"],
|
|
204
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
205
|
+
"Referer": page_url,
|
|
206
|
+
"Origin": origin,
|
|
207
|
+
"Accept": "application/json, text/javascript, */*; q=0.01",
|
|
208
|
+
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
# Build an unquoted Cookie header — aiohttp wraps values
|
|
212
|
+
# containing +/= in double-quotes, but B2C requires raw values.
|
|
213
|
+
cookie_header = self._build_cookie_header(jar, fields["post_url"])
|
|
214
|
+
post_headers["Cookie"] = cookie_header
|
|
215
|
+
_LOGGER.debug("Submitting credentials to %s", fields["post_url"])
|
|
216
|
+
|
|
217
|
+
# Use DummyCookieJar so POST response cookies aren't
|
|
218
|
+
# re-injected with quoted values on the next request.
|
|
219
|
+
async with aiohttp.ClientSession(
|
|
220
|
+
cookie_jar=aiohttp.DummyCookieJar(),
|
|
221
|
+
) as raw_session:
|
|
222
|
+
async with raw_session.post(
|
|
223
|
+
fields["post_url"],
|
|
224
|
+
data=post_data,
|
|
225
|
+
headers=post_headers,
|
|
226
|
+
allow_redirects=False,
|
|
227
|
+
) as resp:
|
|
228
|
+
body = await resp.text()
|
|
229
|
+
if resp.status != HTTP_OK:
|
|
230
|
+
raise DimplexAuthError(f"Credential submission returned HTTP {resp.status}")
|
|
231
|
+
try:
|
|
232
|
+
resp_data = json.loads(body)
|
|
233
|
+
if str(resp_data.get("status")) == "400":
|
|
234
|
+
raise DimplexAuthError("Invalid email or password")
|
|
235
|
+
except json.JSONDecodeError:
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
# Merge POST response cookies into the cookie header
|
|
239
|
+
cookies: dict[str, str] = {}
|
|
240
|
+
for part in cookie_header.split("; "):
|
|
241
|
+
if "=" in part:
|
|
242
|
+
n, v = part.split("=", 1)
|
|
243
|
+
cookies[n] = v
|
|
244
|
+
for raw_sc in resp.headers.getall("Set-Cookie", []):
|
|
245
|
+
sc_pair = raw_sc.split(";", 1)[0]
|
|
246
|
+
if "=" in sc_pair:
|
|
247
|
+
n, v = sc_pair.split("=", 1)
|
|
248
|
+
cookies[n] = v
|
|
249
|
+
cookie_header = "; ".join(f"{n}={v}" for n, v in cookies.items())
|
|
250
|
+
|
|
251
|
+
# Step 4: GET the confirmed endpoint and follow redirects
|
|
252
|
+
confirmed_qs = (
|
|
253
|
+
f"rememberMe=false" f"&csrf_token={fields['csrf']}" f"&tx={fields['tx']}" f"&p={fields['p']}"
|
|
254
|
+
)
|
|
255
|
+
next_url: str = fields["confirmed_url"] + "?" + confirmed_qs
|
|
256
|
+
confirmed_headers = {"Cookie": cookie_header}
|
|
257
|
+
|
|
258
|
+
for _ in range(20): # max redirect hops
|
|
259
|
+
_LOGGER.debug("Following redirect: %s", next_url[:120])
|
|
260
|
+
async with raw_session.get(
|
|
261
|
+
next_url,
|
|
262
|
+
headers=confirmed_headers,
|
|
263
|
+
allow_redirects=False,
|
|
264
|
+
) as resp:
|
|
265
|
+
resp_body = await resp.text()
|
|
266
|
+
if resp.status in (301, 302, 303, 307, 308):
|
|
267
|
+
location = resp.headers.get("Location", "")
|
|
268
|
+
if not location:
|
|
269
|
+
raise DimplexAuthError("Redirect without Location header")
|
|
270
|
+
if location.startswith(REDIRECT_URI) and (
|
|
271
|
+
len(location) == len(REDIRECT_URI) or location[len(REDIRECT_URI)] in ("?", "/")
|
|
272
|
+
):
|
|
273
|
+
_LOGGER.debug(
|
|
274
|
+
"Captured redirect with code: %s...",
|
|
275
|
+
location[:120],
|
|
276
|
+
)
|
|
277
|
+
parsed = urlparse(location)
|
|
278
|
+
query = parse_qs(parsed.query)
|
|
279
|
+
code = query.get("code", [""])[0]
|
|
280
|
+
if not code:
|
|
281
|
+
raise DimplexAuthError("Redirect URL missing auth code")
|
|
282
|
+
await self.exchange_code(code)
|
|
283
|
+
return
|
|
284
|
+
if not location.startswith("http"):
|
|
285
|
+
location = (
|
|
286
|
+
f"{parsed_page.scheme}://{parsed_page.netloc}" + location
|
|
287
|
+
if location.startswith("/")
|
|
288
|
+
else location
|
|
289
|
+
)
|
|
290
|
+
next_url = location
|
|
291
|
+
continue
|
|
292
|
+
if resp.status == HTTP_OK:
|
|
293
|
+
redirect_match = re.search(
|
|
294
|
+
rf"({re.escape(REDIRECT_URI)}\?[^\s\"'<]+)",
|
|
295
|
+
resp_body,
|
|
296
|
+
)
|
|
297
|
+
if redirect_match:
|
|
298
|
+
parsed = urlparse(redirect_match.group(1))
|
|
299
|
+
query = parse_qs(parsed.query)
|
|
300
|
+
code = query.get("code", [""])[0]
|
|
301
|
+
if code:
|
|
302
|
+
await self.exchange_code(code)
|
|
303
|
+
return
|
|
304
|
+
raise DimplexAuthError("Reached 200 response without finding redirect URL")
|
|
305
|
+
raise DimplexAuthError(f"Unexpected HTTP {resp.status} during redirect chain")
|
|
306
|
+
|
|
307
|
+
raise DimplexAuthError("Exceeded maximum redirect hops without capturing auth code")
|
|
308
|
+
|
|
309
|
+
def save_tokens(self, file_path: str) -> None:
|
|
310
|
+
"""Save current tokens to a JSON file."""
|
|
311
|
+
data = {
|
|
312
|
+
"access_token": self._access_token,
|
|
313
|
+
"refresh_token": self._refresh_token,
|
|
314
|
+
"expires_at": self._expires_at,
|
|
315
|
+
}
|
|
316
|
+
with open(file_path, "w") as f:
|
|
317
|
+
json.dump(data, f, indent=2)
|
|
318
|
+
_LOGGER.info("Tokens saved to %s", file_path)
|
|
319
|
+
|
|
320
|
+
@classmethod
|
|
321
|
+
def load_tokens(cls, file_path: str) -> Optional[Dict]:
|
|
322
|
+
"""Load tokens from a JSON file."""
|
|
323
|
+
if not os.path.exists(file_path):
|
|
324
|
+
return None
|
|
325
|
+
try:
|
|
326
|
+
with open(file_path, "r") as f:
|
|
327
|
+
return json.load(f)
|
|
328
|
+
except Exception as e:
|
|
329
|
+
_LOGGER.error("Failed to load tokens from %s: %s", file_path, e)
|
|
330
|
+
return None
|
|
@@ -19,3 +19,4 @@ HEADER_DEVICE_MODEL = "iPhone18,1"
|
|
|
19
19
|
CLIENT_ID = "6c983ca3-506e-4933-8993-0e18e6a24bbd"
|
|
20
20
|
SCOPE = "https://gdhvb2c.onmicrosoft.com/Mobile/read offline_access openid profile"
|
|
21
21
|
REDIRECT_URI = "msal6c983ca3-506e-4933-8993-0e18e6a24bbd://auth/"
|
|
22
|
+
B2C_POLICY = "B2C_1A_DimplexControlSignupSignin"
|
|
@@ -1,304 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import logging
|
|
3
|
-
import os
|
|
4
|
-
import re
|
|
5
|
-
import time
|
|
6
|
-
from typing import Dict, Optional
|
|
7
|
-
from urllib.parse import parse_qs, urlencode, urlparse
|
|
8
|
-
|
|
9
|
-
import aiohttp
|
|
10
|
-
from bs4 import BeautifulSoup
|
|
11
|
-
|
|
12
|
-
from .const import (
|
|
13
|
-
AUTH_URL,
|
|
14
|
-
CLIENT_ID,
|
|
15
|
-
HTTP_OK,
|
|
16
|
-
REDIRECT_URI,
|
|
17
|
-
SCOPE,
|
|
18
|
-
)
|
|
19
|
-
from .exceptions import DimplexAuthError
|
|
20
|
-
|
|
21
|
-
_LOGGER = logging.getLogger(__name__)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
class AuthManager:
|
|
25
|
-
"""Manages authentication for Dimplex Control."""
|
|
26
|
-
|
|
27
|
-
def __init__(self, session: aiohttp.ClientSession, token_data: Optional[Dict] = None):
|
|
28
|
-
"""Initialize the auth manager."""
|
|
29
|
-
self._session = session
|
|
30
|
-
self._access_token: Optional[str] = token_data.get("access_token") if token_data else None
|
|
31
|
-
self._refresh_token: Optional[str] = token_data.get("refresh_token") if token_data else None
|
|
32
|
-
self._expires_at: float = token_data.get("expires_at", 0) if token_data else 0
|
|
33
|
-
|
|
34
|
-
@property
|
|
35
|
-
def is_authenticated(self) -> bool:
|
|
36
|
-
"""Check if we have a valid access token."""
|
|
37
|
-
return self._access_token is not None and time.time() < self._expires_at
|
|
38
|
-
|
|
39
|
-
async def get_access_token(self) -> str:
|
|
40
|
-
"""Get a valid access token, refreshing if necessary."""
|
|
41
|
-
if not self._refresh_token:
|
|
42
|
-
raise DimplexAuthError("No refresh token available. User must authenticate first.")
|
|
43
|
-
|
|
44
|
-
if self.is_authenticated:
|
|
45
|
-
return self._access_token
|
|
46
|
-
|
|
47
|
-
# Token expired or missing, try refresh
|
|
48
|
-
await self.refresh_tokens()
|
|
49
|
-
return self._access_token
|
|
50
|
-
|
|
51
|
-
async def refresh_tokens(self) -> None:
|
|
52
|
-
"""Refresh the access token using the refresh token."""
|
|
53
|
-
_LOGGER.debug("Refreshing access token")
|
|
54
|
-
payload = {
|
|
55
|
-
"client_id": CLIENT_ID,
|
|
56
|
-
"grant_type": "refresh_token",
|
|
57
|
-
"refresh_token": self._refresh_token,
|
|
58
|
-
"scope": SCOPE,
|
|
59
|
-
"client_info": "1",
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
async with self._session.post(f"{AUTH_URL}/token", data=payload) as resp:
|
|
63
|
-
if resp.status != HTTP_OK:
|
|
64
|
-
text = await resp.text()
|
|
65
|
-
_LOGGER.error("Failed to refresh token: %s", text)
|
|
66
|
-
raise DimplexAuthError(f"Failed to refresh token: {resp.status} - {text}")
|
|
67
|
-
|
|
68
|
-
data = await resp.json()
|
|
69
|
-
self._update_tokens(data)
|
|
70
|
-
|
|
71
|
-
def _update_tokens(self, data: Dict) -> None:
|
|
72
|
-
"""Update internal token state from API response."""
|
|
73
|
-
self._access_token = data.get("access_token")
|
|
74
|
-
self._refresh_token = data.get("refresh_token")
|
|
75
|
-
expires_in = data.get("expires_in", 3600)
|
|
76
|
-
self._expires_at = time.time() + expires_in - 60 # Buffer 60s
|
|
77
|
-
|
|
78
|
-
def get_login_url(self) -> str:
|
|
79
|
-
"""Generate the login URL for the user to visit."""
|
|
80
|
-
# Note: This is a simplified URL generation.
|
|
81
|
-
# In a real app, we might need state, nonce, code_challenge (PKCE).
|
|
82
|
-
# Based on logs, iOS app uses standard OAuth2.
|
|
83
|
-
# Constructing a URL for manual copy-paste might be tricky if it strictly requires a custom scheme redirect.
|
|
84
|
-
# But we can try the standard authorize endpoint.
|
|
85
|
-
|
|
86
|
-
params = {
|
|
87
|
-
"client_id": CLIENT_ID,
|
|
88
|
-
"response_type": "code",
|
|
89
|
-
"redirect_uri": REDIRECT_URI,
|
|
90
|
-
"scope": SCOPE,
|
|
91
|
-
"response_mode": "query",
|
|
92
|
-
}
|
|
93
|
-
from urllib.parse import urlencode
|
|
94
|
-
|
|
95
|
-
return f"{AUTH_URL}/authorize?{urlencode(params)}"
|
|
96
|
-
|
|
97
|
-
async def exchange_code(self, code: str) -> None:
|
|
98
|
-
"""Exchange authorization code for tokens."""
|
|
99
|
-
payload = {
|
|
100
|
-
"client_id": CLIENT_ID,
|
|
101
|
-
"grant_type": "authorization_code",
|
|
102
|
-
"code": code,
|
|
103
|
-
"redirect_uri": REDIRECT_URI,
|
|
104
|
-
"scope": SCOPE,
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
_LOGGER.info(f"Exchanging code for tokens at {AUTH_URL}/token")
|
|
108
|
-
client_id = payload["client_id"]
|
|
109
|
-
redirect_uri = payload["redirect_uri"]
|
|
110
|
-
code_preview = code[:10]
|
|
111
|
-
_LOGGER.info(f"Payload: client_id={client_id}, redirect_uri={redirect_uri}, " f"code={code_preview}...")
|
|
112
|
-
|
|
113
|
-
async with self._session.post(f"{AUTH_URL}/token", data=payload) as resp:
|
|
114
|
-
_LOGGER.info(f"Token exchange response status: {resp.status}")
|
|
115
|
-
if resp.status != HTTP_OK:
|
|
116
|
-
text = await resp.text()
|
|
117
|
-
_LOGGER.error(f"Token exchange failed: {text}")
|
|
118
|
-
raise DimplexAuthError(f"Failed to exchange code: {text}")
|
|
119
|
-
|
|
120
|
-
data = await resp.json()
|
|
121
|
-
self._update_tokens(data)
|
|
122
|
-
|
|
123
|
-
async def headless_login(self, email, password) -> None:
|
|
124
|
-
"""Perform a headless login to obtain tokens."""
|
|
125
|
-
# 1. Get the login page
|
|
126
|
-
params = {
|
|
127
|
-
"client_id": CLIENT_ID,
|
|
128
|
-
"response_type": "code",
|
|
129
|
-
"redirect_uri": REDIRECT_URI,
|
|
130
|
-
"scope": SCOPE,
|
|
131
|
-
"response_mode": "query",
|
|
132
|
-
}
|
|
133
|
-
start_url = f"{AUTH_URL}/authorize?{urlencode(params)}"
|
|
134
|
-
_LOGGER.debug(f"Fetching login page: {start_url}")
|
|
135
|
-
|
|
136
|
-
async with self._session.get(start_url) as resp:
|
|
137
|
-
html = await resp.text()
|
|
138
|
-
final_url = str(resp.url)
|
|
139
|
-
|
|
140
|
-
# 2. Extract SETTINGS and CSRF
|
|
141
|
-
match = re.search(r"SETTINGS\s*=\s*({.*?});", html, re.DOTALL | re.MULTILINE)
|
|
142
|
-
if not match:
|
|
143
|
-
raise DimplexAuthError("Could not find SETTINGS in login page")
|
|
144
|
-
|
|
145
|
-
try:
|
|
146
|
-
settings = json.loads(match.group(1))
|
|
147
|
-
except json.JSONDecodeError:
|
|
148
|
-
raise DimplexAuthError("Failed to parse SETTINGS JSON from login page")
|
|
149
|
-
|
|
150
|
-
csrf_token = settings.get("csrf")
|
|
151
|
-
trans_id = settings.get("transId")
|
|
152
|
-
|
|
153
|
-
if not csrf_token or not trans_id:
|
|
154
|
-
raise DimplexAuthError("Missing csrf or transId in login page SETTINGS")
|
|
155
|
-
|
|
156
|
-
# 3. Construct SelfAsserted URL
|
|
157
|
-
if "/oauth2/v2.0/authorize" in final_url:
|
|
158
|
-
base_url = final_url.split("/oauth2/v2.0/authorize")[0]
|
|
159
|
-
else:
|
|
160
|
-
base_url = "https://gdhvb2c.b2clogin.com/gdhvb2c.onmicrosoft.com/B2C_1A_DimplexControlSignupSignin"
|
|
161
|
-
|
|
162
|
-
post_url = f"{base_url}/SelfAsserted"
|
|
163
|
-
|
|
164
|
-
params = {
|
|
165
|
-
"tx": trans_id,
|
|
166
|
-
"p": "B2C_1A_DimplexControlSignupSignin",
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
post_url_with_params = f"{post_url}?{urlencode(params)}"
|
|
170
|
-
|
|
171
|
-
# 4. Submit Credentials
|
|
172
|
-
# Extract hidden fields from the form to ensure we aren't missing anything
|
|
173
|
-
soup = BeautifulSoup(html, "html.parser")
|
|
174
|
-
form = soup.find("form", {"id": "localAccountForm"}) or soup.find("form")
|
|
175
|
-
|
|
176
|
-
form_data = {}
|
|
177
|
-
if form:
|
|
178
|
-
for input_tag in form.find_all("input"):
|
|
179
|
-
name = input_tag.get("name")
|
|
180
|
-
value = input_tag.get("value", "")
|
|
181
|
-
if name:
|
|
182
|
-
form_data[name] = value
|
|
183
|
-
|
|
184
|
-
# Overwrite with credentials
|
|
185
|
-
form_data.update(
|
|
186
|
-
{
|
|
187
|
-
"request_type": "RESPONSE",
|
|
188
|
-
"email": email,
|
|
189
|
-
"password": password,
|
|
190
|
-
}
|
|
191
|
-
)
|
|
192
|
-
|
|
193
|
-
headers = {
|
|
194
|
-
"X-CSRF-TOKEN": csrf_token,
|
|
195
|
-
"X-Requested-With": "XMLHttpRequest",
|
|
196
|
-
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
197
|
-
"Origin": "https://gdhvb2c.b2clogin.com",
|
|
198
|
-
"Referer": final_url,
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
_LOGGER.debug(f"Submitting credentials to {post_url_with_params}")
|
|
202
|
-
# _LOGGER.debug(f"Form data: {form_data}") # Security risk to log password
|
|
203
|
-
|
|
204
|
-
async with self._session.post(post_url_with_params, data=form_data, headers=headers) as resp:
|
|
205
|
-
resp_text = await resp.text()
|
|
206
|
-
_LOGGER.debug(f"Login response status: {resp.status}")
|
|
207
|
-
|
|
208
|
-
try:
|
|
209
|
-
resp_json = json.loads(resp_text)
|
|
210
|
-
_LOGGER.debug(f"Login response JSON: {resp_json}")
|
|
211
|
-
except json.JSONDecodeError:
|
|
212
|
-
_LOGGER.debug(f"Login response Text: {resp_text}")
|
|
213
|
-
raise DimplexAuthError(f"Login response was not valid JSON: {resp_text[:100]}")
|
|
214
|
-
|
|
215
|
-
if resp_json.get("status") != "200":
|
|
216
|
-
status = resp_json.get("status")
|
|
217
|
-
message = resp_json.get("message") or resp_json.get("reason", "Unknown reason")
|
|
218
|
-
raise DimplexAuthError(f"Login failed: {status} - {message}")
|
|
219
|
-
|
|
220
|
-
# 5. Follow the 'Confirmed' step to get the actual code
|
|
221
|
-
# After a successful SelfAsserted, we usually need to make a GET to
|
|
222
|
-
# the 'CombinedSigninAndSignup' or similar endpoint to finalize the
|
|
223
|
-
# flow and get the redirect to our app with the code.
|
|
224
|
-
# Or, sometimes the SelfAsserted response sets a cookie and we just
|
|
225
|
-
# need to hit the authorize endpoint again.
|
|
226
|
-
|
|
227
|
-
# Let's try hitting the original authorize URL again (or the one we were redirected to).
|
|
228
|
-
# Since cookies are in the session, it should now redirect us to the app with the code.
|
|
229
|
-
|
|
230
|
-
_LOGGER.debug("Credentials accepted. Fetching authorize URL again to get code.")
|
|
231
|
-
|
|
232
|
-
# We need to allow redirects to capture the final msauth:// url
|
|
233
|
-
# aiohttp generic session checks redirects.
|
|
234
|
-
# But since the scheme is custom (msal...), aiohttp might throw an error or stop.
|
|
235
|
-
|
|
236
|
-
try:
|
|
237
|
-
async with self._session.get(start_url, allow_redirects=True) as resp:
|
|
238
|
-
# If we are here, it means we didn't crash on custom scheme yet,
|
|
239
|
-
# or we are at a page that directs us.
|
|
240
|
-
# Check history
|
|
241
|
-
pass
|
|
242
|
-
except aiohttp.ClientError:
|
|
243
|
-
# This might happen if the redirect schema is not http/https
|
|
244
|
-
# We can inspect the error or just capture it from the history if possible
|
|
245
|
-
pass
|
|
246
|
-
except Exception:
|
|
247
|
-
# If it tries to redirect to msal..., it might fail if aiohttp doesn't support it.
|
|
248
|
-
# We can disable redirects and follow manually to catch it.
|
|
249
|
-
pass
|
|
250
|
-
|
|
251
|
-
# Manual redirect following to catch custom scheme
|
|
252
|
-
current_url = start_url
|
|
253
|
-
code = None
|
|
254
|
-
|
|
255
|
-
for _ in range(10): # Max redirects
|
|
256
|
-
async with self._session.get(current_url, allow_redirects=False) as resp:
|
|
257
|
-
if resp.status in (302, 303, 301):
|
|
258
|
-
location = resp.headers.get("Location")
|
|
259
|
-
if not location:
|
|
260
|
-
break
|
|
261
|
-
|
|
262
|
-
if location.startswith(REDIRECT_URI) or "code=" in location:
|
|
263
|
-
# Success!
|
|
264
|
-
_LOGGER.debug(f"Found redirect with code: {location}")
|
|
265
|
-
# Extract code
|
|
266
|
-
parsed = urlparse(location)
|
|
267
|
-
query = parse_qs(parsed.query)
|
|
268
|
-
code = query.get("code", [""])[0]
|
|
269
|
-
break
|
|
270
|
-
|
|
271
|
-
current_url = location
|
|
272
|
-
else:
|
|
273
|
-
break
|
|
274
|
-
|
|
275
|
-
if not code:
|
|
276
|
-
raise DimplexAuthError(
|
|
277
|
-
"Failed to obtain auth code after successful login. Redirect URI might have changed."
|
|
278
|
-
)
|
|
279
|
-
|
|
280
|
-
_LOGGER.debug(f"Got code: {code[:10]}...")
|
|
281
|
-
await self.exchange_code(code)
|
|
282
|
-
|
|
283
|
-
def save_tokens(self, file_path: str) -> None:
|
|
284
|
-
"""Save current tokens to a JSON file."""
|
|
285
|
-
data = {
|
|
286
|
-
"access_token": self._access_token,
|
|
287
|
-
"refresh_token": self._refresh_token,
|
|
288
|
-
"expires_at": self._expires_at,
|
|
289
|
-
}
|
|
290
|
-
with open(file_path, "w") as f:
|
|
291
|
-
json.dump(data, f, indent=2)
|
|
292
|
-
_LOGGER.info("Tokens saved to %s", file_path)
|
|
293
|
-
|
|
294
|
-
@classmethod
|
|
295
|
-
def load_tokens(cls, file_path: str) -> Optional[Dict]:
|
|
296
|
-
"""Load tokens from a JSON file."""
|
|
297
|
-
if not os.path.exists(file_path):
|
|
298
|
-
return None
|
|
299
|
-
try:
|
|
300
|
-
with open(file_path, "r") as f:
|
|
301
|
-
return json.load(f)
|
|
302
|
-
except Exception as e:
|
|
303
|
-
_LOGGER.error("Failed to load tokens from %s: %s", file_path, e)
|
|
304
|
-
return None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|