dimplex-controller 0.2.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dimplex-controller
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Python client for Dimplex heating controllers (GDHV IoT)
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -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,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "dimplex-controller"
3
- version = "0.2.1"
3
+ version = "0.3.0"
4
4
  description = "Python client for Dimplex heating controllers (GDHV IoT)"
5
5
  authors = ["Kieran Roper"]
6
6
  license = "MIT"
@@ -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