homebridge-melcloud-control 4.0.0-beta.400 → 4.0.0-beta.402
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/melcloudhometoken.js +126 -166
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.402",
|
|
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/melcloudhometoken.js
CHANGED
|
@@ -4,212 +4,172 @@ import { CookieJar } from 'tough-cookie';
|
|
|
4
4
|
import { wrapper } from 'axios-cookiejar-support';
|
|
5
5
|
import EventEmitter from 'events';
|
|
6
6
|
|
|
7
|
+
const MOBILE_USER_AGENT = 'MonitorAndControl.App.Mobile/35 CFNetwork/3860.100.1 Darwin/25.0.0';
|
|
8
|
+
const CLIENT_ID = 'homemobile';
|
|
9
|
+
const REDIRECT_URI = 'melcloudhome://';
|
|
10
|
+
const SCOPE = 'openid profile';
|
|
11
|
+
|
|
7
12
|
class MelCloudHomeToken extends EventEmitter {
|
|
8
13
|
constructor() {
|
|
9
14
|
super();
|
|
10
|
-
this.
|
|
11
|
-
this.CLIENT_ID = 'homemobile';
|
|
12
|
-
this.REDIRECT_URI = 'melcloudhome://';
|
|
13
|
-
this.SCOPE = 'openid profile email offline_access IdentityServerApi';
|
|
14
|
-
this.AUTH_URL = 'https://auth.melcloudhome.com/connect/authorize';
|
|
15
|
-
this.TOKEN_URL = 'https://auth.melcloudhome.com/connect/token';
|
|
16
|
-
|
|
17
|
-
// Axios with cookie support
|
|
18
|
-
const jar = new CookieJar();
|
|
19
|
-
this.client = wrapper(axios.create({
|
|
20
|
-
jar,
|
|
21
|
-
withCredentials: true,
|
|
22
|
-
maxRedirects: 0, // we’ll handle redirects manually
|
|
23
|
-
validateStatus: null,
|
|
24
|
-
headers: {
|
|
25
|
-
'User-Agent': this.MOBILE_USER_AGENT,
|
|
26
|
-
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
27
|
-
},
|
|
28
|
-
}));
|
|
15
|
+
this.cookieJar = new CookieJar();
|
|
29
16
|
}
|
|
30
17
|
|
|
31
|
-
/**
|
|
32
|
-
* Entry point: logs in and returns tokens
|
|
33
|
-
*/
|
|
34
18
|
async getTokens(email, password) {
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
`&redirect_uri=${encodeURIComponent(this.REDIRECT_URI)}` +
|
|
41
|
-
`&response_type=code` +
|
|
42
|
-
`&scope=${encodeURIComponent(this.SCOPE)}` +
|
|
43
|
-
`&code_challenge=${codeChallenge}` +
|
|
44
|
-
`&code_challenge_method=S256` +
|
|
45
|
-
`&state=${state}`;
|
|
46
|
-
|
|
47
|
-
console.log('[OAuth] Step 1: GET login page');
|
|
48
|
-
const loginPage = await this.client.get(authUrl);
|
|
49
|
-
if (loginPage.status !== 200) {
|
|
50
|
-
throw new Error(`Unexpected status on login page: ${loginPage.status}`);
|
|
51
|
-
}
|
|
19
|
+
const verifier = this._generateCodeVerifier();
|
|
20
|
+
const challenge = this._generateCodeChallenge(verifier);
|
|
21
|
+
const authUrl = `https://live-melcloudhome.auth.eu-west-1.amazoncognito.com/login?client_id=3g4d5l5kivuqi7oia68gib7uso&redirect_uri=${encodeURIComponent(
|
|
22
|
+
'https://auth.melcloudhome.com/signin-oidc-meu'
|
|
23
|
+
)}&response_type=code&scope=openid%20profile&code_challenge=${challenge}&code_challenge_method=S256`;
|
|
52
24
|
|
|
53
|
-
|
|
54
|
-
const csrfMatch = loginPage.data.match(/name="_csrf"\s+value="([^"]+)"/);
|
|
55
|
-
if (!csrfMatch) throw new Error('Could not find CSRF token');
|
|
56
|
-
const csrf = csrfMatch[1];
|
|
57
|
-
console.log('[OAuth] Found CSRF token');
|
|
25
|
+
console.log('[OAuth] Starting MELCloud login flow');
|
|
58
26
|
|
|
59
|
-
|
|
60
|
-
const
|
|
27
|
+
const html = await this._get(authUrl);
|
|
28
|
+
const csrf = this._extractCsrf(html);
|
|
29
|
+
if (!csrf) throw new Error('Cannot find CSRF token');
|
|
30
|
+
|
|
31
|
+
console.log('[OAuth] Submitting credentials...');
|
|
32
|
+
|
|
33
|
+
const form = new URLSearchParams({
|
|
61
34
|
_csrf: csrf,
|
|
62
35
|
username: email,
|
|
63
|
-
password: password
|
|
36
|
+
password: password,
|
|
64
37
|
});
|
|
65
38
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
39
|
+
const loginResp = await this._post('https://live-melcloudhome.auth.eu-west-1.amazoncognito.com/login', form);
|
|
40
|
+
|
|
41
|
+
if (loginResp.status === 302 && loginResp.headers.location) {
|
|
42
|
+
const nextUrl = this._resolveUrl('https://live-melcloudhome.auth.eu-west-1.amazoncognito.com', loginResp.headers.location);
|
|
43
|
+
console.log('[OAuth] Redirecting to:', nextUrl);
|
|
44
|
+
return await this._followRedirects(nextUrl, verifier);
|
|
45
|
+
} else if (loginResp.status === 200) {
|
|
46
|
+
throw new Error('Login failed — probably wrong credentials');
|
|
47
|
+
} else {
|
|
48
|
+
throw new Error(`Unexpected login status: ${loginResp.status}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async _followRedirects(url, verifier, depth = 0) {
|
|
53
|
+
if (depth > 10) throw new Error('Too many redirects');
|
|
54
|
+
|
|
55
|
+
const resp = await this._get(url);
|
|
56
|
+
|
|
57
|
+
if (resp.status === 302 && resp.headers.location) {
|
|
58
|
+
const nextUrl = this._resolveUrl(url, resp.headers.location);
|
|
59
|
+
console.log(`[OAuth] Redirect (${resp.status}) to: ${nextUrl}`);
|
|
60
|
+
return await this._followRedirects(nextUrl, verifier, depth + 1);
|
|
61
|
+
}
|
|
80
62
|
|
|
81
|
-
if (
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
63
|
+
if (typeof resp.data === 'string') {
|
|
64
|
+
const code = this._extractAuthCode(resp.data);
|
|
65
|
+
if (code) {
|
|
66
|
+
console.log('[OAuth] Got authorization code');
|
|
67
|
+
return await this._exchangeToken(code, verifier);
|
|
68
|
+
}
|
|
85
69
|
}
|
|
86
70
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const authCode = codeMatch[1];
|
|
90
|
-
console.log('[OAuth] ✓ Got authorization code');
|
|
71
|
+
throw new Error(`No authorization code found (status ${resp.status})`);
|
|
72
|
+
}
|
|
91
73
|
|
|
92
|
-
|
|
93
|
-
console.log('[OAuth]
|
|
94
|
-
|
|
74
|
+
async _exchangeToken(code, verifier) {
|
|
75
|
+
console.log('[OAuth] Exchanging code for tokens...');
|
|
76
|
+
|
|
77
|
+
const tokenUrl = 'https://auth.melcloudhome.com/connect/token';
|
|
78
|
+
const data = new URLSearchParams({
|
|
95
79
|
grant_type: 'authorization_code',
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
code_verifier:
|
|
80
|
+
client_id: CLIENT_ID,
|
|
81
|
+
code: code,
|
|
82
|
+
redirect_uri: REDIRECT_URI,
|
|
83
|
+
code_verifier: verifier,
|
|
100
84
|
});
|
|
101
85
|
|
|
102
|
-
const
|
|
86
|
+
const resp = await axios.post(tokenUrl, data.toString(), {
|
|
103
87
|
headers: {
|
|
88
|
+
'User-Agent': MOBILE_USER_AGENT,
|
|
104
89
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
105
|
-
'Authorization': 'Basic aG9tZW1vYmlsZTo=',
|
|
106
|
-
}
|
|
90
|
+
'Authorization': 'Basic aG9tZW1vYmlsZTo=',
|
|
91
|
+
},
|
|
92
|
+
validateStatus: () => true,
|
|
107
93
|
});
|
|
108
94
|
|
|
109
|
-
if (
|
|
110
|
-
console.error('[OAuth] Token
|
|
111
|
-
throw new Error(`Token exchange failed
|
|
95
|
+
if (resp.status !== 200) {
|
|
96
|
+
console.error('[OAuth] Token exchange failed:', resp.status, resp.data);
|
|
97
|
+
throw new Error(`Token exchange failed with status ${resp.status}`);
|
|
112
98
|
}
|
|
113
99
|
|
|
114
|
-
console.log('[OAuth]
|
|
100
|
+
console.log('[OAuth] Tokens received!');
|
|
115
101
|
return {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
expiresIn:
|
|
102
|
+
accessToken: resp.data.access_token,
|
|
103
|
+
refreshToken: resp.data.refresh_token,
|
|
104
|
+
expiresIn: resp.data.expires_in,
|
|
119
105
|
};
|
|
120
106
|
}
|
|
121
107
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
108
|
+
async _get(url) {
|
|
109
|
+
const cookies = await this.cookieJar.getCookieString(url);
|
|
110
|
+
const resp = await axios.get(url, {
|
|
111
|
+
headers: {
|
|
112
|
+
'User-Agent': MOBILE_USER_AGENT,
|
|
113
|
+
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
114
|
+
'Cookie': cookies,
|
|
115
|
+
},
|
|
116
|
+
validateStatus: () => true,
|
|
117
|
+
maxRedirects: 0,
|
|
118
|
+
responseType: 'text',
|
|
119
|
+
});
|
|
120
|
+
if (resp.headers['set-cookie']) {
|
|
121
|
+
await this.cookieJar.setCookie(resp.headers['set-cookie'].join('; '), url);
|
|
134
122
|
}
|
|
123
|
+
return resp;
|
|
124
|
+
}
|
|
135
125
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
console.log('[OAuth] Submitting hidden form →', formAction);
|
|
153
|
-
return await this.followRedirects(formAction, {
|
|
154
|
-
method: 'POST',
|
|
155
|
-
data: form.toString(),
|
|
156
|
-
headers: {
|
|
157
|
-
'Content-Type': 'application/x-www-form-urlencoded',
|
|
158
|
-
'Referer': url,
|
|
159
|
-
'User-Agent': this.MOBILE_USER_AGENT
|
|
160
|
-
}
|
|
161
|
-
}, depth + 1);
|
|
162
|
-
}
|
|
126
|
+
async _post(url, formData) {
|
|
127
|
+
const cookies = await this.cookieJar.getCookieString(url);
|
|
128
|
+
const resp = await axios.post(url, formData.toString(), {
|
|
129
|
+
headers: {
|
|
130
|
+
'User-Agent': MOBILE_USER_AGENT,
|
|
131
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
132
|
+
'Accept': 'text/html,application/xhtml+xml',
|
|
133
|
+
'Cookie': cookies,
|
|
134
|
+
'Content-Length': Buffer.byteLength(formData.toString()),
|
|
135
|
+
},
|
|
136
|
+
validateStatus: () => true,
|
|
137
|
+
maxRedirects: 0,
|
|
138
|
+
responseType: 'text',
|
|
139
|
+
});
|
|
140
|
+
if (resp.headers['set-cookie']) {
|
|
141
|
+
await this.cookieJar.setCookie(resp.headers['set-cookie'].join('; '), url);
|
|
163
142
|
}
|
|
143
|
+
return resp;
|
|
144
|
+
}
|
|
164
145
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
return await this.followRedirects(nextUrl, { method: 'GET' }, depth + 1);
|
|
170
|
-
}
|
|
146
|
+
_extractCsrf(html) {
|
|
147
|
+
const match = html.match(/name="_csrf" value="([^"]+)"/);
|
|
148
|
+
return match ? match[1] : null;
|
|
149
|
+
}
|
|
171
150
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
}
|
|
151
|
+
_extractAuthCode(html) {
|
|
152
|
+
const urlMatch = html.match(/[?&]code=([^&]+)/);
|
|
153
|
+
if (urlMatch) return urlMatch[1];
|
|
154
|
+
const formMatch = html.match(/name="code"\s+value="([^"]+)"/);
|
|
155
|
+
return formMatch ? formMatch[1] : null;
|
|
156
|
+
}
|
|
180
157
|
|
|
181
|
-
|
|
182
|
-
return
|
|
158
|
+
_generateCodeVerifier() {
|
|
159
|
+
return crypto.randomBytes(32).toString('base64url');
|
|
183
160
|
}
|
|
184
161
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
generatePKCE() {
|
|
189
|
-
const verifier = crypto.randomBytes(32)
|
|
190
|
-
.toString('base64')
|
|
191
|
-
.replace(/\+/g, '-')
|
|
192
|
-
.replace(/\//g, '_')
|
|
193
|
-
.replace(/=/g, '');
|
|
194
|
-
const challenge = crypto.createHash('sha256')
|
|
195
|
-
.update(verifier)
|
|
196
|
-
.digest('base64')
|
|
197
|
-
.replace(/\+/g, '-')
|
|
198
|
-
.replace(/\//g, '_')
|
|
199
|
-
.replace(/=/g, '');
|
|
200
|
-
return { codeVerifier: verifier, codeChallenge: challenge };
|
|
162
|
+
_generateCodeChallenge(verifier) {
|
|
163
|
+
const hash = crypto.createHash('sha256').update(verifier).digest();
|
|
164
|
+
return hash.toString('base64url');
|
|
201
165
|
}
|
|
202
166
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const baseUrl = new URL(base);
|
|
209
|
-
if (relative.startsWith('/')) {
|
|
210
|
-
return `${baseUrl.origin}${relative}`;
|
|
167
|
+
_resolveUrl(base, relative) {
|
|
168
|
+
try {
|
|
169
|
+
return new URL(relative, base).toString();
|
|
170
|
+
} catch {
|
|
171
|
+
return relative;
|
|
211
172
|
}
|
|
212
|
-
return `${baseUrl.origin}${baseUrl.pathname.replace(/\/[^/]*$/, '/')}${relative}`;
|
|
213
173
|
}
|
|
214
174
|
}
|
|
215
175
|
|