aioamazondevices 1.0.0__tar.gz → 1.1.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.1.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
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "aioamazondevices"
3
- version = "1.0.0"
3
+ version = "1.1.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.1.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(
342
+ _, resp = await self._session_request(
343
+ "POST",
338
344
  register_url,
339
- json=body,
340
- headers=headers,
345
+ input_data=body,
346
+ json_data=True,
341
347
  )
342
348
  resp_json = resp.json()
343
349
 
@@ -412,7 +418,9 @@ class AmazonEchoApi:
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)
@@ -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(
@@ -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
 
@@ -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
- )