node-red-contrib-tdn-eventsub 0.2.0 → 0.2.2

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/README.md CHANGED
@@ -9,7 +9,7 @@ Node-RED nodes for an access-control **REST Server API**.
9
9
  | `get items` | Downloads all items (optionally filtered by type/division), stores to global context | request/reply |
10
10
  | `item status` | Subscribes to status of one or more item (source) ids and keeps waiting for updates indefinitely | continuous |
11
11
 
12
- All share one **REST server** config node: URL/host, port (default 8904), API key, optional auth scheme, optional server certificate fingerprint, optional client certificate.
12
+ All share one **REST server** config node: URL/host, port (default 8904), API key (optional: can come from a credentials object instead), how to send it (Authorization or API key header), optional server certificate fingerprint, optional client certificate.
13
13
 
14
14
  Every node has a trigger input and **three outputs**:
15
15
 
@@ -34,13 +34,31 @@ Restart Node-RED. Nodes appear under **TDN REST**. Import the example via *Impor
34
34
  |---|---|
35
35
  | URL / host | `rest-server`, `192.168.1.10` or `https://rest-server`. `/api` path assumed. |
36
36
  | Port | Default `8904`. |
37
- | API key | Stored as a Node-RED credential; treated as opaque. |
38
- | Auth scheme | Blank (default): `Authorization: Basic base64(":" + key)`, accepted by server v9.0+. For older servers enter the API-key scheme token from your server's REST API docs; the key is then sent as `Authorization: <scheme> <key>`. |
37
+ | API key | Optional. Stored as a Node-RED credential; treated as opaque. When set it is always used. When blank, each node reads a [credentials object](#credentials-object). |
38
+ | Send key as | **Authorization header** (default) or **API key header**. |
39
+ | Auth scheme | Authorization header only. Blank (default): `Authorization: Basic base64(":" + key)`, accepted by server v9.0+. For older servers enter the API-key scheme token from your server's REST API docs; the key is then sent as `Authorization: <scheme> <key>`. |
40
+ | Header name | API key header only. Sent as `<header name>: <key>`; default `X-API-Key`. |
39
41
  | Cert fingerprint | Optional. SHA-256 (64 hex) or SHA-1 (40 hex) of the **server** certificate, colons optional. When set, the server's self-signed cert is accepted only if it matches; the check runs before any request is written, so the key is never sent to an impostor. A mismatch error prints the actual fingerprint. |
42
+ | Plain HTTP (insecure) | Unencrypted `http://` whatever the URL says; certificate settings are ignored and the key travels in clear text. For bench testing against a non-TLS endpoint only. A TLS error like `unexpected message` or `wrong version number` usually means the endpoint needs this; the error text says so. |
40
43
  | Verify server certificate | Normal CA check when no fingerprint is set. |
41
44
  | Force links onto this host:port | The server builds `href`s from its own machine name. With this on, followed links keep path + query but use your configured host/port. With it off, a link that would downgrade `https` to `http` is refused. |
42
45
  | Client cert / key / PFX | Only if the REST Client item on the server has a thumbprint (pinned client certificate). |
43
46
 
47
+ ## Credentials object
48
+
49
+ If the server config has **no API key stored** in Node-RED, every node takes the key from its **Credentials** property: a typedInput pointing at `msg`, `flow`, `global` or `env`, default `msg.credentials`. The value is either a bare key string or:
50
+
51
+ ```js
52
+ {
53
+ apiKey: "XXXX-...", // required
54
+ authType: "authorization" | "header", // default: server config
55
+ authScheme: "", // authorization only; blank = Basic
56
+ headerName: "X-API-Key" // header only
57
+ }
58
+ ```
59
+
60
+ Missing fields fall back to the server config. A stored key always takes precedence, and the credentials object is then ignored. A msg-borne credentials property is deleted from the msg before it is passed on, so the key doesn't reach debug nodes or downstream flows. The item status node reads the credentials once per trigger and keeps them for that subscription.
61
+
44
62
  Get the server fingerprint with:
45
63
 
46
64
  ```sh
@@ -91,7 +91,9 @@
91
91
  [
92
92
  "dbg_err"
93
93
  ]
94
- ]
94
+ ],
95
+ "creds": "credentials",
96
+ "credsType": "msg"
95
97
  },
96
98
  {
97
99
  "z": "rest_tab",
@@ -141,7 +143,9 @@
141
143
  [
142
144
  "dbg_err"
143
145
  ]
144
- ]
146
+ ],
147
+ "creds": "credentials",
148
+ "credsType": "msg"
145
149
  },
146
150
  {
147
151
  "z": "rest_tab",
@@ -191,7 +195,9 @@
191
195
  [
192
196
  "dbg_err"
193
197
  ]
194
- ]
198
+ ],
199
+ "creds": "credentials",
200
+ "credsType": "msg"
195
201
  },
196
202
  {
197
203
  "z": "rest_tab",
@@ -257,7 +263,9 @@
257
263
  [
258
264
  "dbg_err"
259
265
  ]
260
- ]
266
+ ],
267
+ "creds": "credentials",
268
+ "credsType": "msg"
261
269
  },
262
270
  {
263
271
  "z": "rest_tab",
@@ -278,8 +286,11 @@
278
286
  "name": "REST Server",
279
287
  "url": "192.168.1.10",
280
288
  "port": 8904,
289
+ "authType": "authorization",
281
290
  "authScheme": "",
291
+ "headerName": "X-API-Key",
282
292
  "fingerprint": "",
293
+ "plainHttp": false,
283
294
  "rejectUnauthorized": true,
284
295
  "rewriteHrefs": true,
285
296
  "timeout": 30000,
@@ -287,4 +298,4 @@
287
298
  "keyPath": "",
288
299
  "pfxPath": ""
289
300
  }
290
- ]
301
+ ]
package/lib/client.js CHANGED
@@ -3,7 +3,9 @@
3
3
  * Minimal REST Server API client.
4
4
  *
5
5
  * - Auth: "Authorization: Basic base64(':' + key)" by default (server v9.0+),
6
- * or "Authorization: <scheme> <key>" when a custom scheme token is configured
6
+ * "Authorization: <scheme> <key>" when a custom scheme token is configured,
7
+ * or the key in its own header ("X-API-Key: <key>" or a configured name)
8
+ * - Credentials can be fixed at construction or supplied later (withCredentials)
7
9
  * - HATEOAS: everything is discovered from GET /api (features.*.href)
8
10
  * - Optional server certificate pinning by SHA-1 or SHA-256 fingerprint
9
11
  * (lets you talk to a self-signed REST Server without disabling verification)
@@ -36,6 +38,64 @@ class RestError extends Error {
36
38
  }
37
39
  }
38
40
 
41
+ const AUTH_TYPES = ['authorization', 'header'];
42
+ const DEFAULT_KEY_HEADER = 'X-API-Key';
43
+ // headers we set ourselves; a key header must not replace them
44
+ const RESERVED_HEADERS = ['host', 'accept', 'connection', 'content-type', 'content-length', 'transfer-encoding'];
45
+
46
+ /**
47
+ * Normalise a credentials object. Accepts a bare key string, or
48
+ * { apiKey | key, authType, authScheme, headerName }; missing fields come from `defaults`.
49
+ */
50
+ function normaliseCredentials(cred, defaults) {
51
+ const d = defaults || {};
52
+ const c = typeof cred === 'string' ? { apiKey: cred } : Object.assign({}, cred || {});
53
+ const pick = (v, dv) => (v === undefined || v === null || v === '' ? dv : v);
54
+ return {
55
+ apiKey: String(pick(c.apiKey !== undefined ? c.apiKey : c.key, d.apiKey) || '').trim(),
56
+ authType: String(pick(c.authType, d.authType) || 'authorization').trim().toLowerCase(),
57
+ authScheme: String(pick(c.authScheme, d.authScheme) || '').trim(),
58
+ headerName: String(pick(c.headerName, d.headerName) || DEFAULT_KEY_HEADER).trim()
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Build the auth headers for one credential set:
64
+ * authType 'authorization': Authorization: Basic base64(':' + key) (blank scheme, server v9.0+)
65
+ * Authorization: <scheme> <key> (custom scheme token)
66
+ * authType 'header': <headerName>: <key> (default X-API-Key)
67
+ */
68
+ function buildAuthHeaders(cred) {
69
+ const c = normaliseCredentials(cred);
70
+ if (!c.apiKey) throw new RestError('No API key: store one in the server config or pass a credentials object', { code: 'ENOCREDS' });
71
+ if (/[\r\n]/.test(c.apiKey)) throw new RestError('API key must not contain line breaks');
72
+ if (!AUTH_TYPES.includes(c.authType)) throw new RestError(`Auth type must be one of: ${AUTH_TYPES.join(', ')}`);
73
+ if (c.authType === 'header') {
74
+ if (!TOKEN_RE.test(c.headerName) || RESERVED_HEADERS.includes(c.headerName.toLowerCase())) {
75
+ throw new RestError(`Invalid API key header name "${c.headerName}"`);
76
+ }
77
+ return { [c.headerName]: c.apiKey };
78
+ }
79
+ if (c.authScheme && !TOKEN_RE.test(c.authScheme)) throw new RestError('Auth scheme must be a single token (letters, digits, - _ . etc., no spaces)');
80
+ return {
81
+ Authorization: c.authScheme
82
+ ? c.authScheme + ' ' + c.apiKey
83
+ : 'Basic ' + Buffer.from(':' + c.apiKey, 'utf8').toString('base64')
84
+ };
85
+ }
86
+
87
+ // OpenSSL errors seen when the other end isn't speaking TLS at all
88
+ const NOT_TLS_CODES = ['ERR_SSL_WRONG_VERSION_NUMBER', 'ERR_SSL_UNEXPECTED_MESSAGE', 'ERR_SSL_PACKET_LENGTH_TOO_LONG', 'ERR_SSL_HTTP_REQUEST', 'ERR_SSL_RECORD_LAYER_FAILURE'];
89
+
90
+ /** Add a hint to TLS handshake errors that usually mean the endpoint is plain HTTP. */
91
+ function notTlsHint(e) {
92
+ if (e && NOT_TLS_CODES.includes(e.code)) {
93
+ e.message = `TLS handshake failed (${e.code}): the endpoint may not be using TLS. ` +
94
+ 'For a plain-HTTP endpoint tick "Plain HTTP (insecure)" in the server config. ' + e.message;
95
+ }
96
+ return e;
97
+ }
98
+
39
99
  function abortError() {
40
100
  return new RestError('Request aborted', { code: 'ABORTED', aborted: true });
41
101
  }
@@ -45,12 +105,15 @@ class RestClient {
45
105
  * @param {object} o
46
106
  * @param {string} o.url host, IP or URL (https:// assumed)
47
107
  * @param {number} [o.port] default 8904
48
- * @param {string} o.apiKey
49
- * @param {string} [o.authScheme] '' = Basic with ':<key>'; otherwise sent as '<scheme> <key>'
108
+ * @param {string} [o.apiKey] optional; without it use withCredentials() per request source
109
+ * @param {string} [o.authType] 'authorization' (default) or 'header'
110
+ * @param {string} [o.authScheme] authorization: '' = Basic with ':<key>'; otherwise '<scheme> <key>'
111
+ * @param {string} [o.headerName] header: name of the key header (default X-API-Key)
50
112
  * @param {string} [o.fingerprint] server cert SHA-1 (40 hex) or SHA-256 (64 hex), any separators
51
113
  * @param {boolean} [o.rejectUnauthorized] used only when no fingerprint is set
52
114
  * @param {string} [o.certPath] [o.keyPath] [o.pfxPath] [o.passphrase] client certificate
53
115
  * @param {boolean} [o.rewriteHrefs] force followed hrefs back onto the configured host:port
116
+ * @param {boolean} [o.plainHttp] use unencrypted http:// whatever the URL says (bench/test only)
54
117
  * @param {number} [o.timeoutMs] default request timeout
55
118
  */
56
119
  constructor(o) {
@@ -59,6 +122,7 @@ class RestClient {
59
122
  if (!raw) throw new RestError('No server URL configured');
60
123
  if (!/^https?:\/\//i.test(raw)) raw = 'https://' + raw;
61
124
  const u = new URL(raw);
125
+ if (o.plainHttp) u.protocol = 'http:';
62
126
  const port = parseInt(o.port, 10);
63
127
  if (port > 0) u.port = String(port);
64
128
  else if (!u.port) u.port = String(DEFAULT_PORT);
@@ -66,13 +130,12 @@ class RestClient {
66
130
  this.base = u;
67
131
  const path = u.pathname && u.pathname !== '/' ? u.pathname.replace(/\/+$/, '') : '/api';
68
132
  this.apiRoot = u.origin + path;
69
- this.apiKey = String(o.apiKey || '').trim();
70
- const scheme = String(o.authScheme || '').trim();
71
- if (scheme && !TOKEN_RE.test(scheme)) throw new RestError('Auth scheme must be a single token (letters, digits, - _ . etc., no spaces)');
72
- if (/[\r\n]/.test(this.apiKey)) throw new RestError('API key must not contain line breaks');
73
- this.authHeader = scheme
74
- ? scheme + ' ' + this.apiKey
75
- : 'Basic ' + Buffer.from(':' + this.apiKey, 'utf8').toString('base64');
133
+ // no key here is fine: credentials can be supplied later with withCredentials().
134
+ // The auth settings are still checked now, so a bad scheme/header fails at deploy.
135
+ this.authDefaults = { authType: o.authType, authScheme: o.authScheme, headerName: o.headerName };
136
+ const hasKey = !!String(o.apiKey || '').trim();
137
+ const headers = buildAuthHeaders(Object.assign({}, o, { apiKey: hasKey ? o.apiKey : 'placeholder' }));
138
+ this.authHeaders = hasKey ? headers : null;
76
139
  this.fingerprint = normFp(o.fingerprint);
77
140
  if (this.fingerprint && this.fingerprint.length !== 40 && this.fingerprint.length !== 64) {
78
141
  throw new RestError('Certificate fingerprint must be SHA-1 (40 hex) or SHA-256 (64 hex)');
@@ -95,6 +158,20 @@ class RestClient {
95
158
  this._discoveryAt = 0;
96
159
  }
97
160
 
161
+ /**
162
+ * A client for the same server using other credentials (bare key string or
163
+ * { apiKey, authType, authScheme, headerName }; missing fields fall back to this
164
+ * client's auth settings). Shares TLS settings; has its own discovery cache,
165
+ * because what /api lists depends on the operator behind the key.
166
+ */
167
+ withCredentials(cred) {
168
+ const c = Object.create(this);
169
+ c.authHeaders = buildAuthHeaders(normaliseCredentials(cred, this.authDefaults));
170
+ c._discovery = null;
171
+ c._discoveryAt = 0;
172
+ return c;
173
+ }
174
+
98
175
  /** Resolve an href (absolute or relative) and optionally pin it to the configured host:port. */
99
176
  resolve(href) {
100
177
  const t = new URL(href, this.base.origin);
@@ -167,7 +244,7 @@ class RestClient {
167
244
  if (!net.isIP(host)) opts.servername = host;
168
245
 
169
246
  sock = tls.connect(opts);
170
- sock.once('error', (e) => finish(e));
247
+ sock.once('error', (e) => finish(notTlsHint(e)));
171
248
  sock.once('secureConnect', () => {
172
249
  if (pinning) {
173
250
  const c = sock.getPeerCertificate();
@@ -195,13 +272,15 @@ class RestClient {
195
272
  const signal = opts.signal;
196
273
  // The socket is opened (and the server cert checked) before any header leaves,
197
274
  // so the API key is never sent to a server that fails the fingerprint pin.
275
+ if (!this.authHeaders) {
276
+ throw new RestError('No API key: store one in the server config or pass a credentials object', { code: 'ENOCREDS' });
277
+ }
198
278
  const sock = await this._connect(target, signal, timeoutMs);
199
279
  const payload = body === undefined || body === null ? null : Buffer.from(JSON.stringify(body));
200
- const headers = {
201
- Authorization: this.authHeader,
280
+ const headers = Object.assign({
202
281
  Accept: 'application/json',
203
282
  Connection: 'close'
204
- };
283
+ }, this.authHeaders);
205
284
  if (payload) {
206
285
  headers['Content-Type'] = 'application/json';
207
286
  headers['Content-Length'] = payload.length;
@@ -319,4 +398,4 @@ class RestClient {
319
398
  }
320
399
  }
321
400
 
322
- module.exports = { RestClient, RestError, normFp, fmtFp, DEFAULT_PORT };
401
+ module.exports = { RestClient, RestError, normaliseCredentials, buildAuthHeaders, normFp, fmtFp, DEFAULT_PORT, DEFAULT_KEY_HEADER };
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+ /**
3
+ * Credentials object lookup for functional nodes.
4
+ *
5
+ * Used only when the server config node has no API key stored in Node-RED's
6
+ * credentials. The node's "Credentials" property (typedInput: msg / flow / global / env)
7
+ * points at either a bare key string or
8
+ * { apiKey, authType: 'authorization'|'header', authScheme, headerName }
9
+ */
10
+
11
+ const CRED_TYPES = ['msg', 'flow', 'global', 'env'];
12
+
13
+ /** Read node.creds/node.credsType config into the node (default msg.credentials). */
14
+ function configureCreds(node, config) {
15
+ node.credsType = CRED_TYPES.includes(config.credsType) ? config.credsType : 'msg';
16
+ node.creds = config.creds === undefined || config.creds === null || config.creds === '' ? 'credentials' : String(config.creds).trim();
17
+ }
18
+
19
+ /** Resolve the credentials object for this message (undefined if absent). */
20
+ function resolveCreds(RED, node, msg) {
21
+ if (node.server && node.server.hasStoredKey) return Promise.resolve(undefined); // stored key wins; don't touch context
22
+ return new Promise((resolve, reject) => {
23
+ RED.util.evaluateNodeProperty(node.creds, node.credsType, node, msg, (err, value) => {
24
+ if (err) reject(err); else resolve(value);
25
+ });
26
+ });
27
+ }
28
+
29
+ /** Remove a msg-borne credentials object so the key doesn't travel downstream. */
30
+ function stripCreds(RED, node, msg) {
31
+ if (node.credsType !== 'msg' || !msg) return;
32
+ let parts;
33
+ try { parts = RED.util.normalisePropertyExpression(node.creds); } catch (e) { return; }
34
+ let obj = msg;
35
+ for (let i = 0; i < parts.length - 1; i++) {
36
+ obj = obj[parts[i]];
37
+ if (!obj || typeof obj !== 'object') return;
38
+ }
39
+ delete obj[parts[parts.length - 1]];
40
+ }
41
+
42
+ module.exports = { CRED_TYPES, configureCreds, resolveCreds, stripCreds };
@@ -10,7 +10,9 @@
10
10
  maxRetryDelay: { value: 60000, validate: RED.validators.number() },
11
11
  emitTimeouts: { value: false },
12
12
  contextKey: { value: '' },
13
- contextStore: { value: '' }
13
+ contextStore: { value: '' },
14
+ creds: { value: 'credentials' },
15
+ credsType: { value: 'msg' }
14
16
  },
15
17
  inputs: 1,
16
18
  outputs: 3,
@@ -19,6 +21,7 @@
19
21
  label: function () { return this.name || 'item status updates'; },
20
22
  paletteLabel: 'item status',
21
23
  oneditprepare: function () {
24
+ $('#node-input-creds').typedInput({ default: 'msg', types: ['msg', 'flow', 'global', 'env'], typeField: '#node-input-credsType' });
22
25
  var sel = $('#node-input-contextStore');
23
26
  var stores = (RED.settings.context && RED.settings.context.stores) || ['default'];
24
27
  sel.empty();
@@ -38,6 +41,11 @@
38
41
  <label for="node-input-server"><i class="fa fa-server"></i> Server</label>
39
42
  <input type="text" id="node-input-server">
40
43
  </div>
44
+ <div class="form-row">
45
+ <label for="node-input-creds"><i class="fa fa-key"></i> Credentials</label>
46
+ <input type="text" id="node-input-creds" style="width:70%">
47
+ <input type="hidden" id="node-input-credsType">
48
+ </div>
41
49
  <div class="form-row">
42
50
  <label for="node-input-pollDelay">Delay between waits (ms)</label>
43
51
  <input type="text" id="node-input-pollDelay" style="width:100px"> <span>0&ndash;20000</span>
@@ -79,7 +87,11 @@
79
87
  <dd>The source id(s): <code>"508"</code>, <code>"508,526"</code>, <code>["508","526"]</code>,
80
88
  <code>{"itemIds":["508","526"]}</code>, or an array of item objects with an <code>id</code> (e.g. straight from <i>get items</i> results).
81
89
  <code>msg.itemIds</code> takes precedence if present.</dd>
82
- <dd>A new trigger replaces the running subscription. <code>payload</code> = <code>"stop"</code> or <code>msg.stop = true</code> stops it.</dd>
90
+ <dt class="optional">credentials <span class="property-type">string | object</span></dt><dd>Used only when the server config has no API key stored.
91
+ Read from the <i>Credentials</i> property (default <code>msg.credentials</code>; msg, flow, global or env): a bare key string, or
92
+ <code>{"apiKey":"...", "authType":"authorization"|"header", "authScheme":"...", "headerName":"X-API-Key"}</code>.
93
+ Missing fields fall back to the server config. A msg-borne credentials property is removed from the trigger msg.</dd>
94
+ <dd>Credentials are read once per trigger and kept for that subscription. A new trigger replaces the running subscription. <code>payload</code> = <code>"stop"</code> or <code>msg.stop = true</code> stops it.</dd>
83
95
  </dl>
84
96
  <h3>Outputs</h3>
85
97
  <ol class="node-ports">
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
  const { OUT_RESPONSE, send, setStatus, reportError, storeName, nowIso } = require('../lib/outputs');
3
+ const { configureCreds, resolveCreds, stripCreds } = require('../lib/credentials');
3
4
 
4
5
  /**
5
6
  * Item status subscription (REST Server v8.30+, RESTStatus licence).
@@ -50,6 +51,7 @@ module.exports = function (RED) {
50
51
  node.emitTimeouts = config.emitTimeouts === true;
51
52
  node.contextKey = (config.contextKey || '').trim();
52
53
  node.contextStore = config.contextStore || '';
54
+ configureCreds(node, config);
53
55
 
54
56
  let run = null; // { ctrl, ids }
55
57
 
@@ -87,10 +89,11 @@ module.exports = function (RED) {
87
89
  });
88
90
  }
89
91
 
90
- async function loop(ids, ctrl) {
92
+ async function loop(ids, ctrl, cred) {
91
93
  const signal = ctrl.signal;
92
94
  let client;
93
- try { client = node.server.getClient(); } catch (e) { reportError(node, e); return; }
95
+ // credentials are taken once, from the trigger; the subscription keeps using them
96
+ try { client = node.server.getClient(cred); } catch (e) { reportError(node, e); return; }
94
97
 
95
98
  let next = null;
96
99
  let failures = 0;
@@ -161,13 +164,23 @@ module.exports = function (RED) {
161
164
  if (!node.server) setStatus(node, 'error', 'no server config');
162
165
  else node.status({ fill: 'grey', shape: 'ring', text: 'idle' });
163
166
 
164
- node.on('input', function (msg, sendFn, done) {
167
+ node.on('input', async function (msg, sendFn, done) {
165
168
  done = done || function () {};
166
169
  if (isStop(msg)) {
170
+ stripCreds(RED, node, msg);
167
171
  if (run) stop('stopped by input');
168
172
  else setStatus(node, 'stopped', 'not running');
169
173
  return done();
170
174
  }
175
+ let cred;
176
+ try {
177
+ cred = await resolveCreds(RED, node, msg);
178
+ } catch (e) {
179
+ reportError(node, e);
180
+ return done();
181
+ } finally {
182
+ stripCreds(RED, node, msg);
183
+ }
171
184
  if (!node.server) {
172
185
  reportError(node, new Error('No REST server configured'));
173
186
  return done();
@@ -180,7 +193,7 @@ module.exports = function (RED) {
180
193
  if (run) stop('replaced by new subscription');
181
194
  const ctrl = new AbortController();
182
195
  run = { ctrl, ids };
183
- loop(ids, ctrl).catch((e) => reportError(node, e)).finally(() => {
196
+ loop(ids, ctrl, cred).catch((e) => reportError(node, e)).finally(() => {
184
197
  if (run && run.ctrl === ctrl) run = null;
185
198
  });
186
199
  done(); // the trigger message is handled; the subscription carries on by itself
@@ -15,7 +15,9 @@
15
15
  fields: { value: '' },
16
16
  query: { value: '' },
17
17
  maxPages: { value: 0, validate: RED.validators.number() },
18
- includePages: { value: true }
18
+ includePages: { value: true },
19
+ creds: { value: 'credentials' },
20
+ credsType: { value: 'msg' }
19
21
  },
20
22
  inputs: 1,
21
23
  outputs: 3,
@@ -24,6 +26,7 @@
24
26
  label: function () { return this.name || ('get ' + d.palette); },
25
27
  paletteLabel: 'get ' + d.palette,
26
28
  oneditprepare: function () {
29
+ $('#node-input-creds').typedInput({ default: 'msg', types: ['msg', 'flow', 'global', 'env'], typeField: '#node-input-credsType' });
27
30
  var sel = $('#node-input-contextStore');
28
31
  var stores = (RED.settings.context && RED.settings.context.stores) || ['default'];
29
32
  sel.empty();
@@ -45,6 +48,11 @@
45
48
  <label for="node-input-server"><i class="fa fa-server"></i> Server</label>
46
49
  <input type="text" id="node-input-server">
47
50
  </div>
51
+ <div class="form-row">
52
+ <label for="node-input-creds"><i class="fa fa-key"></i> Credentials</label>
53
+ <input type="text" id="node-input-creds" style="width:70%">
54
+ <input type="hidden" id="node-input-credsType">
55
+ </div>
48
56
  <div class="form-row">
49
57
  <label for="node-input-contextKey"><i class="fa fa-globe"></i> Global key</label>
50
58
  <input type="text" id="node-input-contextKey" placeholder="rest_accessGroups">
@@ -94,6 +102,10 @@
94
102
  <dt class="optional">fields <span class="property-type">string</span></dt><dd>Overrides the <i>Fields</i> setting.</dd>
95
103
  <dt class="optional">top / maxPages <span class="property-type">number</span></dt><dd>Override page size / page limit.</dd>
96
104
  <dt class="optional">contextKey <span class="property-type">string</span></dt><dd>Overrides the global key.</dd>
105
+ <dt class="optional">credentials <span class="property-type">string | object</span></dt><dd>Used only when the server config has no API key stored.
106
+ Read from the <i>Credentials</i> property (default <code>msg.credentials</code>; msg, flow, global or env): a bare key string, or
107
+ <code>{"apiKey":"...", "authType":"authorization"|"header", "authScheme":"...", "headerName":"X-API-Key"}</code>.
108
+ Missing fields fall back to the server config. A msg-borne credentials property is removed before the msg is passed on.</dd>
97
109
  </dl>
98
110
  <h3>Outputs</h3>
99
111
  <ol class="node-ports">
@@ -122,6 +134,11 @@
122
134
  <label for="node-input-server"><i class="fa fa-server"></i> Server</label>
123
135
  <input type="text" id="node-input-server">
124
136
  </div>
137
+ <div class="form-row">
138
+ <label for="node-input-creds"><i class="fa fa-key"></i> Credentials</label>
139
+ <input type="text" id="node-input-creds" style="width:70%">
140
+ <input type="hidden" id="node-input-credsType">
141
+ </div>
125
142
  <div class="form-row">
126
143
  <label for="node-input-contextKey"><i class="fa fa-globe"></i> Global key</label>
127
144
  <input type="text" id="node-input-contextKey" placeholder="rest_cardholders">
@@ -171,6 +188,10 @@
171
188
  <dt class="optional">fields <span class="property-type">string</span></dt><dd>Overrides the <i>Fields</i> setting.</dd>
172
189
  <dt class="optional">top / maxPages <span class="property-type">number</span></dt><dd>Override page size / page limit.</dd>
173
190
  <dt class="optional">contextKey <span class="property-type">string</span></dt><dd>Overrides the global key.</dd>
191
+ <dt class="optional">credentials <span class="property-type">string | object</span></dt><dd>Used only when the server config has no API key stored.
192
+ Read from the <i>Credentials</i> property (default <code>msg.credentials</code>; msg, flow, global or env): a bare key string, or
193
+ <code>{"apiKey":"...", "authType":"authorization"|"header", "authScheme":"...", "headerName":"X-API-Key"}</code>.
194
+ Missing fields fall back to the server config. A msg-borne credentials property is removed before the msg is passed on.</dd>
174
195
  </dl>
175
196
  <h3>Outputs</h3>
176
197
  <ol class="node-ports">
@@ -199,6 +220,11 @@
199
220
  <label for="node-input-server"><i class="fa fa-server"></i> Server</label>
200
221
  <input type="text" id="node-input-server">
201
222
  </div>
223
+ <div class="form-row">
224
+ <label for="node-input-creds"><i class="fa fa-key"></i> Credentials</label>
225
+ <input type="text" id="node-input-creds" style="width:70%">
226
+ <input type="hidden" id="node-input-credsType">
227
+ </div>
202
228
  <div class="form-row">
203
229
  <label for="node-input-contextKey"><i class="fa fa-globe"></i> Global key</label>
204
230
  <input type="text" id="node-input-contextKey" placeholder="rest_items">
@@ -248,6 +274,10 @@
248
274
  <dt class="optional">fields <span class="property-type">string</span></dt><dd>Overrides the <i>Fields</i> setting.</dd>
249
275
  <dt class="optional">top / maxPages <span class="property-type">number</span></dt><dd>Override page size / page limit.</dd>
250
276
  <dt class="optional">contextKey <span class="property-type">string</span></dt><dd>Overrides the global key.</dd>
277
+ <dt class="optional">credentials <span class="property-type">string | object</span></dt><dd>Used only when the server config has no API key stored.
278
+ Read from the <i>Credentials</i> property (default <code>msg.credentials</code>; msg, flow, global or env): a bare key string, or
279
+ <code>{"apiKey":"...", "authType":"authorization"|"header", "authScheme":"...", "headerName":"X-API-Key"}</code>.
280
+ Missing fields fall back to the server config. A msg-borne credentials property is removed before the msg is passed on.</dd>
251
281
  </dl>
252
282
  <h3>Outputs</h3>
253
283
  <ol class="node-ports">
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
  const { RestClient } = require('../lib/client');
3
3
  const { OUT_RESPONSE, send, setStatus, reportError, storeName, nowIso } = require('../lib/outputs');
4
+ const { configureCreds, resolveCreds, stripCreds } = require('../lib/credentials');
4
5
 
5
6
  /**
6
7
  * Request/reply "list" nodes. Each trigger:
@@ -28,6 +29,7 @@ module.exports = function (RED) {
28
29
  node.query = (config.query || '').trim();
29
30
  node.maxPages = parseInt(config.maxPages, 10) || 0;
30
31
  node.includePages = config.includePages !== false;
32
+ configureCreds(node, config);
31
33
 
32
34
  let running = null; // AbortController of in-flight run
33
35
 
@@ -48,7 +50,13 @@ module.exports = function (RED) {
48
50
  running = ctrl;
49
51
  const started = Date.now();
50
52
  try {
51
- const client = node.server.getClient();
53
+ let cred;
54
+ try {
55
+ cred = await resolveCreds(RED, node, msg);
56
+ } finally {
57
+ stripCreds(RED, node, msg); // never pass a key downstream, even on error
58
+ }
59
+ const client = node.server.getClient(cred);
52
60
  setStatus(node, 'requesting', `requesting ${def.label}`);
53
61
  const firstHref = await client.feature(def.feature, { signal: ctrl.signal });
54
62
 
@@ -5,7 +5,9 @@
5
5
  name: { value: '' },
6
6
  url: { value: '', required: true },
7
7
  port: { value: 8904, required: true, validate: RED.validators.number() },
8
+ authType: { value: 'authorization' },
8
9
  authScheme: { value: '', validate: function (v) { return !v || /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(String(v).trim()); } },
10
+ headerName: { value: 'X-API-Key', validate: function (v) { return !v || /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(String(v).trim()); } },
9
11
  fingerprint: {
10
12
  value: '',
11
13
  validate: function (v) {
@@ -14,6 +16,7 @@
14
16
  return h.length === 40 || h.length === 64;
15
17
  }
16
18
  },
19
+ plainHttp: { value: false },
17
20
  rejectUnauthorized: { value: true },
18
21
  rewriteHrefs: { value: true },
19
22
  timeout: { value: 30000, validate: RED.validators.number() },
@@ -22,11 +25,28 @@
22
25
  pfxPath: { value: '' }
23
26
  },
24
27
  credentials: {
25
- apiKey: { type: 'password', required: true },
28
+ apiKey: { type: 'password' },
26
29
  passphrase: { type: 'password' }
27
30
  },
28
31
  label: function () {
29
32
  return this.name || ((this.url || 'rest-server') + ':' + (this.port || 8904));
33
+ },
34
+ oneditprepare: function () {
35
+ var sync = function () {
36
+ var header = $('#node-config-input-authType').val() === 'header';
37
+ $('.tdn-rest-auth-scheme').toggle(!header);
38
+ $('.tdn-rest-auth-header').toggle(header);
39
+ };
40
+ if (!this.authType) $('#node-config-input-authType').val('authorization');
41
+ $('#node-config-input-authType').on('change', sync);
42
+ sync();
43
+ var syncTls = function () {
44
+ var plain = $('#node-config-input-plainHttp').is(':checked');
45
+ $('.tdn-rest-tls').toggle(!plain);
46
+ $('.tdn-rest-plain-warning').toggle(plain);
47
+ };
48
+ $('#node-config-input-plainHttp').on('change', syncTls);
49
+ syncTls();
30
50
  }
31
51
  });
32
52
  </script>
@@ -46,17 +66,34 @@
46
66
  </div>
47
67
  <div class="form-row">
48
68
  <label for="node-config-input-apiKey"><i class="fa fa-key"></i> API key</label>
49
- <input type="password" id="node-config-input-apiKey" placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX">
69
+ <input type="password" id="node-config-input-apiKey" placeholder="blank = supplied per node (credentials object)">
50
70
  </div>
51
71
  <div class="form-row">
52
- <label for="node-config-input-authScheme"><i class="fa fa-lock"></i> Auth scheme</label>
72
+ <label for="node-config-input-authType"><i class="fa fa-lock"></i> Send key as</label>
73
+ <select id="node-config-input-authType" style="width:70%">
74
+ <option value="authorization">Authorization header</option>
75
+ <option value="header">API key header</option>
76
+ </select>
77
+ </div>
78
+ <div class="form-row tdn-rest-auth-scheme">
79
+ <label for="node-config-input-authScheme">Auth scheme</label>
53
80
  <input type="text" id="node-config-input-authScheme" placeholder="blank = Basic (server v9.0+)">
54
81
  </div>
82
+ <div class="form-row tdn-rest-auth-header">
83
+ <label for="node-config-input-headerName">Header name</label>
84
+ <input type="text" id="node-config-input-headerName" placeholder="X-API-Key">
85
+ </div>
55
86
  <div class="form-row">
87
+ <label>&nbsp;</label>
88
+ <input type="checkbox" id="node-config-input-plainHttp" style="display:inline-block;width:auto;vertical-align:top">
89
+ <label for="node-config-input-plainHttp" style="width:auto">Plain HTTP (insecure, no TLS)</label>
90
+ </div>
91
+ <div class="form-tips tdn-rest-plain-warning"><b>Insecure:</b> the API key and all data are sent unencrypted. Use only for bench testing against a non-TLS endpoint.</div>
92
+ <div class="form-row tdn-rest-tls">
56
93
  <label for="node-config-input-fingerprint"><i class="fa fa-certificate"></i> Cert fingerprint</label>
57
94
  <input type="text" id="node-config-input-fingerprint" placeholder="optional SHA-256 or SHA-1 of server cert">
58
95
  </div>
59
- <div class="form-row">
96
+ <div class="form-row tdn-rest-tls">
60
97
  <label>&nbsp;</label>
61
98
  <input type="checkbox" id="node-config-input-rejectUnauthorized" style="display:inline-block;width:auto;vertical-align:top">
62
99
  <label for="node-config-input-rejectUnauthorized" style="width:auto">Verify server certificate against CA (ignored when a fingerprint is set)</label>
@@ -70,6 +107,7 @@
70
107
  <label for="node-config-input-timeout"><i class="fa fa-clock-o"></i> Timeout (ms)</label>
71
108
  <input type="text" id="node-config-input-timeout" placeholder="30000" style="width:100px">
72
109
  </div>
110
+ <div class="tdn-rest-tls">
73
111
  <hr>
74
112
  <div class="form-tips">Client certificate &mdash; only if the REST Client item on the server has a thumbprint set. Use PEM cert + key, <i>or</i> a PFX.</div>
75
113
  <div class="form-row">
@@ -88,6 +126,7 @@
88
126
  <label for="node-config-input-passphrase">Passphrase</label>
89
127
  <input type="password" id="node-config-input-passphrase">
90
128
  </div>
129
+ </div>
91
130
  </script>
92
131
 
93
132
  <script type="text/html" data-help-name="tdn-rest-server">
@@ -99,16 +138,26 @@
99
138
  <dt>Port <span class="property-type">number</span></dt>
100
139
  <dd>REST port, default <code>8904</code>.</dd>
101
140
  <dt>API key <span class="property-type">credential</span></dt>
102
- <dd>Stored as a Node-RED credential and treated as opaque.</dd>
141
+ <dd>Optional. Stored as a Node-RED credential and treated as opaque. When set, it is always used.
142
+ When blank, each node takes the key from its <i>Credentials</i> object (default <code>msg.credentials</code>);
143
+ see the node's help.</dd>
144
+ <dt>Send key as</dt>
145
+ <dd><b>Authorization header</b> (default) or <b>API key header</b>. A credentials object can override this per message.</dd>
103
146
  <dt class="optional">Auth scheme <span class="property-type">string</span></dt>
104
- <dd>Blank (default): the key is sent as <code>Authorization: Basic base64(":" + key)</code>, accepted by server v9.0+.
147
+ <dd>Authorization header only. Blank (default): <code>Authorization: Basic base64(":" + key)</code>, accepted by server v9.0+.
105
148
  For older servers, enter the API-key scheme token from your server's REST API documentation;
106
149
  the key is then sent as <code>Authorization: &lt;scheme&gt; &lt;key&gt;</code>.</dd>
150
+ <dt class="optional">Header name <span class="property-type">string</span></dt>
151
+ <dd>API key header only. The key is sent as <code>&lt;header name&gt;: &lt;key&gt;</code>; default <code>X-API-Key</code>.</dd>
107
152
  <dt class="optional">Cert fingerprint <span class="property-type">string</span></dt>
108
153
  <dd>SHA-256 (64 hex) or SHA-1 (40 hex) of the <b>server</b> certificate, colons optional.
109
154
  When set, the connection is accepted only if the presented certificate matches, even if it is self-signed.
110
155
  The check happens before any request (and therefore the API key) is sent.
111
156
  A mismatch error reports the actual fingerprint so you can copy it.</dd>
157
+ <dt>Plain HTTP (insecure)</dt>
158
+ <dd>Connect with unencrypted <code>http://</code> whatever the URL says. The API key travels in clear text and the
159
+ certificate settings are ignored, so use it only for bench testing against a non-TLS endpoint. A TLS handshake error such as
160
+ <code>unexpected message</code> or <code>wrong version number</code> usually means the endpoint needs this.</dd>
112
161
  <dt>Verify server certificate</dt>
113
162
  <dd>Normal CA verification when no fingerprint is set. Untick only for lab use.</dd>
114
163
  <dt>Force returned links back onto this host:port</dt>
@@ -1,5 +1,8 @@
1
1
  'use strict';
2
- const { RestClient } = require('../lib/client');
2
+ const crypto = require('crypto');
3
+ const { RestClient, RestError, normaliseCredentials } = require('../lib/client');
4
+
5
+ const MAX_CRED_CLIENTS = 20; // per-credential clients kept (each holds its own discovery cache)
3
6
 
4
7
  module.exports = function (RED) {
5
8
  function RestServerNode(config) {
@@ -8,15 +11,19 @@ module.exports = function (RED) {
8
11
  this.url = config.url;
9
12
  this.port = config.port;
10
13
  this.fingerprint = config.fingerprint;
14
+ this.authType = config.authType || 'authorization';
11
15
  this.authScheme = (config.authScheme || '').trim();
16
+ this.headerName = (config.headerName || '').trim();
12
17
  this.rejectUnauthorized = config.rejectUnauthorized !== false;
13
18
  this.rewriteHrefs = config.rewriteHrefs !== false;
19
+ this.plainHttp = config.plainHttp === true;
14
20
  this.timeout = parseInt(config.timeout, 10) || 30000;
15
21
  this.certPath = config.certPath;
16
22
  this.keyPath = config.keyPath;
17
23
  this.pfxPath = config.pfxPath;
18
24
 
19
25
  const creds = this.credentials || {};
26
+ this.hasStoredKey = !!String(creds.apiKey || '').trim();
20
27
  this.client = null;
21
28
  this.clientError = null;
22
29
  try {
@@ -24,28 +31,54 @@ module.exports = function (RED) {
24
31
  url: this.url,
25
32
  port: this.port,
26
33
  apiKey: creds.apiKey,
34
+ authType: this.authType,
27
35
  authScheme: this.authScheme,
36
+ headerName: this.headerName,
28
37
  fingerprint: this.fingerprint,
29
38
  rejectUnauthorized: this.rejectUnauthorized,
30
39
  rewriteHrefs: this.rewriteHrefs,
40
+ plainHttp: this.plainHttp,
31
41
  timeoutMs: this.timeout,
32
42
  certPath: this.certPath,
33
43
  keyPath: this.keyPath,
34
44
  pfxPath: this.pfxPath,
35
45
  passphrase: creds.passphrase
36
46
  });
37
- if (!creds.apiKey) this.clientError = new Error('No API key configured');
38
- if (this.client.base.protocol === 'http:') this.warn('REST server URL is http:// - the API key will be sent unencrypted');
47
+ if (this.client.base.protocol === 'http:') {
48
+ this.warn('REST server uses plain HTTP - the API key will be sent unencrypted' +
49
+ (this.fingerprint || this.certPath || this.pfxPath ? '; certificate settings are ignored' : ''));
50
+ }
39
51
  } catch (e) {
40
52
  this.clientError = e;
41
53
  this.error('REST server config: ' + e.message);
42
54
  }
43
55
 
44
- /** Returns the client or throws the configuration error. */
45
- this.getClient = () => {
56
+ const credClients = new Map(); // sha256(normalised cred) -> client
57
+
58
+ /**
59
+ * Returns a client or throws.
60
+ * - API key stored in the Node-RED credentials: always used; `cred` is ignored.
61
+ * - Otherwise `cred` (bare key string or { apiKey, authType, authScheme, headerName })
62
+ * supplies the key; missing auth fields come from this config node.
63
+ */
64
+ this.getClient = (cred) => {
46
65
  if (this.clientError) throw this.clientError;
47
- return this.client;
66
+ if (this.hasStoredKey) return this.client;
67
+ if (cred === undefined || cred === null || cred === '') {
68
+ throw new RestError('No API key: none stored in the server config and no credentials object supplied', { code: 'ENOCREDS' });
69
+ }
70
+ const norm = normaliseCredentials(cred, this.client.authDefaults);
71
+ const id = crypto.createHash('sha256').update(JSON.stringify(norm)).digest('hex');
72
+ let c = credClients.get(id);
73
+ if (!c) {
74
+ c = this.client.withCredentials(norm);
75
+ if (credClients.size >= MAX_CRED_CLIENTS) credClients.delete(credClients.keys().next().value);
76
+ credClients.set(id, c);
77
+ }
78
+ return c;
48
79
  };
80
+
81
+ this.on('close', () => credClients.clear());
49
82
  }
50
83
 
51
84
  RED.nodes.registerType('tdn-rest-server', RestServerNode, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-tdn-eventsub",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Node-RED nodes for an access-control REST Server API: access groups, cardholders and items lists to global context, plus continuous item status subscriptions.",
5
5
  "main": "lib/client.js",
6
6
  "scripts": {