aioamazondevices 1.0.0__tar.gz → 1.2.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,8 +1,7 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.3
2
2
  Name: aioamazondevices
3
- Version: 1.0.0
3
+ Version: 1.2.0
4
4
  Summary: Python library to control Amazon devices
5
- Home-page: https://github.com/chemelli74/aioamazondevices
6
5
  License: Apache-2.0
7
6
  Author: Simone Chemelli
8
7
  Author-email: simone.chemelli@gmail.com
@@ -70,12 +69,22 @@ Install this via pip (or your favourite package manager):
70
69
 
71
70
  `pip install aioamazondevices`
72
71
 
73
- ## Usage
72
+ ## Test
74
73
 
75
- Start by importing it:
74
+ Test the library with:
76
75
 
77
- ```python
78
- import aioamazondevices
76
+ `python library_test.py`
77
+
78
+ The script accept command line arguments or a library_test.json config file:
79
+
80
+ ```json
81
+ {
82
+ "country": "IT",
83
+ "email": "<my_address@gmail.com>",
84
+ "password": "<my_password>",
85
+ "login_data_file": "out/login_data.json",
86
+ "save_raw_data": "True"
87
+ }
79
88
  ```
80
89
 
81
90
  ## Contributors ✨
@@ -41,12 +41,22 @@ Install this via pip (or your favourite package manager):
41
41
 
42
42
  `pip install aioamazondevices`
43
43
 
44
- ## Usage
44
+ ## Test
45
45
 
46
- Start by importing it:
46
+ Test the library with:
47
47
 
48
- ```python
49
- import aioamazondevices
48
+ `python library_test.py`
49
+
50
+ The script accept command line arguments or a library_test.json config file:
51
+
52
+ ```json
53
+ {
54
+ "country": "IT",
55
+ "email": "<my_address@gmail.com>",
56
+ "password": "<my_password>",
57
+ "login_data_file": "out/login_data.json",
58
+ "save_raw_data": "True"
59
+ }
50
60
  ```
51
61
 
52
62
  ## Contributors ✨
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "aioamazondevices"
3
- version = "1.0.0"
3
+ version = "1.2.0"
4
4
  description = "Python library to control Amazon devices"
5
5
  authors = ["Simone Chemelli <simone.chemelli@gmail.com>"]
6
6
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  """aioamazondevices library."""
2
2
 
3
- __version__ = "1.0.0"
3
+ __version__ = "1.2.0"
4
4
 
5
5
 
6
6
  from .api import AmazonDevice, AmazonEchoApi
@@ -14,9 +14,8 @@ from urllib.parse import parse_qs, urlencode
14
14
 
15
15
  import orjson
16
16
  from bs4 import BeautifulSoup, Tag
17
- from httpx import URL, AsyncClient, Auth, Response
17
+ from httpx import URL, AsyncClient, Response
18
18
 
19
- from .auth import Authenticator
20
19
  from .const import (
21
20
  _LOGGER,
22
21
  AMAZON_APP_BUNDLE_ID,
@@ -98,7 +97,7 @@ class AmazonEchoApi:
98
97
  if not self._login_stored_data:
99
98
  return {}
100
99
 
101
- return cast(dict, self._login_stored_data["website_cookies"])
100
+ return cast("dict", self._login_stored_data["website_cookies"])
102
101
 
103
102
  def _serial_number(self) -> str:
104
103
  """Get or calculate device serial number."""
@@ -109,7 +108,7 @@ class AmazonEchoApi:
109
108
 
110
109
  _LOGGER.debug("Found previous login data, loading serial number")
111
110
  return cast(
112
- str,
111
+ "str",
113
112
  self._login_stored_data["device_info"]["device_serial_number"],
114
113
  )
115
114
 
@@ -207,16 +206,15 @@ class AmazonEchoApi:
207
206
  parsed_url = parse_qs(url.query.decode())
208
207
  return parsed_url["openid.oa2.authorization_code"][0]
209
208
 
210
- def _client_session(self, auth: Auth | None = None) -> None:
211
- """Create httpx ClientSession."""
209
+ def _client_session(self) -> None:
210
+ """Create HTTP client session."""
212
211
  if not hasattr(self, "session") or self.session.is_closed:
213
- _LOGGER.debug("Creating HTTP ClientSession")
212
+ _LOGGER.debug("Creating HTTP session (httpx)")
214
213
  self.session = AsyncClient(
215
214
  base_url=f"https://www.amazon.{self._domain}",
216
215
  headers=DEFAULT_HEADERS,
217
216
  cookies=self._cookies,
218
217
  follow_redirects=True,
219
- auth=auth,
220
218
  )
221
219
 
222
220
  async def _session_request(
@@ -224,19 +222,28 @@ class AmazonEchoApi:
224
222
  method: str,
225
223
  url: str,
226
224
  input_data: dict[str, Any] | None = None,
225
+ json_data: bool = False,
227
226
  ) -> tuple[BeautifulSoup, Response]:
228
227
  """Return request response context data."""
229
- _LOGGER.debug("%s request: %s with payload %s", method, url, input_data)
228
+ _LOGGER.debug(
229
+ "%s request: %s with payload %s [json=%s]",
230
+ method,
231
+ url,
232
+ input_data,
233
+ json_data,
234
+ )
230
235
  resp = await self.session.request(
231
236
  method,
232
237
  url,
233
- data=input_data,
238
+ data=input_data if not json_data else orjson.dumps(input_data),
234
239
  cookies=self._website_cookies,
240
+ headers={"Content-Type": "application/json"} if json_data else None,
235
241
  )
236
242
  content_type: str = resp.headers.get("Content-Type", "")
237
243
  _LOGGER.debug(
238
- "Response %s with content type: %s",
244
+ "Response %s for url %s with content type: %s",
239
245
  resp.status_code,
246
+ url,
240
247
  content_type,
241
248
  )
242
249
 
@@ -331,13 +338,12 @@ class AmazonEchoApi:
331
338
  ],
332
339
  }
333
340
 
334
- headers = {"Content-Type": "application/json"}
335
-
336
341
  register_url = f"https://api.amazon.{self._domain}/auth/register"
337
- resp = await self.session.post(
338
- register_url,
339
- json=body,
340
- headers=headers,
342
+ _, resp = await self._session_request(
343
+ method="POST",
344
+ url=register_url,
345
+ input_data=body,
346
+ json_data=True,
341
347
  )
342
348
  resp_json = resp.json()
343
349
 
@@ -398,7 +404,7 @@ class AmazonEchoApi:
398
404
  _LOGGER.debug("Build oauth URL")
399
405
  login_url = self._build_oauth_url(code_verifier, client_id)
400
406
 
401
- login_soup, _ = await self._session_request("GET", login_url)
407
+ login_soup, _ = await self._session_request(method="GET", url=login_url)
402
408
  login_method, login_url = self._get_request_from_soup(login_soup)
403
409
  login_inputs = self._get_inputs_from_soup(login_soup)
404
410
  login_inputs["email"] = self._login_email
@@ -406,13 +412,15 @@ class AmazonEchoApi:
406
412
 
407
413
  _LOGGER.debug("Register at %s", login_url)
408
414
  login_soup, _ = await self._session_request(
409
- login_method,
410
- login_url,
411
- login_inputs,
415
+ method=login_method,
416
+ url=login_url,
417
+ input_data=login_inputs,
412
418
  )
413
419
 
414
420
  if not login_soup.find("input", id="auth-mfa-otpcode"):
415
- _LOGGER.debug('Cannot find "auth-mfa-otpcode" in html source')
421
+ _LOGGER.debug(
422
+ 'Cannot find "auth-mfa-otpcode" in html source [%s]', login_url
423
+ )
416
424
  raise CannotAuthenticate
417
425
 
418
426
  login_method, login_url = self._get_request_from_soup(login_soup)
@@ -423,9 +431,9 @@ class AmazonEchoApi:
423
431
  login_inputs["rememberDevice"] = "false"
424
432
 
425
433
  login_soup, login_resp = await self._session_request(
426
- login_method,
427
- login_url,
428
- login_inputs,
434
+ method=login_method,
435
+ url=login_url,
436
+ input_data=login_inputs,
429
437
  )
430
438
 
431
439
  authcode_url = None
@@ -457,7 +465,7 @@ class AmazonEchoApi:
457
465
  if not self._login_stored_data:
458
466
  _LOGGER.debug(
459
467
  "Cannot find previous login data,\
460
- use login_interactive() method instead",
468
+ use login_mode_interactive() method instead",
461
469
  )
462
470
  raise WrongMethod
463
471
 
@@ -466,18 +474,14 @@ class AmazonEchoApi:
466
474
  self._login_email,
467
475
  )
468
476
 
469
- auth = Authenticator.from_dict(
470
- self._login_stored_data,
471
- self._domain,
472
- )
473
- self._client_session(auth)
477
+ self._client_session()
474
478
 
475
479
  return self._login_stored_data
476
480
 
477
481
  async def close(self) -> None:
478
- """Close httpx session."""
482
+ """Close http client session."""
479
483
  if hasattr(self, "session"):
480
- _LOGGER.debug("Closing httpx session")
484
+ _LOGGER.debug("Closing HTTP session (httpx)")
481
485
  await self.session.aclose()
482
486
 
483
487
  async def get_devices_data(
@@ -487,8 +491,8 @@ class AmazonEchoApi:
487
491
  devices: dict[str, Any] = {}
488
492
  for key in URI_QUERIES:
489
493
  _, raw_resp = await self._session_request(
490
- "GET",
491
- f"https://alexa.amazon.{self._domain}{URI_QUERIES[key]}",
494
+ method="GET",
495
+ url=f"https://alexa.amazon.{self._domain}{URI_QUERIES[key]}",
492
496
  )
493
497
  _LOGGER.debug("Response URL: %s", raw_resp.url)
494
498
  response_code = raw_resp.status_code
@@ -496,7 +500,7 @@ class AmazonEchoApi:
496
500
 
497
501
  response_data = raw_resp.text
498
502
  _LOGGER.debug("Response data: |%s|", response_data)
499
- json_data = {} if len(raw_resp.content) == 0 else raw_resp.json()
503
+ json_data = {} if len(response_data) == 0 else raw_resp.json()
500
504
 
501
505
  _LOGGER.debug("JSON data: |%s|", json_data)
502
506
 
@@ -532,3 +536,25 @@ class AmazonEchoApi:
532
536
  )
533
537
 
534
538
  return final_devices_list
539
+
540
+ async def auth_check_status(self) -> bool:
541
+ """Check AUTH status."""
542
+ _, raw_resp = await self._session_request(
543
+ method="GET",
544
+ url=f"https://alexa.amazon.{self._domain}/api/bootstrap?version=0",
545
+ )
546
+ if raw_resp.status_code != HTTPStatus.OK:
547
+ _LOGGER.debug(
548
+ "Session not authenticated: reply error %s",
549
+ raw_resp.status_code,
550
+ )
551
+ return False
552
+
553
+ resp_json = raw_resp.json()
554
+ if not (authentication := resp_json.get("authentication")):
555
+ _LOGGER.debug('Session not authenticated: reply missing "authentication"')
556
+ return False
557
+
558
+ authenticated = authentication.get("authenticated")
559
+ _LOGGER.debug("Session authenticated: %s", authenticated)
560
+ return bool(authenticated)
@@ -1,315 +0,0 @@
1
- """Custom authentication module for httpx."""
2
-
3
- import base64
4
- from collections.abc import Generator
5
- from datetime import UTC, datetime, timedelta
6
- from typing import (
7
- Any,
8
- cast,
9
- )
10
-
11
- import httpx
12
- from httpx import Cookies
13
- from pyasn1.codec.der import decoder
14
- from pyasn1.type import namedtype, univ
15
- from rsa import PrivateKey, pkcs1
16
-
17
- from .const import _LOGGER, AMAZON_APP_NAME, AMAZON_APP_VERSION
18
- from .exceptions import (
19
- AuthFlowError,
20
- AuthMissingAccessToken,
21
- AuthMissingRefreshToken,
22
- AuthMissingSigningData,
23
- AuthMissingTimestamp,
24
- AuthMissingWebsiteCookies,
25
- )
26
-
27
-
28
- def base64_der_to_pkcs1(base64_key: str) -> str:
29
- """Convert DER private key to PEM format."""
30
-
31
- class PrivateKeyAlgorithm(univ.Sequence): # type: ignore[misc]
32
- component_type = namedtype.NamedTypes(
33
- namedtype.NamedType("algorithm", univ.ObjectIdentifier()),
34
- namedtype.NamedType("parameters", univ.Any()),
35
- )
36
-
37
- class PrivateKeyInfo(univ.Sequence): # type: ignore[misc]
38
- component_type = namedtype.NamedTypes(
39
- namedtype.NamedType("version", univ.Integer()),
40
- namedtype.NamedType("pkalgo", PrivateKeyAlgorithm()),
41
- namedtype.NamedType("key", univ.OctetString()),
42
- )
43
-
44
- encoded_key = base64.b64decode(base64_key)
45
- (key_info, _) = decoder.decode(encoded_key, asn1Spec=PrivateKeyInfo())
46
- key_octet_string = key_info.components[2]
47
- key = PrivateKey.load_pkcs1(key_octet_string, format="DER")
48
- return cast(str, key.save_pkcs1().decode("utf-8"))
49
-
50
-
51
- def refresh_access_token(
52
- refresh_token: str,
53
- domain: str,
54
- ) -> dict[str, Any]:
55
- """Refresh an access token."""
56
- body = {
57
- "app_name": AMAZON_APP_NAME,
58
- "app_version": AMAZON_APP_VERSION,
59
- "source_token": refresh_token,
60
- "requested_token_type": "access_token",
61
- "source_token_type": "refresh_token",
62
- }
63
-
64
- resp = httpx.post(f"https://api.amazon.{domain}/auth/token", data=body)
65
- resp.raise_for_status()
66
- resp_dict = resp.json()
67
-
68
- expires_in_sec = int(resp_dict["expires_in"])
69
- expires = (datetime.now(UTC) + timedelta(seconds=expires_in_sec)).timestamp()
70
-
71
- return {"access_token": resp_dict["access_token"], "expires": expires}
72
-
73
-
74
- def refresh_website_cookies(
75
- refresh_token: str,
76
- domain: str,
77
- ) -> dict[str, str]:
78
- """Fetch website cookies for a specific domain."""
79
- url = f"https://www.amazon.{domain}/ap/exchangetoken/cookies"
80
-
81
- body = {
82
- "app_name": AMAZON_APP_NAME,
83
- "app_version": AMAZON_APP_VERSION,
84
- "source_token": refresh_token,
85
- "requested_token_type": "auth_cookies",
86
- "source_token_type": "refresh_token",
87
- "domain": f".amazon.{domain}",
88
- }
89
-
90
- resp = httpx.post(url, data=body)
91
- resp.raise_for_status()
92
- resp_dict = resp.json()
93
-
94
- raw_cookies = resp_dict["response"]["tokens"]["cookies"]
95
- website_cookies = {}
96
- for domain_cookies in raw_cookies:
97
- for cookie in raw_cookies[domain_cookies]:
98
- website_cookies[cookie["Name"]] = cookie["Value"].replace(r'"', r"")
99
-
100
- return website_cookies
101
-
102
-
103
- def user_profile(access_token: str, domain: str) -> dict[str, Any]:
104
- """Return Amazon user profile from Amazon."""
105
- headers = {"Authorization": f"Bearer {access_token}"}
106
-
107
- resp = httpx.get(f"https://api.amazon.{domain}/user/profile", headers=headers)
108
- resp.raise_for_status()
109
- profile: dict[str, Any] = resp.json()
110
-
111
- if "user_id" not in profile:
112
- raise ValueError("Malformed user profile response.")
113
-
114
- return profile
115
-
116
-
117
- def sign_request(
118
- method: str,
119
- path: str,
120
- body: bytes,
121
- adp_token: str,
122
- private_key: str,
123
- ) -> dict[str, str]:
124
- """Create signed headers for http requests."""
125
- date = datetime.now(UTC).isoformat("T") + "Z"
126
- str_body = body.decode("utf-8")
127
-
128
- data = f"{method}\n{path}\n{date}\n{str_body}\n{adp_token}"
129
-
130
- key = PrivateKey.load_pkcs1(private_key.encode("utf-8"))
131
- cipher = pkcs1.sign(data.encode(), key, "SHA-256")
132
- signed_encoded = base64.b64encode(cipher)
133
-
134
- signature = f"{signed_encoded.decode()}:{date}"
135
-
136
- return {
137
- "x-adp-token": adp_token,
138
- "x-adp-alg": "SHA256withRSA:1.0",
139
- "x-adp-signature": signature,
140
- }
141
-
142
-
143
- class Authenticator(httpx.Auth): # type: ignore[misc]
144
- """Amazon Authenticator class."""
145
-
146
- access_token: str | None = None
147
- activation_bytes: str | None = None
148
- adp_token: str | None = None
149
- customer_info: dict[str, Any] | None = None
150
- device_info: dict[str, Any] | None = None
151
- device_private_key: str | None = None
152
- expires: float | None = None
153
- domain: str
154
- refresh_token: str | None = None
155
- store_authentication_cookie: dict[str, Any] | None = None
156
- website_cookies: dict[str, Any] | None = None
157
- requires_request_body: bool = True
158
- _forbid_new_attrs: bool = True
159
- _apply_test_convert: bool = True
160
-
161
- def update_attrs(self, **kwargs: Any) -> None: # noqa: ANN401
162
- """Update attributes from dict."""
163
- for attr, value in kwargs.items():
164
- setattr(self, attr, value)
165
-
166
- @classmethod
167
- def from_dict(
168
- cls,
169
- data: dict[str, Any],
170
- domain: str,
171
- ) -> "Authenticator":
172
- """Instantiate an Authenticator from authentication dictionary."""
173
- auth = cls()
174
-
175
- auth.domain = domain
176
-
177
- if "login_cookies" in data:
178
- auth.website_cookies = data.pop("login_cookies")
179
-
180
- if data["device_private_key"].startswith("MII"):
181
- pk_der = data["device_private_key"]
182
- data["device_private_key"] = base64_der_to_pkcs1(pk_der)
183
-
184
- auth.update_attrs(**data)
185
-
186
- _LOGGER.info("load data from dictionary for domain %s", auth.domain)
187
-
188
- return auth
189
-
190
- def auth_flow(
191
- self,
192
- request: httpx.Request,
193
- ) -> Generator[httpx.Request, httpx.Response, None]:
194
- """Auth flow to be executed on every request by :mod:`httpx`."""
195
- available_modes = self.available_auth_modes
196
-
197
- _LOGGER.debug("Auth flow modes: %s", available_modes)
198
- if "signing" in available_modes:
199
- self._apply_signing_auth_flow(request)
200
- elif "bearer" in available_modes:
201
- self._apply_bearer_auth_flow(request)
202
- else:
203
- message = "signing or bearer auth flow are not available."
204
- _LOGGER.critical(message)
205
- raise AuthFlowError(message)
206
-
207
- yield request
208
-
209
- def _apply_signing_auth_flow(self, request: httpx.Request) -> None:
210
- if self.adp_token is None or self.device_private_key is None:
211
- raise AuthMissingSigningData
212
-
213
- headers = sign_request(
214
- method=request.method,
215
- path=request.url.raw_path.decode(),
216
- body=request.content,
217
- adp_token=self.adp_token,
218
- private_key=self.device_private_key,
219
- )
220
-
221
- request.headers.update(headers)
222
- _LOGGER.info("signing auth flow applied to request")
223
-
224
- def _apply_bearer_auth_flow(self, request: httpx.Request) -> None:
225
- if self.access_token_expired:
226
- self.refresh_access_token()
227
-
228
- if self.access_token is None:
229
- raise AuthMissingAccessToken
230
-
231
- headers = {"Authorization": "Bearer " + self.access_token, "client-id": "0"}
232
- request.headers.update(headers)
233
- _LOGGER.info("bearer auth flow applied to request")
234
-
235
- def _apply_cookies_auth_flow(self, request: httpx.Request) -> None:
236
- if self.website_cookies is None:
237
- raise AuthMissingWebsiteCookies
238
- cookies = self.website_cookies.copy()
239
-
240
- Cookies(cookies).set_cookie_header(request)
241
- _LOGGER.info("cookies auth flow applied to request")
242
-
243
- @property
244
- def available_auth_modes(self) -> list[str]:
245
- """List available authentication modes."""
246
- available_modes = []
247
-
248
- if self.adp_token and self.device_private_key:
249
- available_modes.append("signing")
250
-
251
- if self.access_token:
252
- if self.access_token_expired and not self.refresh_token:
253
- pass
254
- else:
255
- available_modes.append("bearer")
256
-
257
- if self.website_cookies:
258
- available_modes.append("cookies")
259
-
260
- return available_modes
261
-
262
- def refresh_access_token(self, force: bool = False) -> None:
263
- """Refresh access token."""
264
- if force or self.access_token_expired:
265
- if self.refresh_token is None:
266
- message = "No refresh token found. Can't refresh access token."
267
- _LOGGER.critical(message)
268
- raise AuthMissingRefreshToken(message)
269
-
270
- refresh_data = refresh_access_token(
271
- refresh_token=self.refresh_token,
272
- domain=self.domain,
273
- )
274
-
275
- self.update_attrs(**refresh_data)
276
- else:
277
- _LOGGER.info(
278
- "Access Token not expired. No refresh necessary. "
279
- "To force refresh please use force=True",
280
- )
281
-
282
- def set_website_cookies_for_country(self) -> None:
283
- """Set website cookies for country."""
284
- if self.refresh_token is None:
285
- raise AuthMissingRefreshToken
286
-
287
- self.website_cookies = refresh_website_cookies(
288
- self.refresh_token,
289
- self.domain,
290
- )
291
-
292
- def user_profile(self) -> dict[str, Any]:
293
- """Get user profile."""
294
- if self.access_token is None:
295
- raise AuthMissingAccessToken
296
-
297
- return user_profile(access_token=self.access_token, domain=self.domain)
298
-
299
- @property
300
- def access_token_expires(self) -> timedelta:
301
- """Time to access token expiration."""
302
- if self.expires is None:
303
- raise AuthMissingTimestamp
304
- return datetime.fromtimestamp(self.expires, UTC) - datetime.now(
305
- UTC,
306
- )
307
-
308
- @property
309
- def access_token_expired(self) -> bool:
310
- """Return True if access token is expired."""
311
- if self.expires is None:
312
- raise AuthMissingTimestamp
313
- return datetime.fromtimestamp(self.expires, UTC) <= datetime.now(
314
- UTC,
315
- )