node-red-contrib-tdn-eventsub 0.1.0 → 0.2.1

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,30 @@ 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. |
40
42
  | Verify server certificate | Normal CA check when no fingerprint is set. |
41
43
  | 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
44
  | Client cert / key / PFX | Only if the REST Client item on the server has a thumbprint (pinned client certificate). |
43
45
 
46
+ ## Credentials object
47
+
48
+ 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:
49
+
50
+ ```js
51
+ {
52
+ apiKey: "XXXX-...", // required
53
+ authType: "authorization" | "header", // default: server config
54
+ authScheme: "", // authorization only; blank = Basic
55
+ headerName: "X-API-Key" // header only
56
+ }
57
+ ```
58
+
59
+ 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.
60
+
44
61
  Get the server fingerprint with:
45
62
 
46
63
  ```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,7 +286,9 @@
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": "",
283
293
  "rejectUnauthorized": true,
284
294
  "rewriteHrefs": true,
@@ -287,4 +297,4 @@
287
297
  "keyPath": "",
288
298
  "pfxPath": ""
289
299
  }
290
- ]
300
+ ]
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,52 @@ 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
+
39
87
  function abortError() {
40
88
  return new RestError('Request aborted', { code: 'ABORTED', aborted: true });
41
89
  }
@@ -45,8 +93,10 @@ class RestClient {
45
93
  * @param {object} o
46
94
  * @param {string} o.url host, IP or URL (https:// assumed)
47
95
  * @param {number} [o.port] default 8904
48
- * @param {string} o.apiKey
49
- * @param {string} [o.authScheme] '' = Basic with ':<key>'; otherwise sent as '<scheme> <key>'
96
+ * @param {string} [o.apiKey] optional; without it use withCredentials() per request source
97
+ * @param {string} [o.authType] 'authorization' (default) or 'header'
98
+ * @param {string} [o.authScheme] authorization: '' = Basic with ':<key>'; otherwise '<scheme> <key>'
99
+ * @param {string} [o.headerName] header: name of the key header (default X-API-Key)
50
100
  * @param {string} [o.fingerprint] server cert SHA-1 (40 hex) or SHA-256 (64 hex), any separators
51
101
  * @param {boolean} [o.rejectUnauthorized] used only when no fingerprint is set
52
102
  * @param {string} [o.certPath] [o.keyPath] [o.pfxPath] [o.passphrase] client certificate
@@ -66,13 +116,12 @@ class RestClient {
66
116
  this.base = u;
67
117
  const path = u.pathname && u.pathname !== '/' ? u.pathname.replace(/\/+$/, '') : '/api';
68
118
  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');
119
+ // no key here is fine: credentials can be supplied later with withCredentials().
120
+ // The auth settings are still checked now, so a bad scheme/header fails at deploy.
121
+ this.authDefaults = { authType: o.authType, authScheme: o.authScheme, headerName: o.headerName };
122
+ const hasKey = !!String(o.apiKey || '').trim();
123
+ const headers = buildAuthHeaders(Object.assign({}, o, { apiKey: hasKey ? o.apiKey : 'placeholder' }));
124
+ this.authHeaders = hasKey ? headers : null;
76
125
  this.fingerprint = normFp(o.fingerprint);
77
126
  if (this.fingerprint && this.fingerprint.length !== 40 && this.fingerprint.length !== 64) {
78
127
  throw new RestError('Certificate fingerprint must be SHA-1 (40 hex) or SHA-256 (64 hex)');
@@ -95,6 +144,20 @@ class RestClient {
95
144
  this._discoveryAt = 0;
96
145
  }
97
146
 
147
+ /**
148
+ * A client for the same server using other credentials (bare key string or
149
+ * { apiKey, authType, authScheme, headerName }; missing fields fall back to this
150
+ * client's auth settings). Shares TLS settings; has its own discovery cache,
151
+ * because what /api lists depends on the operator behind the key.
152
+ */
153
+ withCredentials(cred) {
154
+ const c = Object.create(this);
155
+ c.authHeaders = buildAuthHeaders(normaliseCredentials(cred, this.authDefaults));
156
+ c._discovery = null;
157
+ c._discoveryAt = 0;
158
+ return c;
159
+ }
160
+
98
161
  /** Resolve an href (absolute or relative) and optionally pin it to the configured host:port. */
99
162
  resolve(href) {
100
163
  const t = new URL(href, this.base.origin);
@@ -195,13 +258,15 @@ class RestClient {
195
258
  const signal = opts.signal;
196
259
  // The socket is opened (and the server cert checked) before any header leaves,
197
260
  // so the API key is never sent to a server that fails the fingerprint pin.
261
+ if (!this.authHeaders) {
262
+ throw new RestError('No API key: store one in the server config or pass a credentials object', { code: 'ENOCREDS' });
263
+ }
198
264
  const sock = await this._connect(target, signal, timeoutMs);
199
265
  const payload = body === undefined || body === null ? null : Buffer.from(JSON.stringify(body));
200
- const headers = {
201
- Authorization: this.authHeader,
266
+ const headers = Object.assign({
202
267
  Accept: 'application/json',
203
268
  Connection: 'close'
204
- };
269
+ }, this.authHeaders);
205
270
  if (payload) {
206
271
  headers['Content-Type'] = 'application/json';
207
272
  headers['Content-Length'] = payload.length;
@@ -319,4 +384,4 @@ class RestClient {
319
384
  }
320
385
  }
321
386
 
322
- module.exports = { RestClient, RestError, normFp, fmtFp, DEFAULT_PORT };
387
+ 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) {
@@ -22,11 +24,21 @@
22
24
  pfxPath: { value: '' }
23
25
  },
24
26
  credentials: {
25
- apiKey: { type: 'password', required: true },
27
+ apiKey: { type: 'password' },
26
28
  passphrase: { type: 'password' }
27
29
  },
28
30
  label: function () {
29
31
  return this.name || ((this.url || 'rest-server') + ':' + (this.port || 8904));
32
+ },
33
+ oneditprepare: function () {
34
+ var sync = function () {
35
+ var header = $('#node-config-input-authType').val() === 'header';
36
+ $('.tdn-rest-auth-scheme').toggle(!header);
37
+ $('.tdn-rest-auth-header').toggle(header);
38
+ };
39
+ if (!this.authType) $('#node-config-input-authType').val('authorization');
40
+ $('#node-config-input-authType').on('change', sync);
41
+ sync();
30
42
  }
31
43
  });
32
44
  </script>
@@ -46,12 +58,23 @@
46
58
  </div>
47
59
  <div class="form-row">
48
60
  <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">
61
+ <input type="password" id="node-config-input-apiKey" placeholder="blank = supplied per node (credentials object)">
50
62
  </div>
51
63
  <div class="form-row">
52
- <label for="node-config-input-authScheme"><i class="fa fa-lock"></i> Auth scheme</label>
64
+ <label for="node-config-input-authType"><i class="fa fa-lock"></i> Send key as</label>
65
+ <select id="node-config-input-authType" style="width:70%">
66
+ <option value="authorization">Authorization header</option>
67
+ <option value="header">API key header</option>
68
+ </select>
69
+ </div>
70
+ <div class="form-row tdn-rest-auth-scheme">
71
+ <label for="node-config-input-authScheme">Auth scheme</label>
53
72
  <input type="text" id="node-config-input-authScheme" placeholder="blank = Basic (server v9.0+)">
54
73
  </div>
74
+ <div class="form-row tdn-rest-auth-header">
75
+ <label for="node-config-input-headerName">Header name</label>
76
+ <input type="text" id="node-config-input-headerName" placeholder="X-API-Key">
77
+ </div>
55
78
  <div class="form-row">
56
79
  <label for="node-config-input-fingerprint"><i class="fa fa-certificate"></i> Cert fingerprint</label>
57
80
  <input type="text" id="node-config-input-fingerprint" placeholder="optional SHA-256 or SHA-1 of server cert">
@@ -99,11 +122,17 @@
99
122
  <dt>Port <span class="property-type">number</span></dt>
100
123
  <dd>REST port, default <code>8904</code>.</dd>
101
124
  <dt>API key <span class="property-type">credential</span></dt>
102
- <dd>Stored as a Node-RED credential and treated as opaque.</dd>
125
+ <dd>Optional. Stored as a Node-RED credential and treated as opaque. When set, it is always used.
126
+ When blank, each node takes the key from its <i>Credentials</i> object (default <code>msg.credentials</code>);
127
+ see the node's help.</dd>
128
+ <dt>Send key as</dt>
129
+ <dd><b>Authorization header</b> (default) or <b>API key header</b>. A credentials object can override this per message.</dd>
103
130
  <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+.
131
+ <dd>Authorization header only. Blank (default): <code>Authorization: Basic base64(":" + key)</code>, accepted by server v9.0+.
105
132
  For older servers, enter the API-key scheme token from your server's REST API documentation;
106
133
  the key is then sent as <code>Authorization: &lt;scheme&gt; &lt;key&gt;</code>.</dd>
134
+ <dt class="optional">Header name <span class="property-type">string</span></dt>
135
+ <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
136
  <dt class="optional">Cert fingerprint <span class="property-type">string</span></dt>
108
137
  <dd>SHA-256 (64 hex) or SHA-1 (40 hex) of the <b>server</b> certificate, colons optional.
109
138
  When set, the connection is accepted only if the presented certificate matches, even if it is self-signed.
@@ -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,7 +11,9 @@ 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;
14
19
  this.timeout = parseInt(config.timeout, 10) || 30000;
@@ -17,6 +22,7 @@ module.exports = function (RED) {
17
22
  this.pfxPath = config.pfxPath;
18
23
 
19
24
  const creds = this.credentials || {};
25
+ this.hasStoredKey = !!String(creds.apiKey || '').trim();
20
26
  this.client = null;
21
27
  this.clientError = null;
22
28
  try {
@@ -24,7 +30,9 @@ module.exports = function (RED) {
24
30
  url: this.url,
25
31
  port: this.port,
26
32
  apiKey: creds.apiKey,
33
+ authType: this.authType,
27
34
  authScheme: this.authScheme,
35
+ headerName: this.headerName,
28
36
  fingerprint: this.fingerprint,
29
37
  rejectUnauthorized: this.rejectUnauthorized,
30
38
  rewriteHrefs: this.rewriteHrefs,
@@ -34,18 +42,38 @@ module.exports = function (RED) {
34
42
  pfxPath: this.pfxPath,
35
43
  passphrase: creds.passphrase
36
44
  });
37
- if (!creds.apiKey) this.clientError = new Error('No API key configured');
38
45
  if (this.client.base.protocol === 'http:') this.warn('REST server URL is http:// - the API key will be sent unencrypted');
39
46
  } catch (e) {
40
47
  this.clientError = e;
41
48
  this.error('REST server config: ' + e.message);
42
49
  }
43
50
 
44
- /** Returns the client or throws the configuration error. */
45
- this.getClient = () => {
51
+ const credClients = new Map(); // sha256(normalised cred) -> client
52
+
53
+ /**
54
+ * Returns a client or throws.
55
+ * - API key stored in the Node-RED credentials: always used; `cred` is ignored.
56
+ * - Otherwise `cred` (bare key string or { apiKey, authType, authScheme, headerName })
57
+ * supplies the key; missing auth fields come from this config node.
58
+ */
59
+ this.getClient = (cred) => {
46
60
  if (this.clientError) throw this.clientError;
47
- return this.client;
61
+ if (this.hasStoredKey) return this.client;
62
+ if (cred === undefined || cred === null || cred === '') {
63
+ throw new RestError('No API key: none stored in the server config and no credentials object supplied', { code: 'ENOCREDS' });
64
+ }
65
+ const norm = normaliseCredentials(cred, this.client.authDefaults);
66
+ const id = crypto.createHash('sha256').update(JSON.stringify(norm)).digest('hex');
67
+ let c = credClients.get(id);
68
+ if (!c) {
69
+ c = this.client.withCredentials(norm);
70
+ if (credClients.size >= MAX_CRED_CLIENTS) credClients.delete(credClients.keys().next().value);
71
+ credClients.set(id, c);
72
+ }
73
+ return c;
48
74
  };
75
+
76
+ this.on('close', () => credClients.clear());
49
77
  }
50
78
 
51
79
  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.1.0",
3
+ "version": "0.2.1",
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": {