homebridge-nuheat2 1.2.15 → 1.2.17

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/CHANGELOG.md CHANGED
@@ -4,6 +4,20 @@ All notable changes to this project should be documented in this file
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.2.17] - 2026-04-29
8
+
9
+ ### Fixed
10
+
11
+ - Treat the built-in Nuheat public client ID as PKCE-only even when it is explicitly saved in Advanced OAuth settings
12
+ - Ignore and clear stale saved client secrets when users return to the built-in PKCE public client
13
+
14
+ ## [1.2.16] - 2026-04-29
15
+
16
+ ### Changed
17
+
18
+ - Switch the built-in Nuheat OAuth client to Authorization Code with PKCE using the public `homebridge-nuheat2_260421` client ID
19
+ - Keep legacy confidential-client OAuth overrides available when a custom `clientSecret` is explicitly configured
20
+
7
21
  ## [1.2.15] - 2026-04-27
8
22
 
9
23
  ### Fixed
package/README.md CHANGED
@@ -15,6 +15,7 @@ This project builds on the original [`senorshaun/homebridge-nuheat`](https://git
15
15
  - Optionally creates HomeKit switches for Nuheat group away mode
16
16
  - Supports permanent, scheduled, and timed holds
17
17
  - Uses Nuheat's OAuth-based API instead of legacy site scraping
18
+ - Uses the official Nuheat PKCE public client by default, with no distributable client secret
18
19
  - Includes compatibility improvements for Homebridge 1.8+ and 2.0 betas
19
20
  - Can optionally expose a schedule switch for each thermostat
20
21
  - Allows advanced OAuth overrides for long-term API stability
@@ -89,8 +90,8 @@ The equivalent JSON looks like this:
89
90
  - `refresh`: Poll interval in seconds, default `60`. Values lower than `30` are raised to `30` to reduce API traffic
90
91
  - `enableNotifications`: Enables Nuheat SignalR notifications for faster updates. Defaults to `true`; set to `false` only while troubleshooting
91
92
  - `debug`: Enables verbose Nuheat API, notification, and accessory logging. Defaults to `false`
92
- - `clientId`: Optional advanced override for the Nuheat OAuth client ID. Current releases require this to be paired with `clientSecret`; PKCE public-client support is planned but is not active yet
93
- - `clientSecret`: Optional legacy OAuth client secret override for confidential-client credentials. Required with a custom `clientId` until PKCE support ships. Do not publish or share this value
93
+ - `clientId`: Optional advanced override for the Nuheat OAuth client ID. Leave blank to use the built-in PKCE public client ID, `homebridge-nuheat2_260421`
94
+ - `clientSecret`: Optional legacy OAuth client secret override for confidential-client credentials. Leave blank for the PKCE public-client flow. Do not publish or share this value
94
95
  - `redirectUri`: Optional advanced override for the Nuheat OAuth redirect URI, default `http://localhost`
95
96
 
96
97
  ### Hold Length Behavior
@@ -114,7 +115,9 @@ Nuheat's public OpenAPI documentation indicates that third-party developers shou
114
115
  - [Nuheat OpenAPI docs](https://api.mynuheat.com/)
115
116
  - [Nuheat API access request page](https://www.nuheat.com/openapi)
116
117
 
117
- This fork still supports the legacy built-in OAuth client settings as a fallback. Nuheat is expected to issue a PKCE-based public client for this integration so the plugin can eventually ship with a public `clientId` without distributing a client secret. Until that migration is complete, keep any issued `clientSecret` out of GitHub, npm, screenshots, and shared logs.
118
+ This fork uses Nuheat's PKCE-based public client for normal authentication. The built-in public `clientId` is `homebridge-nuheat2_260421`; there is no distributable client secret.
119
+
120
+ Legacy confidential-client credentials can still be supplied with `clientId` and `clientSecret` for testing alternate Nuheat-issued clients. Keep any `clientSecret` out of GitHub, npm, screenshots, and shared logs.
118
121
 
119
122
  ## What's New In This Fork
120
123
 
@@ -173,7 +176,6 @@ After that, bump the version in `package.json`, push to `master`, and GitHub Act
173
176
 
174
177
  ## Future Work
175
178
 
176
- - Validate the plugin against the official Nuheat API credentials requested for this integration.
177
- - Move the normal OAuth path to Nuheat's PKCE-based public client once the new client details are issued.
179
+ - Validate the PKCE public-client flow against more real-world Nuheat accounts.
178
180
  - Verify group and away-mode behavior against current live API responses.
179
181
  - Evaluate whether SignalR notifications can reduce polling further in real-world deployments.
@@ -125,13 +125,13 @@
125
125
  "clientId": {
126
126
  "title": "Nuheat Client ID",
127
127
  "type": "string",
128
- "description": "Advanced OAuth override. Current releases require this to be paired with a clientSecret; PKCE public-client support is planned but not active yet."
128
+ "description": "Advanced OAuth override. Leave blank to use the built-in PKCE public client ID."
129
129
  },
130
130
  "clientSecret": {
131
131
  "title": "Nuheat Client Secret (Legacy)",
132
132
  "type": "string",
133
133
  "format": "password",
134
- "description": "Advanced OAuth override for confidential-client credentials. Required with a custom clientId until PKCE support ships. Do not publish or share this value."
134
+ "description": "Legacy OAuth override for confidential-client credentials. Leave blank for the PKCE public-client flow. Do not publish or share this value."
135
135
  },
136
136
  "redirectUri": {
137
137
  "title": "Nuheat Redirect URI",
@@ -177,7 +177,7 @@
177
177
  "title": "Advanced OAuth",
178
178
  "expandable": true,
179
179
  "expanded": false,
180
- "description": "Only change these fields when testing Nuheat-issued API credentials.",
180
+ "description": "Only change these fields when testing alternate Nuheat-issued API credentials.",
181
181
  "items": [
182
182
  "clientId",
183
183
  "clientSecret",
@@ -173,19 +173,21 @@
173
173
  <div>
174
174
  <h2>Advanced OAuth</h2>
175
175
  <p class="help">
176
- Leave these fields blank unless Nuheat has issued confidential-client
177
- credentials for testing. PKCE public-client support is planned but is
178
- not active yet.
176
+ Leave these fields blank to use the built-in Nuheat PKCE public
177
+ client. Only change them when testing alternate Nuheat-issued API
178
+ credentials.
179
179
  </p>
180
180
  </div>
181
- <span id="oauth-status" class="status-pill warn">Built-in fallback</span>
181
+ <span id="oauth-status" class="status-pill good">Built-in PKCE</span>
182
182
  </div>
183
183
 
184
184
  <div class="settings-grid">
185
185
  <label class="field">
186
186
  <span>Nuheat Client ID</span>
187
187
  <input id="client-id" type="text" placeholder="Optional client ID" />
188
- <small>Advanced override for a Nuheat-issued OAuth client ID.</small>
188
+ <small>
189
+ Advanced override. Blank uses homebridge-nuheat2_260421.
190
+ </small>
189
191
  </label>
190
192
 
191
193
  <label id="client-secret-row" class="field">
@@ -196,8 +198,8 @@
196
198
  placeholder="Leave blank to keep existing"
197
199
  />
198
200
  <small>
199
- Required with a custom client ID until PKCE support ships. Do not
200
- publish or share this value.
201
+ Leave blank for PKCE public-client auth. Only use this for legacy
202
+ confidential-client credentials.
201
203
  </small>
202
204
  </label>
203
205
 
@@ -1,4 +1,5 @@
1
1
  const PLATFORM_NAME = "NuHeat";
2
+ const BUILT_IN_CLIENT_ID = "homebridge-nuheat2_260421";
2
3
 
3
4
  const elements = {
4
5
  name: document.getElementById("name"),
@@ -139,7 +140,9 @@ function renderConfig(config) {
139
140
 
140
141
  elements.clientId.value = config.clientId || "";
141
142
  elements.clientSecret.value = "";
142
- state.hasClientSecret = Boolean(config.clientSecret);
143
+ state.hasClientSecret = Boolean(
144
+ config.clientSecret && config.clientId !== BUILT_IN_CLIENT_ID,
145
+ );
143
146
  elements.clientSecret.placeholder = state.hasClientSecret
144
147
  ? "Saved secret (leave blank to keep)"
145
148
  : "Optional client secret";
@@ -313,6 +316,7 @@ async function saveOauth() {
313
316
  const clientId = elements.clientId.value.trim();
314
317
  const clientSecret = elements.clientSecret.value;
315
318
  const redirectUri = elements.redirectUri.value.trim();
319
+ const usesBuiltInClient = !clientId || clientId === BUILT_IN_CLIENT_ID;
316
320
 
317
321
  if (!clientId && clientSecret) {
318
322
  showToast("error", "A client secret requires a Nuheat client ID.");
@@ -320,34 +324,26 @@ async function saveOauth() {
320
324
  return;
321
325
  }
322
326
 
323
- if (clientId && !hasUsableClientSecret(clientId) && !clientSecret) {
324
- showToast(
325
- "error",
326
- "Current OAuth overrides require a matching client ID and client secret.",
327
- );
328
- elements.clientSecret.focus();
329
- return;
330
- }
331
-
332
327
  const patch = {
333
- clientId: clientId || undefined,
328
+ clientId: usesBuiltInClient ? undefined : clientId,
334
329
  redirectUri: redirectUri && redirectUri !== "http://localhost" ? redirectUri : undefined,
335
330
  };
336
331
 
337
- if (clientSecret) {
332
+ if (clientSecret && !usesBuiltInClient) {
338
333
  patch.clientSecret = clientSecret;
339
- } else if (!clientId) {
334
+ } else if (usesBuiltInClient || clientId !== (state.config?.clientId || "")) {
340
335
  patch.clientSecret = undefined;
341
336
  }
342
337
 
343
338
  await persistPatch(patch);
344
- if (clientSecret) {
345
- state.hasClientSecret = true;
346
- elements.clientSecret.value = "";
347
- elements.clientSecret.placeholder = "Saved secret (leave blank to keep)";
348
- updateStatuses();
349
- renderDiagnostics();
350
- }
339
+ state.hasClientSecret = Boolean(state.config?.clientSecret);
340
+ elements.clientId.value = state.config?.clientId || "";
341
+ elements.clientSecret.value = "";
342
+ elements.clientSecret.placeholder = state.hasClientSecret
343
+ ? "Saved secret (leave blank to keep)"
344
+ : "Optional client secret";
345
+ updateStatuses();
346
+ renderDiagnostics();
351
347
  showToast("success", "OAuth settings saved.");
352
348
  }
353
349
 
@@ -446,17 +442,22 @@ function updateBehaviorStatus() {
446
442
  }
447
443
 
448
444
  function updateOauthStatus() {
449
- const hasClientId = elements.clientId.value.trim().length > 0;
450
- const hasClientSecret = hasUsableClientSecret(elements.clientId.value.trim());
445
+ const clientId = elements.clientId.value.trim();
446
+ const hasClientId = clientId.length > 0 && clientId !== BUILT_IN_CLIENT_ID;
447
+ const hasClientSecret = hasUsableClientSecret(clientId);
451
448
  const text = hasClientId
452
449
  ? hasClientSecret
453
- ? "Custom OAuth"
454
- : "Secret needed"
455
- : "Built-in fallback";
456
- setStatus(elements.oauthStatus, hasClientId && hasClientSecret, text);
450
+ ? "Custom legacy OAuth"
451
+ : "Custom PKCE"
452
+ : "Built-in PKCE";
453
+ setStatus(elements.oauthStatus, true, text);
457
454
  }
458
455
 
459
456
  function hasUsableClientSecret(clientId) {
457
+ if (!clientId || clientId === BUILT_IN_CLIENT_ID) {
458
+ return false;
459
+ }
460
+
460
461
  if (elements.clientSecret.value) {
461
462
  return true;
462
463
  }
@@ -586,13 +587,16 @@ function getHoldSummary(value) {
586
587
  }
587
588
 
588
589
  function getOauthMode(config) {
590
+ if (!config.clientId || config.clientId === BUILT_IN_CLIENT_ID) {
591
+ return "built-in PKCE public client";
592
+ }
593
+
589
594
  if (config.clientId && hasUsableClientSecret(config.clientId)) {
590
595
  return "configured confidential client";
591
596
  }
592
597
  if (config.clientId) {
593
- return "incomplete custom client";
598
+ return "configured PKCE public client";
594
599
  }
595
- return "built-in fallback credentials";
596
600
  }
597
601
 
598
602
  function normalizeHoldLength(value) {
package/lib/NuHeatAPI.js CHANGED
@@ -6,6 +6,7 @@ const FetchError = fetchModule.FetchError;
6
6
  const HeadersCtor = fetchModule.Headers;
7
7
  const isRedirect = fetchModule.isRedirect;
8
8
  const { parse } = htmlParser;
9
+ const node_crypto_1 = require("node:crypto");
9
10
  const settings_1 = require("./settings");
10
11
  const NuHeatModels_1 = require("./NuHeatModels");
11
12
  const OAUTH_SCOPES = ["openapi", "openid", "profile", "offline_access"];
@@ -16,7 +17,9 @@ class NuHeatAPI {
16
17
  oauthClientId;
17
18
  oauthClientSecret;
18
19
  oauthRedirectUri;
19
- usingFallbackCredentials;
20
+ usingBuiltInClient;
21
+ usePkce;
22
+ pkceCodeVerifier;
20
23
  headers;
21
24
  accessToken;
22
25
  accessTokenTimestamp;
@@ -28,23 +31,20 @@ class NuHeatAPI {
28
31
  this.email = email;
29
32
  this.password = password;
30
33
  this.log = log;
31
- this.oauthClientId =
32
- options.clientId ||
33
- process.env.NUHEAT_API_CLIENT_ID ||
34
- settings_1.NUHEAT_API_CLIENT_ID;
35
- this.oauthClientSecret =
36
- options.clientSecret ||
37
- process.env.NUHEAT_API_CLIENT_SECRET ||
38
- settings_1.NUHEAT_API_CLIENT_SECRET;
34
+ const configuredClientId = options.clientId || process.env.NUHEAT_API_CLIENT_ID || "";
35
+ const configuredClientSecret = options.clientSecret || process.env.NUHEAT_API_CLIENT_SECRET || "";
36
+ const usingBuiltInPublicClient = !configuredClientId || configuredClientId === settings_1.NUHEAT_API_CLIENT_ID;
37
+ this.oauthClientId = configuredClientId || settings_1.NUHEAT_API_CLIENT_ID;
38
+ this.oauthClientSecret = usingBuiltInPublicClient
39
+ ? ""
40
+ : configuredClientSecret || settings_1.NUHEAT_API_CLIENT_SECRET;
39
41
  this.oauthRedirectUri =
40
42
  options.redirectUri ||
41
43
  process.env.NUHEAT_API_REDIRECT_URI ||
42
44
  settings_1.NUHEAT_API_REDIRECT_URI;
43
- this.usingFallbackCredentials =
44
- !options.clientId &&
45
- !process.env.NUHEAT_API_CLIENT_ID &&
46
- !options.clientSecret &&
47
- !process.env.NUHEAT_API_CLIENT_SECRET;
45
+ this.usingBuiltInClient = usingBuiltInPublicClient;
46
+ this.usePkce = !this.oauthClientSecret;
47
+ this.pkceCodeVerifier = "";
48
48
  this.headers = new HeadersCtor();
49
49
  this.headers.set("Content-Type", "application/json");
50
50
  this.headers.set("Accept", "application/json");
@@ -54,8 +54,10 @@ class NuHeatAPI {
54
54
  this.refreshToken = "";
55
55
  this.tokenScope = "";
56
56
  this.tokenType = "Bearer";
57
- if (this.usingFallbackCredentials) {
58
- this.log.warn("NuHeatAPI: Using built-in OAuth client credentials. Request your own Nuheat API client for long-term reliability.");
57
+ if (this.usingBuiltInClient) {
58
+ this.log.info("NuHeatAPI: Using built-in Nuheat PKCE public client ID " +
59
+ this.oauthClientId +
60
+ ".");
59
61
  }
60
62
  else {
61
63
  this.log.info("NuHeatAPI: Using configured OAuth client ID " + this.oauthClientId + ".");
@@ -63,11 +65,48 @@ class NuHeatAPI {
63
65
  this.log.debug("NuHeatAPI: OAuth redirect URI " +
64
66
  this.oauthRedirectUri +
65
67
  ". Requested scopes: " +
66
- this.getRequestedScope());
68
+ this.getRequestedScope() +
69
+ ". OAuth flow: " +
70
+ (this.usePkce ? "authorization_code_pkce" : "authorization_code_secret"));
67
71
  }
68
72
  getRequestedScope() {
69
73
  return OAUTH_SCOPES.join(" ");
70
74
  }
75
+ generatePkceCodeVerifier() {
76
+ return (0, node_crypto_1.randomBytes)(64).toString("base64url");
77
+ }
78
+ getPkceCodeChallenge(codeVerifier) {
79
+ return (0, node_crypto_1.createHash)("sha256").update(codeVerifier).digest("base64url");
80
+ }
81
+ buildAuthorizationCodeTokenRequest(redirectUrl) {
82
+ const requestBody = new URLSearchParams({
83
+ client_id: this.oauthClientId,
84
+ code: redirectUrl.searchParams.get("code") || "",
85
+ grant_type: "authorization_code",
86
+ redirect_uri: this.oauthRedirectUri,
87
+ scope: redirectUrl.searchParams.get("scope") || "",
88
+ });
89
+ if (this.usePkce) {
90
+ requestBody.set("code_verifier", this.pkceCodeVerifier);
91
+ }
92
+ else {
93
+ requestBody.set("client_secret", this.oauthClientSecret);
94
+ }
95
+ return requestBody;
96
+ }
97
+ buildRefreshTokenRequest() {
98
+ const requestBody = new URLSearchParams({
99
+ client_id: this.oauthClientId,
100
+ grant_type: "refresh_token",
101
+ refresh_token: this.refreshToken,
102
+ scope: this.tokenScope,
103
+ });
104
+ if (!this.usePkce) {
105
+ requestBody.set("client_secret", this.oauthClientSecret);
106
+ requestBody.set("redirect_uri", this.oauthRedirectUri);
107
+ }
108
+ return requestBody;
109
+ }
71
110
  async setAwayMode(groupId, awayMode) {
72
111
  const callURL = "https://api.mynuheat.com/api/v1/Group";
73
112
  const callOptions = {
@@ -248,8 +287,14 @@ class NuHeatAPI {
248
287
  authEndpoint.searchParams.set("client_id", this.oauthClientId);
249
288
  authEndpoint.searchParams.set("redirect_uri", this.oauthRedirectUri);
250
289
  authEndpoint.searchParams.set("scope", this.getRequestedScope());
290
+ if (this.usePkce) {
291
+ this.pkceCodeVerifier = this.generatePkceCodeVerifier();
292
+ authEndpoint.searchParams.set("code_challenge", this.getPkceCodeChallenge(this.pkceCodeVerifier));
293
+ authEndpoint.searchParams.set("code_challenge_method", "S256");
294
+ }
251
295
  this.log.debug("NuHeatAPI: Requesting OAuth authorization page with scopes: " +
252
- this.getRequestedScope());
296
+ this.getRequestedScope() +
297
+ (this.usePkce ? " using PKCE." : "."));
253
298
  const response = await this.fetch(authEndpoint.toString(), {
254
299
  redirect: "follow",
255
300
  });
@@ -396,14 +441,7 @@ class NuHeatAPI {
396
441
  return null;
397
442
  }
398
443
  const redirectUrl = new URL(response.headers.get("location") || "");
399
- const requestBody = new URLSearchParams({
400
- client_id: this.oauthClientId,
401
- client_secret: this.oauthClientSecret,
402
- code: redirectUrl.searchParams.get("code") || "",
403
- grant_type: "authorization_code",
404
- redirect_uri: this.oauthRedirectUri,
405
- scope: redirectUrl.searchParams.get("scope") || "",
406
- });
444
+ const requestBody = this.buildAuthorizationCodeTokenRequest(redirectUrl);
407
445
  response = await this.fetch(settings_1.NUHEAT_API_TOKEN_URI, {
408
446
  body: requestBody.toString(),
409
447
  headers: {
@@ -426,14 +464,7 @@ class NuHeatAPI {
426
464
  return null;
427
465
  }
428
466
  async getRefreshedAccessToken() {
429
- const requestBody = new URLSearchParams({
430
- client_id: this.oauthClientId,
431
- client_secret: this.oauthClientSecret,
432
- grant_type: "refresh_token",
433
- redirect_uri: this.oauthRedirectUri,
434
- refresh_token: this.refreshToken,
435
- scope: this.tokenScope,
436
- });
467
+ const requestBody = this.buildRefreshTokenRequest();
437
468
  const response = await this.fetch(settings_1.NUHEAT_API_TOKEN_URI, {
438
469
  body: requestBody.toString(),
439
470
  headers: {
package/lib/settings.js CHANGED
@@ -1,8 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.NUHEAT_API_CONSENT_URI = exports.NUHEAT_API_TOKEN_URI = exports.NUHEAT_API_AUTHORIZE_URI = exports.NUHEAT_API_REDIRECT_URI = exports.NUHEAT_API_CLIENT_SECRET = exports.NUHEAT_API_CLIENT_ID = void 0;
4
- exports.NUHEAT_API_CLIENT_ID = "homebridge-nuheat";
5
- exports.NUHEAT_API_CLIENT_SECRET = "PkYnj7yq6b3r1H4PN/gehP7NPvSMCzTIXerRx0ZZpsE=";
4
+ exports.NUHEAT_API_CLIENT_ID = "homebridge-nuheat2_260421";
5
+ exports.NUHEAT_API_CLIENT_SECRET = "";
6
6
  exports.NUHEAT_API_REDIRECT_URI = "http://localhost";
7
7
  exports.NUHEAT_API_AUTHORIZE_URI = "https://identity.mynuheat.com/connect/authorize";
8
8
  exports.NUHEAT_API_TOKEN_URI = "https://identity.mynuheat.com/connect/token";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-nuheat2",
3
- "version": "1.2.15",
3
+ "version": "1.2.17",
4
4
  "description": "Homebridge Platform for NuHeat Signature Thermostats",
5
5
  "main": "index.js",
6
6
  "scripts": {