homebridge-melcloud-control 4.0.0-beta.253 → 4.0.0-beta.254

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "displayName": "MELCloud Control",
3
3
  "name": "homebridge-melcloud-control",
4
- "version": "4.0.0-beta.253",
4
+ "version": "4.0.0-beta.254",
5
5
  "description": "Homebridge plugin to control Mitsubishi Air Conditioner, Heat Pump and Energy Recovery Ventilation.",
6
6
  "license": "MIT",
7
7
  "author": "grzegorz914",
@@ -1,61 +1,219 @@
1
+ /**
2
+ * OAuth Helper - Automatic Token Management (ES Module)
3
+ */
4
+
1
5
  import https from 'https';
2
- import { URLSearchParams } from 'url';
6
+ import crypto from 'crypto';
3
7
 
4
- const TOKEN_ENDPOINT = 'https://auth.melcloudhome.com/connect/token';
8
+ const MOBILE_USER_AGENT = 'MonitorAndControl.App.Mobile/35 CFNetwork/3860.100.1 Darwin/25.0.0';
5
9
  const CLIENT_ID = 'homemobile';
6
- const REDIRECT_URI = 'melcloudhome://signin-oidc-meu';
10
+ const REDIRECT_URI = 'melcloudhome://';
7
11
  const SCOPE = 'openid profile email offline_access IdentityServerApi';
12
+ const TOKEN_ENDPOINT = 'https://auth.melcloudhome.com/connect/token';
13
+ const AUTHORIZE_ENDPOINT = 'https://auth.melcloudhome.com/connect/authorize';
8
14
 
9
15
  class MelCloudHomeToken {
10
- httpsRequest(url, options = {}, body = undefined) {
11
- return new Promise((resolve, reject) => {
12
- const req = https.request(url, options, (res) => {
13
- let data = '';
14
- res.on('data', chunk => data += chunk);
15
- res.on('end', () => resolve({ statusCode: res.statusCode, body: data }));
16
- });
17
- req.on('error', reject);
18
- if (body) req.write(body);
19
- req.end();
20
- });
21
- }
16
+ constructor(log) {
17
+ this.log = log;
18
+ }
22
19
 
23
- /**
24
- * Get OAuth tokens using username/password directly (ROPC)
25
- * Works in Node.js / Docker, no Puppeteer needed
26
- */
27
- async getTokensWithPassword(username, password) {
28
- console.log('Obtaining OAuth tokens using password grant...');
29
-
30
- const params = new URLSearchParams({
31
- grant_type: 'password',
32
- client_id: CLIENT_ID,
33
- username,
34
- password,
35
- scope: SCOPE,
36
- });
20
+ generatePKCE() {
21
+ const verifier = crypto.randomBytes(32).toString('base64url');
22
+ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
23
+ return { verifier, challenge };
24
+ }
37
25
 
38
- const headers = {
39
- 'Content-Type': 'application/x-www-form-urlencoded',
40
- 'Content-Length': params.toString().length,
41
- };
26
+ generateState() {
27
+ return crypto.randomBytes(32).toString('hex');
28
+ }
42
29
 
43
- const resp = await this.httpsRequest(TOKEN_ENDPOINT, { method: 'POST', headers }, params.toString());
30
+ httpsRequest(url, options, body, redirectCount = 0) {
31
+ if (redirectCount > 10) return Promise.reject(new Error('Too many redirects'));
44
32
 
45
- console.log('Token endpoint response:', resp.body);
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
+ }
46
45
 
47
- const tokens = JSON.parse(resp.body);
46
+ let data = '';
47
+ res.on('data', chunk => data += chunk);
48
+ res.on('end', () => resolve(data));
49
+ });
48
50
 
49
- if (tokens.error) {
50
- console.error('Token response error:', tokens);
51
- throw new Error(`Token request failed: ${tokens.error_description || tokens.error}`);
51
+ req.on('error', reject);
52
+ if (body) req.write(body);
53
+ req.end();
54
+ });
55
+ }
56
+
57
+ httpsRequestWithCookies(url, options, body, redirectCount = 0, cookies = []) {
58
+ if (redirectCount > 10) return Promise.reject(new Error('Too many redirects'));
59
+
60
+ return new Promise((resolve, reject) => {
61
+ const urlObj = new URL(url);
62
+ const reqOptions = { ...options, hostname: urlObj.hostname, path: urlObj.pathname + urlObj.search };
63
+
64
+ if (cookies.length) {
65
+ reqOptions.headers = { ...(reqOptions.headers || {}), Cookie: cookies.join('; ') };
66
+ }
67
+
68
+ const req = https.request(reqOptions, (res) => {
69
+ const setCookie = res.headers['set-cookie'];
70
+ if (setCookie) {
71
+ setCookie.forEach(cookie => {
72
+ const cookieName = cookie.split('=')[0];
73
+ const existingIndex = cookies.findIndex(c => c.startsWith(cookieName + '='));
74
+ if (existingIndex >= 0) cookies[existingIndex] = cookie.split(';')[0];
75
+ else cookies.push(cookie.split(';')[0]);
76
+ });
52
77
  }
53
78
 
54
- console.log('✓ Successfully obtained OAuth tokens');
55
- return tokens;
79
+ if ([301, 302].includes(res.statusCode) && res.headers.location) {
80
+ const redirectUrl = res.headers.location.startsWith('http')
81
+ ? res.headers.location
82
+ : `https://${urlObj.hostname}${res.headers.location}`;
83
+ return this.httpsRequestWithCookies(redirectUrl, options, undefined, redirectCount + 1, cookies)
84
+ .then(resolve).catch(reject);
85
+ }
86
+
87
+ let data = '';
88
+ res.on('data', chunk => data += chunk);
89
+ res.on('end', () => resolve({ html: data, cookies, finalUrl: url }));
90
+ });
91
+
92
+ req.on('error', reject);
93
+ if (body) req.write(body);
94
+ req.end();
95
+ });
96
+ }
97
+
98
+ async getLoginPage(authUrl) {
99
+ console.log('Getting login page...');
100
+ const cookies = [];
101
+ const response = await this.httpsRequestWithCookies(authUrl, {
102
+ method: 'GET',
103
+ headers: {
104
+ 'User-Agent': MOBILE_USER_AGENT,
105
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
106
+ 'Accept-Language': 'en-US,en;q=0.9',
107
+ },
108
+ }, undefined, 0, cookies);
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'));
150
+ });
151
+ });
152
+
153
+ req.on('error', reject);
154
+ req.write(formData.toString());
155
+ req.end();
156
+ });
157
+ }
158
+
159
+ async exchangeCodeForTokens(code, codeVerifier) {
160
+ const tokenData = new URLSearchParams({
161
+ grant_type: 'authorization_code',
162
+ code,
163
+ redirect_uri: REDIRECT_URI,
164
+ client_id: CLIENT_ID,
165
+ code_verifier: codeVerifier,
166
+ });
167
+
168
+ const response = await this.httpsRequest(TOKEN_ENDPOINT, {
169
+ method: 'POST',
170
+ headers: {
171
+ 'Content-Type': 'application/x-www-form-urlencoded',
172
+ 'Content-Length': tokenData.toString().length,
173
+ Authorization: 'Basic aG9tZW1vYmlsZTo=',
174
+ },
175
+ }, tokenData.toString());
176
+
177
+ const tokens = JSON.parse(response);
178
+ if (tokens.error) throw new Error(`Token exchange failed: ${tokens.error_description || tokens.error}`);
179
+
180
+ return {
181
+ accessToken: tokens.access_token,
182
+ refreshToken: tokens.refresh_token,
183
+ expiresIn: tokens.expires_in,
184
+ idToken: tokens.id_token,
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;
56
212
  }
213
+ }
57
214
  }
58
215
 
216
+
59
217
  export default MelCloudHomeToken;
60
218
 
61
219