@camstack/addon-import-alexa 0.2.174 → 0.2.175
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.
- package/CHANGELOG.md +7 -0
- package/dist/addon.js +14 -1
- package/dist/addon.mjs +14 -1
- package/package.json +2 -2
- package/python/alexa_gateway.py +38 -4
- package/python/test_alexa_gateway.py +34 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.175] - 2026-11-01
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Alexa cookie login: the login proxy no longer forwards Amazon's upgrade-insecure-requests directive, which stopped the page from completing over http
|
|
15
|
+
- Echo sign-in through a broker now uses the cookies Amazon returns for the token directly, and a failed sign-in names its step
|
|
16
|
+
|
|
10
17
|
## [0.2.174] - 2026-11-01
|
|
11
18
|
|
|
12
19
|
### Changed
|
package/dist/addon.js
CHANGED
|
@@ -133240,6 +133240,7 @@ var require_proxy = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
133240
133240
|
_options.proxyLogLevel === "debug" && _options.logger && _options.logger(`Alexa-Cookie: Proxy-Request: (modified:${modified})${customStringify(proxyReq, null, 2)}`);
|
|
133241
133241
|
}
|
|
133242
133242
|
function onProxyRes(proxyRes, req, res) {
|
|
133243
|
+
stripUpgradeInsecureRequestsHeaders(proxyRes);
|
|
133243
133244
|
const url = req.originalUrl || req.url;
|
|
133244
133245
|
if (url.endsWith(".ico") || url.endsWith(".js") || url.endsWith(".ttf") || url.endsWith(".svg") || url.endsWith(".png") || url.endsWith(".appcache")) return;
|
|
133245
133246
|
if (url.startsWith("/ap/uedata")) return;
|
|
@@ -133332,7 +133333,18 @@ var require_proxy = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
133332
133333
|
callbackListening = null;
|
|
133333
133334
|
});
|
|
133334
133335
|
}
|
|
133336
|
+
function stripUpgradeInsecureRequestsHeaders(proxyRes) {
|
|
133337
|
+
const headers = proxyRes && proxyRes.headers;
|
|
133338
|
+
if (!headers) return;
|
|
133339
|
+
for (const name of ["content-security-policy", "content-security-policy-report-only"]) {
|
|
133340
|
+
if (headers[name] === void 0) continue;
|
|
133341
|
+
const kept = (Array.isArray(headers[name]) ? headers[name] : [headers[name]]).map((v) => String(v).split(";").map((d) => d.trim()).filter((d) => d && !/^upgrade-insecure-requests$/i.test(d)).join("; ")).filter((v) => v);
|
|
133342
|
+
if (kept.length) headers[name] = kept.length === 1 ? kept[0] : kept;
|
|
133343
|
+
else delete headers[name];
|
|
133344
|
+
}
|
|
133345
|
+
}
|
|
133335
133346
|
module.exports.initAmazonProxy = initAmazonProxy;
|
|
133347
|
+
module.exports.stripUpgradeInsecureRequestsHeaders = stripUpgradeInsecureRequestsHeaders;
|
|
133336
133348
|
}));
|
|
133337
133349
|
//#endregion
|
|
133338
133350
|
//#region ../../node_modules/alexa-cookie2/alexa-cookie.js
|
|
@@ -140954,7 +140966,7 @@ var BrokerEcho = class {
|
|
|
140954
140966
|
try {
|
|
140955
140967
|
this.currentDevices = [...parseDevices(await this.deps.worker.request({ op: "devices" }))];
|
|
140956
140968
|
} catch (err) {
|
|
140957
|
-
this.deps.logger.warn("alexa speak: device
|
|
140969
|
+
this.deps.logger.warn("alexa speak: step 3 of 3, device list failed after a good sign-in", {
|
|
140958
140970
|
tags: { brokerId: this.deps.brokerId },
|
|
140959
140971
|
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
140960
140972
|
});
|
|
@@ -141484,6 +141496,7 @@ function createAlexaNotificationProvider(ports) {
|
|
|
141484
141496
|
return found;
|
|
141485
141497
|
},
|
|
141486
141498
|
async discoverTargets(input) {
|
|
141499
|
+
for (const broker of ports.brokers()) if (!broker.ready) ports.logger.warn(`alexa echo discovery: no Echo listed for '${broker.name}', the Amazon account is not signed in for this broker`, { tags: { brokerId: broker.id } });
|
|
141487
141500
|
const named = readString(input.config ?? {}, "brokerId");
|
|
141488
141501
|
if (named.length > 0) {
|
|
141489
141502
|
const owner = ports.brokers().find((broker) => broker.id === named);
|
package/dist/addon.mjs
CHANGED
|
@@ -133238,6 +133238,7 @@ var require_proxy = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
133238
133238
|
_options.proxyLogLevel === "debug" && _options.logger && _options.logger(`Alexa-Cookie: Proxy-Request: (modified:${modified})${customStringify(proxyReq, null, 2)}`);
|
|
133239
133239
|
}
|
|
133240
133240
|
function onProxyRes(proxyRes, req, res) {
|
|
133241
|
+
stripUpgradeInsecureRequestsHeaders(proxyRes);
|
|
133241
133242
|
const url = req.originalUrl || req.url;
|
|
133242
133243
|
if (url.endsWith(".ico") || url.endsWith(".js") || url.endsWith(".ttf") || url.endsWith(".svg") || url.endsWith(".png") || url.endsWith(".appcache")) return;
|
|
133243
133244
|
if (url.startsWith("/ap/uedata")) return;
|
|
@@ -133330,7 +133331,18 @@ var require_proxy = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
133330
133331
|
callbackListening = null;
|
|
133331
133332
|
});
|
|
133332
133333
|
}
|
|
133334
|
+
function stripUpgradeInsecureRequestsHeaders(proxyRes) {
|
|
133335
|
+
const headers = proxyRes && proxyRes.headers;
|
|
133336
|
+
if (!headers) return;
|
|
133337
|
+
for (const name of ["content-security-policy", "content-security-policy-report-only"]) {
|
|
133338
|
+
if (headers[name] === void 0) continue;
|
|
133339
|
+
const kept = (Array.isArray(headers[name]) ? headers[name] : [headers[name]]).map((v) => String(v).split(";").map((d) => d.trim()).filter((d) => d && !/^upgrade-insecure-requests$/i.test(d)).join("; ")).filter((v) => v);
|
|
133340
|
+
if (kept.length) headers[name] = kept.length === 1 ? kept[0] : kept;
|
|
133341
|
+
else delete headers[name];
|
|
133342
|
+
}
|
|
133343
|
+
}
|
|
133333
133344
|
module.exports.initAmazonProxy = initAmazonProxy;
|
|
133345
|
+
module.exports.stripUpgradeInsecureRequestsHeaders = stripUpgradeInsecureRequestsHeaders;
|
|
133334
133346
|
}));
|
|
133335
133347
|
//#endregion
|
|
133336
133348
|
//#region ../../node_modules/alexa-cookie2/alexa-cookie.js
|
|
@@ -140952,7 +140964,7 @@ var BrokerEcho = class {
|
|
|
140952
140964
|
try {
|
|
140953
140965
|
this.currentDevices = [...parseDevices(await this.deps.worker.request({ op: "devices" }))];
|
|
140954
140966
|
} catch (err) {
|
|
140955
|
-
this.deps.logger.warn("alexa speak: device
|
|
140967
|
+
this.deps.logger.warn("alexa speak: step 3 of 3, device list failed after a good sign-in", {
|
|
140956
140968
|
tags: { brokerId: this.deps.brokerId },
|
|
140957
140969
|
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
140958
140970
|
});
|
|
@@ -141482,6 +141494,7 @@ function createAlexaNotificationProvider(ports) {
|
|
|
141482
141494
|
return found;
|
|
141483
141495
|
},
|
|
141484
141496
|
async discoverTargets(input) {
|
|
141497
|
+
for (const broker of ports.brokers()) if (!broker.ready) ports.logger.warn(`alexa echo discovery: no Echo listed for '${broker.name}', the Amazon account is not signed in for this broker`, { tags: { brokerId: broker.id } });
|
|
141485
141498
|
const named = readString(input.config ?? {}, "brokerId");
|
|
141486
141499
|
if (named.length > 0) {
|
|
141487
141500
|
const owner = ports.brokers().find((broker) => broker.id === named);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/addon-import-alexa",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.175",
|
|
4
4
|
"description": "Alexa device-import provider for CamStack — imports the smart-home devices in a user's Alexa account via the unofficial alexa-remote2 cookie/token client (the inverse of the Alexa exporter)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"camstack",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
}
|
|
69
69
|
],
|
|
70
70
|
"requires": {
|
|
71
|
-
"server": ">=1.2.
|
|
71
|
+
"server": ">=1.2.723"
|
|
72
72
|
}
|
|
73
73
|
},
|
|
74
74
|
"files": [
|
package/python/alexa_gateway.py
CHANGED
|
@@ -119,14 +119,32 @@ class AlexaPyGateway:
|
|
|
119
119
|
bare_domain(domain), label, "", session_output(session_path), False, oauth=oauth
|
|
120
120
|
)
|
|
121
121
|
self._login = login
|
|
122
|
+
cookies: dict = {}
|
|
122
123
|
if refresh_token != "":
|
|
123
124
|
exchanged = await _exchange_refresh_token(login, device_app_name)
|
|
124
125
|
if not exchanged:
|
|
125
|
-
return {
|
|
126
|
+
return {
|
|
127
|
+
"status": "failed",
|
|
128
|
+
"error": "step 1 of 3, token exchange: Amazon refused the broker's token",
|
|
129
|
+
}
|
|
126
130
|
await login.save_cookiefile()
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
131
|
+
# The exchange put its cookies in the live session; use them as they are. Reading
|
|
132
|
+
# them back through the cookie file filters them by host and can come back empty.
|
|
133
|
+
cookies = _session_cookies(login)
|
|
134
|
+
if not cookies:
|
|
135
|
+
cookies = await login.load_cookie() or {}
|
|
136
|
+
if not cookies:
|
|
137
|
+
return {
|
|
138
|
+
"status": "failed",
|
|
139
|
+
"error": "step 2 of 3, cookies: the token exchange was accepted but returned no cookies",
|
|
140
|
+
}
|
|
141
|
+
else:
|
|
142
|
+
cookies = await login.load_cookie() or {}
|
|
143
|
+
if not cookies:
|
|
144
|
+
return {
|
|
145
|
+
"status": "failed",
|
|
146
|
+
"error": "step 2 of 3, cookies: no stored Amazon session and no broker token to start from",
|
|
147
|
+
}
|
|
130
148
|
await login.login(cookies=cookies)
|
|
131
149
|
await self._persist_session()
|
|
132
150
|
return self._status_result()
|
|
@@ -213,6 +231,22 @@ def _import_alexapy():
|
|
|
213
231
|
return AlexaLogin, AlexaAPI
|
|
214
232
|
|
|
215
233
|
|
|
234
|
+
def _session_cookies(login) -> dict:
|
|
235
|
+
"""Every cookie the login's live session holds, by name. Values only leave this process
|
|
236
|
+
inside the library; nothing here logs them."""
|
|
237
|
+
session = getattr(login, "_session", None)
|
|
238
|
+
jar = getattr(session, "cookie_jar", None)
|
|
239
|
+
found: dict = {}
|
|
240
|
+
if jar is None:
|
|
241
|
+
return found
|
|
242
|
+
for morsel in jar:
|
|
243
|
+
name = getattr(morsel, "key", None)
|
|
244
|
+
value = getattr(morsel, "value", None)
|
|
245
|
+
if isinstance(name, str) and isinstance(value, str) and name != "":
|
|
246
|
+
found[name] = value.strip('"')
|
|
247
|
+
return found
|
|
248
|
+
|
|
249
|
+
|
|
216
250
|
async def _exchange_refresh_token(login, device_app_name: str) -> bool:
|
|
217
251
|
"""Trade the broker's refresh token for cookies, under the name that token was registered with."""
|
|
218
252
|
import alexapy.alexalogin as module
|
|
@@ -19,7 +19,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
19
19
|
from alexa_gateway import AlexaPyGateway, bare_domain, normalize_device, session_output
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
def install_fake_alexapy(
|
|
22
|
+
def install_fake_alexapy(
|
|
23
|
+
exchange_ok: bool = True, stored_cookies: dict | None = None, file_readback: bool = True
|
|
24
|
+
):
|
|
23
25
|
"""Put a fake `alexapy` (and `alexapy.alexalogin`) in sys.modules; return the call log."""
|
|
24
26
|
calls: dict = {"exchange_app_names": [], "logins": [], "saved": 0}
|
|
25
27
|
module = types.ModuleType("alexapy.alexalogin")
|
|
@@ -34,10 +36,13 @@ def install_fake_alexapy(exchange_ok: bool = True, stored_cookies: dict | None =
|
|
|
34
36
|
calls["jar_path"] = outputpath(".storage/alexa_media.x.pickle")
|
|
35
37
|
self.status = {"login_successful": True}
|
|
36
38
|
self.refresh_token = (oauth or {}).get("refresh_token")
|
|
39
|
+
self._session = types.SimpleNamespace(cookie_jar=[])
|
|
37
40
|
|
|
38
41
|
async def exchange_token_for_cookies(self):
|
|
39
42
|
calls["exchange_app_names"].append(module.APP_NAME)
|
|
40
43
|
calls["exchanged_token"] = self.refresh_token
|
|
44
|
+
if exchange_ok:
|
|
45
|
+
self._session.cookie_jar.append(types.SimpleNamespace(key="session-id", value="1"))
|
|
41
46
|
return exchange_ok
|
|
42
47
|
|
|
43
48
|
async def save_cookiefile(self):
|
|
@@ -45,7 +50,9 @@ def install_fake_alexapy(exchange_ok: bool = True, stored_cookies: dict | None =
|
|
|
45
50
|
Path(calls["jar_path"]).write_bytes(b"jar")
|
|
46
51
|
|
|
47
52
|
async def load_cookie(self):
|
|
48
|
-
|
|
53
|
+
if stored_cookies:
|
|
54
|
+
return stored_cookies
|
|
55
|
+
return {"session-id": "1"} if (exchange_ok and file_readback) else {}
|
|
49
56
|
|
|
50
57
|
async def login(self, cookies=None, data=None):
|
|
51
58
|
calls["logins"].append(cookies)
|
|
@@ -68,6 +75,10 @@ def run(coro):
|
|
|
68
75
|
return asyncio.run(coro)
|
|
69
76
|
|
|
70
77
|
|
|
78
|
+
async def _accept_without_cookies(self):
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
|
|
71
82
|
class GatewayTests(unittest.TestCase):
|
|
72
83
|
def tearDown(self) -> None:
|
|
73
84
|
remove_fake_alexapy()
|
|
@@ -113,6 +124,27 @@ class GatewayTests(unittest.TestCase):
|
|
|
113
124
|
self.assertEqual(calls["logins"], [])
|
|
114
125
|
self.assertEqual(module.APP_NAME, "Alexa Media Player")
|
|
115
126
|
|
|
127
|
+
def test_cookies_from_the_exchange_are_used_when_the_file_reads_back_empty(self) -> None:
|
|
128
|
+
calls, _ = install_fake_alexapy(file_readback=False)
|
|
129
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
130
|
+
result = run(
|
|
131
|
+
AlexaPyGateway().login_broker("alexa_002", "t", "", "amazon.com", "NA", str(Path(directory) / "s"))
|
|
132
|
+
)
|
|
133
|
+
self.assertEqual(result, {"status": "ok"})
|
|
134
|
+
self.assertEqual(calls["logins"], [{"session-id": "1"}])
|
|
135
|
+
|
|
136
|
+
def test_an_exchange_that_yields_no_cookies_names_its_step(self) -> None:
|
|
137
|
+
calls, _ = install_fake_alexapy(file_readback=False)
|
|
138
|
+
sys.modules["alexapy"].AlexaLogin.exchange_token_for_cookies = _accept_without_cookies
|
|
139
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
140
|
+
result = run(
|
|
141
|
+
AlexaPyGateway().login_broker("alexa_002", "t", "", "amazon.com", "NA", str(Path(directory) / "s"))
|
|
142
|
+
)
|
|
143
|
+
self.assertEqual(result["status"], "failed")
|
|
144
|
+
self.assertIn("cookies", result["error"])
|
|
145
|
+
self.assertIn("exchange was accepted", result["error"])
|
|
146
|
+
self.assertEqual(calls["logins"], [])
|
|
147
|
+
|
|
116
148
|
def test_with_no_token_only_the_stored_jar_is_tried(self) -> None:
|
|
117
149
|
calls, _ = install_fake_alexapy(stored_cookies={"session-id": "1"})
|
|
118
150
|
with tempfile.TemporaryDirectory() as directory:
|