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