homebridge-nuheat2 1.2.15 → 1.2.16

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,13 @@ All notable changes to this project should be documented in this file
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.2.16] - 2026-04-29
8
+
9
+ ### Changed
10
+
11
+ - Switch the built-in Nuheat OAuth client to Authorization Code with PKCE using the public `homebridge-nuheat2_260421` client ID
12
+ - Keep legacy confidential-client OAuth overrides available when a custom `clientSecret` is explicitly configured
13
+
7
14
  ## [1.2.15] - 2026-04-27
8
15
 
9
16
  ### 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
 
@@ -320,15 +320,6 @@ async function saveOauth() {
320
320
  return;
321
321
  }
322
322
 
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
323
  const patch = {
333
324
  clientId: clientId || undefined,
334
325
  redirectUri: redirectUri && redirectUri !== "http://localhost" ? redirectUri : undefined,
@@ -336,7 +327,7 @@ async function saveOauth() {
336
327
 
337
328
  if (clientSecret) {
338
329
  patch.clientSecret = clientSecret;
339
- } else if (!clientId) {
330
+ } else if (!clientId || clientId !== (state.config?.clientId || "")) {
340
331
  patch.clientSecret = undefined;
341
332
  }
342
333
 
@@ -450,10 +441,10 @@ function updateOauthStatus() {
450
441
  const hasClientSecret = hasUsableClientSecret(elements.clientId.value.trim());
451
442
  const text = hasClientId
452
443
  ? hasClientSecret
453
- ? "Custom OAuth"
454
- : "Secret needed"
455
- : "Built-in fallback";
456
- setStatus(elements.oauthStatus, hasClientId && hasClientSecret, text);
444
+ ? "Custom legacy OAuth"
445
+ : "Custom PKCE"
446
+ : "Built-in PKCE";
447
+ setStatus(elements.oauthStatus, true, text);
457
448
  }
458
449
 
459
450
  function hasUsableClientSecret(clientId) {
@@ -590,9 +581,9 @@ function getOauthMode(config) {
590
581
  return "configured confidential client";
591
582
  }
592
583
  if (config.clientId) {
593
- return "incomplete custom client";
584
+ return "configured PKCE public client";
594
585
  }
595
- return "built-in fallback credentials";
586
+ return "built-in PKCE public client";
596
587
  }
597
588
 
598
589
  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,19 @@ 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
+ this.oauthClientId = configuredClientId || settings_1.NUHEAT_API_CLIENT_ID;
37
+ this.oauthClientSecret = configuredClientId
38
+ ? configuredClientSecret || settings_1.NUHEAT_API_CLIENT_SECRET
39
+ : "";
39
40
  this.oauthRedirectUri =
40
41
  options.redirectUri ||
41
42
  process.env.NUHEAT_API_REDIRECT_URI ||
42
43
  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;
44
+ this.usingBuiltInClient = !configuredClientId;
45
+ this.usePkce = !this.oauthClientSecret;
46
+ this.pkceCodeVerifier = "";
48
47
  this.headers = new HeadersCtor();
49
48
  this.headers.set("Content-Type", "application/json");
50
49
  this.headers.set("Accept", "application/json");
@@ -54,8 +53,10 @@ class NuHeatAPI {
54
53
  this.refreshToken = "";
55
54
  this.tokenScope = "";
56
55
  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.");
56
+ if (this.usingBuiltInClient) {
57
+ this.log.info("NuHeatAPI: Using built-in Nuheat PKCE public client ID " +
58
+ this.oauthClientId +
59
+ ".");
59
60
  }
60
61
  else {
61
62
  this.log.info("NuHeatAPI: Using configured OAuth client ID " + this.oauthClientId + ".");
@@ -63,11 +64,48 @@ class NuHeatAPI {
63
64
  this.log.debug("NuHeatAPI: OAuth redirect URI " +
64
65
  this.oauthRedirectUri +
65
66
  ". Requested scopes: " +
66
- this.getRequestedScope());
67
+ this.getRequestedScope() +
68
+ ". OAuth flow: " +
69
+ (this.usePkce ? "authorization_code_pkce" : "authorization_code_secret"));
67
70
  }
68
71
  getRequestedScope() {
69
72
  return OAUTH_SCOPES.join(" ");
70
73
  }
74
+ generatePkceCodeVerifier() {
75
+ return (0, node_crypto_1.randomBytes)(64).toString("base64url");
76
+ }
77
+ getPkceCodeChallenge(codeVerifier) {
78
+ return (0, node_crypto_1.createHash)("sha256").update(codeVerifier).digest("base64url");
79
+ }
80
+ buildAuthorizationCodeTokenRequest(redirectUrl) {
81
+ const requestBody = new URLSearchParams({
82
+ client_id: this.oauthClientId,
83
+ code: redirectUrl.searchParams.get("code") || "",
84
+ grant_type: "authorization_code",
85
+ redirect_uri: this.oauthRedirectUri,
86
+ scope: redirectUrl.searchParams.get("scope") || "",
87
+ });
88
+ if (this.usePkce) {
89
+ requestBody.set("code_verifier", this.pkceCodeVerifier);
90
+ }
91
+ else {
92
+ requestBody.set("client_secret", this.oauthClientSecret);
93
+ }
94
+ return requestBody;
95
+ }
96
+ buildRefreshTokenRequest() {
97
+ const requestBody = new URLSearchParams({
98
+ client_id: this.oauthClientId,
99
+ grant_type: "refresh_token",
100
+ refresh_token: this.refreshToken,
101
+ scope: this.tokenScope,
102
+ });
103
+ if (!this.usePkce) {
104
+ requestBody.set("client_secret", this.oauthClientSecret);
105
+ requestBody.set("redirect_uri", this.oauthRedirectUri);
106
+ }
107
+ return requestBody;
108
+ }
71
109
  async setAwayMode(groupId, awayMode) {
72
110
  const callURL = "https://api.mynuheat.com/api/v1/Group";
73
111
  const callOptions = {
@@ -248,8 +286,14 @@ class NuHeatAPI {
248
286
  authEndpoint.searchParams.set("client_id", this.oauthClientId);
249
287
  authEndpoint.searchParams.set("redirect_uri", this.oauthRedirectUri);
250
288
  authEndpoint.searchParams.set("scope", this.getRequestedScope());
289
+ if (this.usePkce) {
290
+ this.pkceCodeVerifier = this.generatePkceCodeVerifier();
291
+ authEndpoint.searchParams.set("code_challenge", this.getPkceCodeChallenge(this.pkceCodeVerifier));
292
+ authEndpoint.searchParams.set("code_challenge_method", "S256");
293
+ }
251
294
  this.log.debug("NuHeatAPI: Requesting OAuth authorization page with scopes: " +
252
- this.getRequestedScope());
295
+ this.getRequestedScope() +
296
+ (this.usePkce ? " using PKCE." : "."));
253
297
  const response = await this.fetch(authEndpoint.toString(), {
254
298
  redirect: "follow",
255
299
  });
@@ -396,14 +440,7 @@ class NuHeatAPI {
396
440
  return null;
397
441
  }
398
442
  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
- });
443
+ const requestBody = this.buildAuthorizationCodeTokenRequest(redirectUrl);
407
444
  response = await this.fetch(settings_1.NUHEAT_API_TOKEN_URI, {
408
445
  body: requestBody.toString(),
409
446
  headers: {
@@ -426,14 +463,7 @@ class NuHeatAPI {
426
463
  return null;
427
464
  }
428
465
  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
- });
466
+ const requestBody = this.buildRefreshTokenRequest();
437
467
  const response = await this.fetch(settings_1.NUHEAT_API_TOKEN_URI, {
438
468
  body: requestBody.toString(),
439
469
  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.16",
4
4
  "description": "Homebridge Platform for NuHeat Signature Thermostats",
5
5
  "main": "index.js",
6
6
  "scripts": {