homebridge-melcloud-control 4.3.11-beta.19 → 4.3.11-beta.20
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/package.json +1 -1
- package/src/melcloudhomeauth.js +205 -301
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"displayName": "MELCloud Control",
|
|
3
3
|
"name": "homebridge-melcloud-control",
|
|
4
|
-
"version": "4.3.11-beta.
|
|
4
|
+
"version": "4.3.11-beta.20",
|
|
5
5
|
"description": "Homebridge plugin to control Mitsubishi Air Conditioner, Heat Pump and Energy Recovery Ventilation.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "grzegorz914",
|
package/src/melcloudhomeauth.js
CHANGED
|
@@ -3,322 +3,226 @@ import { wrapper } from "axios-cookiejar-support";
|
|
|
3
3
|
import { CookieJar } from "tough-cookie";
|
|
4
4
|
import { JSDOM } from "jsdom";
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
* MELCloud Home client (login via AWS Cognito OAuth).
|
|
8
|
-
*
|
|
9
|
-
* Notes:
|
|
10
|
-
* - MELCloud Home uses cookie/session-based auth after the Cognito OAuth redirect chain.
|
|
11
|
-
* - There is not necessarily a single "Bearer token" like classic MELCloud; instead the client
|
|
12
|
-
* keeps cookies and must send x-csrf header (value "1") + referer for API calls.
|
|
13
|
-
*
|
|
14
|
-
* Use:
|
|
15
|
-
* const client = new MelcloudHomeClient({ debug: true });
|
|
16
|
-
* await client.login(email, password);
|
|
17
|
-
* const ctx = await client.getUserContext();
|
|
18
|
-
*/
|
|
19
|
-
|
|
6
|
+
// Constants
|
|
20
7
|
const USER_AGENT =
|
|
21
|
-
|
|
8
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
|
22
9
|
|
|
23
10
|
const BASE_URL = "https://melcloudhome.com";
|
|
24
11
|
const COGNITO_BASE =
|
|
25
|
-
|
|
12
|
+
"https://live-melcloudhome.auth.eu-west-1.amazoncognito.com";
|
|
26
13
|
|
|
27
14
|
class MELCloudHomeAuth {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const payload = new URLSearchParams({
|
|
104
|
-
_csrf: csrf,
|
|
105
|
-
username,
|
|
106
|
-
password,
|
|
107
|
-
cognitoAsfData: "",
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
const resp = await this.axios.post(loginUrl, payload.toString(), {
|
|
111
|
-
headers: {
|
|
112
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
113
|
-
Origin: COGNITO_BASE,
|
|
114
|
-
Referer: loginUrl,
|
|
115
|
-
},
|
|
116
|
-
validateStatus: () => true,
|
|
117
|
-
maxRedirects: 0
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
const redirectUrl =
|
|
121
|
-
resp.headers.location ||
|
|
122
|
-
resp.request?.res?.responseUrl ||
|
|
123
|
-
resp.config.url;
|
|
124
|
-
|
|
125
|
-
if (!redirectUrl.includes("signin-oidc")) {
|
|
126
|
-
throw new Error("Authentication failed: signin-oidc callback not received");
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
this.log("Calling signin-oidc:", redirectUrl);
|
|
130
|
-
|
|
131
|
-
// --- MEGA WAŻNE: wywołać callback ---
|
|
132
|
-
const callback = await this.axios.get(redirectUrl, {
|
|
133
|
-
headers: {
|
|
134
|
-
Referer: loginUrl,
|
|
135
|
-
"User-Agent": USER_AGENT,
|
|
136
|
-
Accept: "text/html",
|
|
137
|
-
},
|
|
138
|
-
validateStatus: () => true
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
this.log("signin-oidc status:", callback.status);
|
|
142
|
-
|
|
143
|
-
// Status 500 JEST NORMALNY – liczą się cookies!
|
|
144
|
-
const cookies = await this.jar.getCookies(redirectUrl);
|
|
145
|
-
this.log("After signin-oidc cookies:", cookies.map(c => c.key));
|
|
146
|
-
|
|
147
|
-
// Jeżeli pojawił się cookie `meu.identity` – logowanie jest zakończone
|
|
148
|
-
const hasIdentity = cookies.some(c => c.key.includes("meu.identity"));
|
|
149
|
-
if (!hasIdentity) {
|
|
150
|
-
throw new Error("openid callback did not produce a session cookie");
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
this.authenticated = true;
|
|
154
|
-
return true;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
// Public login method
|
|
159
|
-
async login(username, password) {
|
|
160
|
-
try {
|
|
161
|
-
const { loginUrl, csrf } = await this.startLoginFlow();
|
|
162
|
-
await this.submitCredentials(loginUrl, csrf, username, password);
|
|
163
|
-
|
|
164
|
-
// Validate session by calling /api/user/context
|
|
165
|
-
const ok = await this.checkSession();
|
|
166
|
-
if (!ok) {
|
|
167
|
-
this.authenticated = false;
|
|
168
|
-
throw new Error("Session not valid after login");
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
this.log("Login flow complete and session validated.");
|
|
172
|
-
return true;
|
|
173
|
-
} catch (err) {
|
|
174
|
-
// Re-throw with contextual message
|
|
175
|
-
throw new Error(`Login error: ${err.message || err}`);
|
|
176
|
-
}
|
|
15
|
+
constructor({ debug = false } = {}) {
|
|
16
|
+
this.debug = debug;
|
|
17
|
+
this.jar = new CookieJar();
|
|
18
|
+
this.axios = wrapper(
|
|
19
|
+
axios.create({
|
|
20
|
+
jar: this.jar,
|
|
21
|
+
withCredentials: true,
|
|
22
|
+
maxRedirects: 10,
|
|
23
|
+
headers: {
|
|
24
|
+
"User-Agent": USER_AGENT,
|
|
25
|
+
Accept:
|
|
26
|
+
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
|
27
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
);
|
|
31
|
+
this.authenticated = false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
log(...args) {
|
|
35
|
+
if (this.debug) console.log("[MelcloudHomeClient]", ...args);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
_delay(ms) {
|
|
39
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --- Helper: generate cognitoAsfData (simplified) ---
|
|
43
|
+
generateAsfData() {
|
|
44
|
+
const data = {
|
|
45
|
+
deviceName: "Chrome",
|
|
46
|
+
osName: "Macintosh",
|
|
47
|
+
osVersion: "14_5",
|
|
48
|
+
timezoneOffset: new Date().getTimezoneOffset(),
|
|
49
|
+
userAgent: USER_AGENT,
|
|
50
|
+
screen: { width: 1440, height: 900 },
|
|
51
|
+
language: "en-US",
|
|
52
|
+
plugins: [],
|
|
53
|
+
fonts: [],
|
|
54
|
+
canvasFingerprint: "simplified", // placeholder
|
|
55
|
+
};
|
|
56
|
+
const json = JSON.stringify(data);
|
|
57
|
+
return Buffer.from(json).toString("base64");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --- Extract CSRF token from HTML ---
|
|
61
|
+
extractCsrf(html) {
|
|
62
|
+
if (!html) return null;
|
|
63
|
+
try {
|
|
64
|
+
const dom = new JSDOM(html);
|
|
65
|
+
const el = dom.window.document.querySelector('input[name="_csrf"]');
|
|
66
|
+
if (el && el.value) return el.value;
|
|
67
|
+
} catch {}
|
|
68
|
+
const m = html.match(/<input[^>]+name="_csrf"[^>]+value="([^"]+)"/i);
|
|
69
|
+
if (m) return m[1];
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
findCognitoLoginUrl(html) {
|
|
74
|
+
if (!html) return null;
|
|
75
|
+
const m = html.match(/action="([^"]*amazoncognito\.com[^"]*)"/i);
|
|
76
|
+
if (m) return m[1];
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
extractErrorMessage(html) {
|
|
81
|
+
if (!html) return null;
|
|
82
|
+
const patterns = [
|
|
83
|
+
/<div[^>]*class="[^"]*error[^"]*"[^>]*>([^<]+)<\/div>/i,
|
|
84
|
+
/<span[^>]*class="[^"]*error[^"]*"[^>]*>([^<]+)<\/span>/i,
|
|
85
|
+
/<p[^>]*class="[^"]*error[^"]*"[^>]*>([^<]+)<\/p>/i,
|
|
86
|
+
];
|
|
87
|
+
for (const p of patterns) {
|
|
88
|
+
const m = html.match(p);
|
|
89
|
+
if (m) return m[1].trim();
|
|
177
90
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async startLoginFlow() {
|
|
95
|
+
this.log("Starting login flow: GET /bff/login");
|
|
96
|
+
const resp = await this.axios.get(`${BASE_URL}/bff/login`, {
|
|
97
|
+
params: { returnUrl: "/dashboard" },
|
|
98
|
+
validateStatus: () => true,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const finalUrl =
|
|
102
|
+
resp.request?.res?.responseUrl || resp.config?.url || "";
|
|
103
|
+
|
|
104
|
+
this.log("Received final redirect URL:", finalUrl);
|
|
105
|
+
|
|
106
|
+
const csrf = this.extractCsrf(resp.data);
|
|
107
|
+
if (!csrf) throw new Error("Failed to extract CSRF token");
|
|
108
|
+
|
|
109
|
+
const loginUrl = this.findCognitoLoginUrl(resp.data) || finalUrl;
|
|
110
|
+
return { loginUrl, csrf };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async submitCredentials(loginUrl, csrf, username, password) {
|
|
114
|
+
this.log("Submitting credentials to Cognito...");
|
|
115
|
+
|
|
116
|
+
const payload = new URLSearchParams({
|
|
117
|
+
_csrf: csrf,
|
|
118
|
+
username,
|
|
119
|
+
password,
|
|
120
|
+
cognitoAsfData: this.generateAsfData(),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Step 1: POST credentials
|
|
124
|
+
const resp = await this.axios.post(loginUrl, payload.toString(), {
|
|
125
|
+
headers: {
|
|
126
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
127
|
+
Origin: COGNITO_BASE,
|
|
128
|
+
Referer: loginUrl,
|
|
129
|
+
},
|
|
130
|
+
validateStatus: () => true,
|
|
131
|
+
maxRedirects: 0,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const redirectUrl = resp.headers.location || resp.request?.res?.responseUrl;
|
|
135
|
+
if (!redirectUrl.includes("signin-oidc")) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
"Authentication failed: signin-oidc callback not received"
|
|
138
|
+
);
|
|
204
139
|
}
|
|
205
140
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
141
|
+
this.log("Calling signin-oidc callback:", redirectUrl);
|
|
142
|
+
|
|
143
|
+
// Step 2: GET callback to set cookies
|
|
144
|
+
const callback = await this.axios.get(redirectUrl, {
|
|
145
|
+
headers: {
|
|
146
|
+
Referer: loginUrl,
|
|
147
|
+
"User-Agent": USER_AGENT,
|
|
148
|
+
Accept: "text/html",
|
|
149
|
+
},
|
|
150
|
+
validateStatus: () => true,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
this.log("signin-oidc status:", callback.status);
|
|
154
|
+
|
|
155
|
+
// Check if cookie meu.identity is set
|
|
156
|
+
const cookies = await this.jar.getCookies(BASE_URL);
|
|
157
|
+
const hasIdentity = cookies.some((c) => c.key.includes("meu.identity"));
|
|
158
|
+
if (!hasIdentity) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
"OpenID callback did not produce session cookie (cognitoAsfData missing or rejected)"
|
|
161
|
+
);
|
|
217
162
|
}
|
|
218
163
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
164
|
+
this.authenticated = true;
|
|
165
|
+
this.log("Login successful, session cookie meu.identity is present");
|
|
166
|
+
await this._delay(1000); // short wait for Blazor initialization
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async login(username, password) {
|
|
171
|
+
const { loginUrl, csrf } = await this.startLoginFlow();
|
|
172
|
+
await this.submitCredentials(loginUrl, csrf, username, password);
|
|
173
|
+
|
|
174
|
+
const valid = await this.checkSession();
|
|
175
|
+
if (!valid) throw new Error("Session not valid after login");
|
|
176
|
+
|
|
177
|
+
this.log("Login flow complete");
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async checkSession() {
|
|
182
|
+
if (!this.authenticated) return false;
|
|
183
|
+
try {
|
|
184
|
+
const resp = await this.axios.get(`${BASE_URL}/api/user/context`, {
|
|
185
|
+
headers: {
|
|
186
|
+
Accept: "application/json",
|
|
187
|
+
"x-csrf": "1",
|
|
188
|
+
Referer: `${BASE_URL}/dashboard`,
|
|
189
|
+
},
|
|
190
|
+
validateStatus: () => true,
|
|
191
|
+
});
|
|
192
|
+
return resp.status === 200;
|
|
193
|
+
} catch {
|
|
194
|
+
return false;
|
|
235
195
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
} catch (e) {
|
|
262
|
-
// ignore
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// fallback: inspect cookies for Cognito identity / session cookies
|
|
266
|
-
const cookies = await this.jar.getCookies(BASE_URL);
|
|
267
|
-
const cookieMap = {};
|
|
268
|
-
for (const c of cookies) {
|
|
269
|
-
cookieMap[c.key] = c.value;
|
|
270
|
-
}
|
|
271
|
-
return { cookies: cookieMap };
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// Utility: extract CSRF token from HTML using JSDOM or regex fallback
|
|
275
|
-
extractCsrf(html) {
|
|
276
|
-
if (!html) return null;
|
|
277
|
-
try {
|
|
278
|
-
const dom = new JSDOM(html);
|
|
279
|
-
const el = dom.window.document.querySelector('input[name="_csrf"]');
|
|
280
|
-
if (el && el.value) return el.value;
|
|
281
|
-
} catch (e) {
|
|
282
|
-
// ignore and try regex
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// regex fallback
|
|
286
|
-
const m = html.match(/<input[^>]+name="_csrf"[^>]+value="([^"]+)"/i);
|
|
287
|
-
if (m) return m[1];
|
|
288
|
-
const m2 = html.match(/name="_csrf"[^>]*value='([^']+)'/i);
|
|
289
|
-
if (m2) return m2[1];
|
|
290
|
-
return null;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// Try to find Cognito login action URL inside HTML (form action) as fallback
|
|
294
|
-
findCognitoLoginUrl(html) {
|
|
295
|
-
if (!html) return null;
|
|
296
|
-
const m = html.match(/action="([^"]*amazoncognito\.com[^"]*)"/i);
|
|
297
|
-
if (m) return m[1];
|
|
298
|
-
return null;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// Try to extract an error message from Cognito error page HTML
|
|
302
|
-
extractErrorMessage(html) {
|
|
303
|
-
if (!html) return null;
|
|
304
|
-
const patterns = [
|
|
305
|
-
/<div[^>]*class="[^"]*error[^"]*"[^>]*>([^<]+)<\/div>/i,
|
|
306
|
-
/<span[^>]*class="[^"]*error[^"]*"[^>]*>([^<]+)<\/span>/i,
|
|
307
|
-
/<p[^>]*class="[^"]*error[^"]*"[^>]*>([^<]+)<\/p>/i,
|
|
308
|
-
/<div[^>]*id=".*error.*"[^>]*>([^<]+)<\/div>/i,
|
|
309
|
-
];
|
|
310
|
-
for (const p of patterns) {
|
|
311
|
-
const m = html.match(p);
|
|
312
|
-
if (m) return m[1].trim();
|
|
313
|
-
}
|
|
314
|
-
return null;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
_delay(ms) {
|
|
318
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async getUserContext() {
|
|
199
|
+
if (!this.authenticated) throw new Error("Not authenticated");
|
|
200
|
+
const resp = await this.axios.get(`${BASE_URL}/api/user/context`, {
|
|
201
|
+
headers: { Accept: "application/json", "x-csrf": "1", Referer: `${BASE_URL}/dashboard` },
|
|
202
|
+
});
|
|
203
|
+
return resp.data;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async getDevices() {
|
|
207
|
+
if (!this.authenticated) throw new Error("Not authenticated");
|
|
208
|
+
const resp = await this.axios.get(`${BASE_URL}/api/devices/devices`, {
|
|
209
|
+
headers: { Accept: "application/json", "x-csrf": "1", Referer: `${BASE_URL}/dashboard` },
|
|
210
|
+
validateStatus: () => true,
|
|
211
|
+
});
|
|
212
|
+
if (resp.status !== 200) throw new Error(`Failed to get devices: ${resp.status}`);
|
|
213
|
+
return resp.data;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async logout() {
|
|
217
|
+
try {
|
|
218
|
+
await this.axios.get(`${BASE_URL}/bff/logout`, { validateStatus: () => true });
|
|
219
|
+
} finally {
|
|
220
|
+
this.authenticated = false;
|
|
319
221
|
}
|
|
222
|
+
}
|
|
320
223
|
}
|
|
321
224
|
|
|
225
|
+
|
|
322
226
|
export default MELCloudHomeAuth;
|
|
323
227
|
|
|
324
228
|
|