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