homebridge-melcloud-control 4.0.0-beta.256 → 4.0.0-beta.257
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 +3 -2
- package/src/melcloudhometoken.js +99 -197
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"displayName": "MELCloud Control",
|
|
3
3
|
"name": "homebridge-melcloud-control",
|
|
4
|
-
"version": "4.0.0-beta.
|
|
4
|
+
"version": "4.0.0-beta.257",
|
|
5
5
|
"description": "Homebridge plugin to control Mitsubishi Air Conditioner, Heat Pump and Energy Recovery Ventilation.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "grzegorz914",
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
"axios": "^1.12.2",
|
|
41
41
|
"express": "^5.1.0",
|
|
42
42
|
"puppeteer": "^24.26.1",
|
|
43
|
-
"
|
|
43
|
+
"axios-cookiejar-support": "^6.0.4",
|
|
44
|
+
"tough-cookie": "^6.0.0"
|
|
44
45
|
},
|
|
45
46
|
"keywords": [
|
|
46
47
|
"homebridge",
|
package/src/melcloudhometoken.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import https from 'https';
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { wrapper } from 'axios-cookiejar-support';
|
|
3
|
+
import { CookieJar } from 'tough-cookie';
|
|
6
4
|
import crypto from 'crypto';
|
|
7
5
|
|
|
8
6
|
const MOBILE_USER_AGENT = 'MonitorAndControl.App.Mobile/35 CFNetwork/3860.100.1 Darwin/25.0.0';
|
|
@@ -13,204 +11,108 @@ const TOKEN_ENDPOINT = 'https://auth.melcloudhome.com/connect/token';
|
|
|
13
11
|
const AUTHORIZE_ENDPOINT = 'https://auth.melcloudhome.com/connect/authorize';
|
|
14
12
|
|
|
15
13
|
class MelCloudHomeToken {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
23
|
-
return { verifier, challenge };
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
generateState() {
|
|
27
|
-
return crypto.randomBytes(32).toString('hex');
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
httpsRequest(url, options, body, redirectCount = 0) {
|
|
31
|
-
if (redirectCount > 10) return Promise.reject(new Error('Too many redirects'));
|
|
32
|
-
|
|
33
|
-
return new Promise((resolve, reject) => {
|
|
34
|
-
const urlObj = new URL(url);
|
|
35
|
-
const reqOptions = { ...options, hostname: urlObj.hostname, path: urlObj.pathname + urlObj.search };
|
|
36
|
-
|
|
37
|
-
const req = https.request(reqOptions, (res) => {
|
|
38
|
-
if ([301, 302].includes(res.statusCode) && res.headers.location) {
|
|
39
|
-
const redirectUrl = res.headers.location.startsWith('http')
|
|
40
|
-
? res.headers.location
|
|
41
|
-
: `https://${urlObj.hostname}${res.headers.location}`;
|
|
42
|
-
return this.httpsRequest(redirectUrl, options, undefined, redirectCount + 1)
|
|
43
|
-
.then(resolve).catch(reject);
|
|
44
|
-
}
|
|
14
|
+
constructor(log = console) {
|
|
15
|
+
this.log = log;
|
|
16
|
+
const jar = new CookieJar();
|
|
17
|
+
this.client = wrapper(axios.create({ jar, withCredentials: true }));
|
|
18
|
+
this.client.defaults.headers['User-Agent'] = MOBILE_USER_AGENT;
|
|
19
|
+
}
|
|
45
20
|
|
|
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
|
-
if (existingIndex >= 0) cookies[existingIndex] = cookie.split(';')[0];
|
|
75
|
-
else cookies.push(cookie.split(';')[0]);
|
|
76
|
-
});
|
|
77
|
-
}
|
|
21
|
+
generatePKCE() {
|
|
22
|
+
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
23
|
+
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
24
|
+
return { verifier, challenge };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
generateState() {
|
|
28
|
+
return crypto.randomBytes(32).toString('hex');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async getLoginPage(authUrl) {
|
|
32
|
+
this.log.debug('Getting login page...');
|
|
33
|
+
const res = await this.client.get(authUrl, { maxRedirects: 10 });
|
|
34
|
+
const csrfMatch = res.data.match(/name="_csrf"\s+value="([^"]+)"/);
|
|
35
|
+
if (!csrfMatch) throw new Error('Could not extract CSRF token from login page');
|
|
36
|
+
return { html: res.data, csrf: csrfMatch[1], url: res.request.res.responseUrl };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async submitLogin(loginUrl, csrf, email, password) {
|
|
40
|
+
const form = new URLSearchParams({ _csrf: csrf, username: email, password });
|
|
41
|
+
const res = await this.client.post(loginUrl, form.toString(), {
|
|
42
|
+
headers: {
|
|
43
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
44
|
+
Referer: loginUrl,
|
|
45
|
+
},
|
|
46
|
+
maxRedirects: 0, // manual redirect handling
|
|
47
|
+
validateStatus: (status) => status >= 200 && status < 400,
|
|
48
|
+
});
|
|
78
49
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
.then(resolve).catch(reject);
|
|
50
|
+
// Try to get code from Location header
|
|
51
|
+
const location = res.headers.location;
|
|
52
|
+
if (location?.startsWith('melcloudhome://')) {
|
|
53
|
+
const codeMatch = location.match(/[?&]code=([^&]+)/);
|
|
54
|
+
if (codeMatch) return codeMatch[1];
|
|
85
55
|
}
|
|
86
56
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const csrfMatch = response.html.match(/name="_csrf"\s+value="([^"]+)"/);
|
|
111
|
-
if (!csrfMatch) throw new Error('Could not extract CSRF token from login page');
|
|
112
|
-
|
|
113
|
-
return {
|
|
114
|
-
html: response.html,
|
|
115
|
-
loginUrl: response.finalUrl,
|
|
116
|
-
csrf: csrfMatch[1],
|
|
117
|
-
cookies: response.cookies.join('; '),
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
submitLogin(loginUrl, csrf, email, password, cookies) {
|
|
122
|
-
const formData = new URLSearchParams({ _csrf: csrf, username: email, password });
|
|
123
|
-
return new Promise((resolve, reject) => {
|
|
124
|
-
const urlObj = new URL(loginUrl);
|
|
125
|
-
const req = https.request({
|
|
126
|
-
hostname: urlObj.hostname,
|
|
127
|
-
path: urlObj.pathname + urlObj.search,
|
|
128
|
-
method: 'POST',
|
|
129
|
-
headers: {
|
|
130
|
-
'User-Agent': MOBILE_USER_AGENT,
|
|
131
|
-
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
132
|
-
'Accept-Language': 'en-US,en;q=0.9',
|
|
133
|
-
'Content-Type': 'application/x-www-form-urlencoded',
|
|
134
|
-
'Content-Length': formData.toString().length,
|
|
135
|
-
Cookie: cookies,
|
|
136
|
-
Origin: 'https://live-melcloudhome.auth.eu-west-1.amazoncognito.com',
|
|
137
|
-
Referer: loginUrl,
|
|
138
|
-
},
|
|
139
|
-
}, (res) => {
|
|
140
|
-
let data = '';
|
|
141
|
-
res.on('data', chunk => data += chunk);
|
|
142
|
-
res.on('end', () => {
|
|
143
|
-
const locationHeader = res.headers.location;
|
|
144
|
-
const bodyCodeMatch = data.match(/melcloudhome:\/\/[^"'\s]*[?&]code=([^&"'\s]+)/);
|
|
145
|
-
if (locationHeader && locationHeader.startsWith('melcloudhome://')) {
|
|
146
|
-
const codeMatch = locationHeader.match(/[?&]code=([^&]+)/);
|
|
147
|
-
if (codeMatch) return resolve(codeMatch[1]);
|
|
148
|
-
} else if (bodyCodeMatch) return resolve(bodyCodeMatch[1]);
|
|
149
|
-
reject(new Error('No authorization code found in login response'));
|
|
57
|
+
// Try to get code from HTML (form_post or JS)
|
|
58
|
+
const codeMatch = res.data.match(/name="code"\s+value="([^"]+)"/) ||
|
|
59
|
+
res.data.match(/melcloudhome:\/\/[^"'\s]*[?&]code=([^&"'\s]+)/);
|
|
60
|
+
if (codeMatch) return codeMatch[1];
|
|
61
|
+
|
|
62
|
+
throw new Error('Authorization code not found in login response');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async exchangeCodeForTokens(code, codeVerifier) {
|
|
66
|
+
const form = new URLSearchParams({
|
|
67
|
+
grant_type: 'authorization_code',
|
|
68
|
+
code,
|
|
69
|
+
redirect_uri: REDIRECT_URI,
|
|
70
|
+
client_id: CLIENT_ID,
|
|
71
|
+
code_verifier: codeVerifier,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const res = await this.client.post(TOKEN_ENDPOINT, form.toString(), {
|
|
75
|
+
headers: {
|
|
76
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
77
|
+
Authorization: 'Basic aG9tZW1vYmlsZTo=',
|
|
78
|
+
},
|
|
150
79
|
});
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
async getTokensWithCredentials(email, password) {
|
|
189
|
-
try {
|
|
190
|
-
console.log('Obtaining OAuth tokens automatically...');
|
|
191
|
-
const pkce = this.generatePKCE();
|
|
192
|
-
const state = this.generateState();
|
|
193
|
-
|
|
194
|
-
const authUrl = new URL(AUTHORIZE_ENDPOINT);
|
|
195
|
-
authUrl.searchParams.set('client_id', CLIENT_ID);
|
|
196
|
-
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
|
|
197
|
-
authUrl.searchParams.set('response_type', 'code');
|
|
198
|
-
authUrl.searchParams.set('scope', SCOPE);
|
|
199
|
-
authUrl.searchParams.set('code_challenge', pkce.challenge);
|
|
200
|
-
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
201
|
-
authUrl.searchParams.set('state', state);
|
|
202
|
-
|
|
203
|
-
const loginPage = await this.getLoginPage(authUrl.toString());
|
|
204
|
-
const authCode = await this.submitLogin(loginPage.loginUrl, loginPage.csrf, email, password, loginPage.cookies);
|
|
205
|
-
const tokens = await this.exchangeCodeForTokens(authCode, pkce.verifier);
|
|
206
|
-
|
|
207
|
-
console.log('✓ Successfully obtained OAuth tokens');
|
|
208
|
-
return tokens;
|
|
209
|
-
} catch (error) {
|
|
210
|
-
console.log('Failed to obtain OAuth tokens:', error);
|
|
211
|
-
throw error;
|
|
80
|
+
|
|
81
|
+
if (res.data.error) throw new Error(`Token exchange failed: ${res.data.error_description || res.data.error}`);
|
|
82
|
+
return {
|
|
83
|
+
accessToken: res.data.access_token,
|
|
84
|
+
refreshToken: res.data.refresh_token,
|
|
85
|
+
expiresIn: res.data.expires_in,
|
|
86
|
+
idToken: res.data.id_token,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async getTokensWithCredentials(email, password) {
|
|
91
|
+
try {
|
|
92
|
+
this.log.info('Obtaining OAuth tokens...');
|
|
93
|
+
const pkce = this.generatePKCE();
|
|
94
|
+
const state = this.generateState();
|
|
95
|
+
|
|
96
|
+
const authUrl = new URL(AUTHORIZE_ENDPOINT);
|
|
97
|
+
authUrl.searchParams.set('client_id', CLIENT_ID);
|
|
98
|
+
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
|
|
99
|
+
authUrl.searchParams.set('response_type', 'code');
|
|
100
|
+
authUrl.searchParams.set('scope', SCOPE);
|
|
101
|
+
authUrl.searchParams.set('code_challenge', pkce.challenge);
|
|
102
|
+
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
103
|
+
authUrl.searchParams.set('state', state);
|
|
104
|
+
|
|
105
|
+
const loginPage = await this.getLoginPage(authUrl.toString());
|
|
106
|
+
const authCode = await this.submitLogin(loginPage.url, loginPage.csrf, email, password);
|
|
107
|
+
const tokens = await this.exchangeCodeForTokens(authCode, pkce.verifier);
|
|
108
|
+
|
|
109
|
+
this.log.info('✓ Successfully obtained OAuth tokens');
|
|
110
|
+
return tokens;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
this.log.error('Failed to obtain OAuth tokens:', error);
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
212
115
|
}
|
|
213
|
-
}
|
|
214
116
|
}
|
|
215
117
|
|
|
216
118
|
|