nodebb-plugin-session-sharing 8.0.5 → 9.0.0

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
@@ -1,8 +1,8 @@
1
1
  # Session Sharing for NodeBB
2
2
 
3
3
  In a nutshell, this plugin allows you to share sessions between your application and NodeBB. You'll need to set a
4
- special cookie with a common domain, containing a JSON Web Token with user data. If sufficient, this plugin will
5
- handle the rest (user registration/login).
4
+ special cookie with a common domain, containing a JSON Web Token (signed, or encrypted) with user data. If
5
+ sufficient, this plugin will handle the rest (user registration/login).
6
6
 
7
7
  ## How is this related to SSO?
8
8
 
@@ -21,8 +21,8 @@ You can use this plugin and single sign-on plugins together, but they won't be s
21
21
  ## How does this work?
22
22
 
23
23
  This plugin checks incoming requests for a **shared cookie** that is saved by your application when a user
24
- logs in. This cookie contains in its value, a specially crafted signed token containing unique identifying
25
- information for that user.
24
+ logs in. This cookie contains in its value, a specially crafted signed (JWS) or encrypted (JWE) token
25
+ containing unique identifying information for that user.
26
26
 
27
27
  If the user can be found in NodeBB, that user will be logged in. If not, then a user is created, and that
28
28
  unique indentifier is saved for future reference.
@@ -36,10 +36,13 @@ to have a couple special attributes:
36
36
  * The `domain` should be set to the naked domain. That is, if your site is at `app.example.com`, and your
37
37
  forum is at `talk.example.com`, the cookie domain should be set to `example.com`.
38
38
  * The cookie name is up to you, and can be configured in this plugin's settings. *(Default: `token`)*
39
- * The cookie value is a [JSON Web Token](https://jwt.io/). See below.
39
+ * The cookie value is a [JSON Web Token](https://jwt.io/) — signed (JWS) or encrypted (JWE). See below.
40
40
 
41
41
  ### Generating the JSON Web Token
42
42
 
43
+ The steps below describe the default **JWS** format; if you've switched the plugin to **JWE**, see
44
+ [Token Format (JWS / JWE)](#token-format-jws--jwe) instead.
45
+
43
46
  A list of compatible libraries can be obtained on the main website for [JSON Web Tokens](https://jwt.io/).
44
47
 
45
48
  You'll be encoding a payload that looks something like this...
@@ -87,14 +90,66 @@ it can properly decode and verify the JWT signature.
87
90
 
88
91
  In which case, you can set the "Parent Key" setting in this plugin to `d`.
89
92
 
93
+ ### Token Format (JWS / JWE)
94
+
95
+ By default the shared cookie is a **JWS** (signed) JWT. The **Token Format** setting in the admin panel
96
+ allows switching to **JWE** (encrypted) JWTs, in which case the payload is confidential and only decryptable
97
+ with the corresponding key, so its contents are not visible in the cookie.
98
+
99
+ This setting controls the format of tokens *generated* by NodeBB (the `nbb_token` reverse cookie and the
100
+ development debug token). **Incoming cookies are auto-detected by their structure** (a JWS has 3
101
+ dot-separated segments, a JWE has 5), so *both* formats are accepted regardless of the setting. This allows
102
+ a gradual migration: flip the setting, then switch your application over to issuing JWE cookies at your own
103
+ pace — users with cookies in either format will continue to log in throughout the transition.
104
+
105
+ Tokens are created and verified with [`jose`](https://github.com/panva/jose). When issuing JWE cookies from
106
+ your application, use the following parameters:
107
+
108
+ | Signing method | `alg` | `enc` | Key |
109
+ | --- | --- | --- | --- |
110
+ | HMAC | `dir` | `A256GCM` | HKDF-derived from your shared secret (see below) |
111
+ | RSA | `RSA-OAEP-256` | `A256GCM` | The RSA **public** key |
112
+
113
+ **HMAC key derivation** — JWE's `dir` key method requires the key to be exactly 32 bytes, so
114
+ arbitrary-length shared secrets cannot be used directly. NodeBB derives the JWE key with HKDF-SHA256:
115
+
116
+ ```
117
+ HKDF(
118
+ ikm: <your shared secret>,
119
+ salt: "nodebb-plugin-session-sharing",
120
+ info: "A256GCM",
121
+ length: 32
122
+ )
123
+ ```
124
+
125
+ Your application must perform the same derivation before encrypting. JWS is unaffected — `HS256` signing
126
+ uses the raw secret as-is, so existing JWS setups keep working unchanged.
127
+
128
+ **RSA key direction** — note that JWE inverts the key direction of JWS: your application *encrypts* with the
129
+ RSA **public** key, and NodeBB *decrypts* with the RSA **private** key. If you use JWE with the RSA signing
130
+ method, make sure the **RSA Private Key** is configured in the admin settings — without it, encrypted cookies
131
+ cannot be decrypted (a warning is logged at startup).
132
+
133
+ The `nbb_token` reverse cookie follows the same Token Format setting. If your application reads the reverse
134
+ token, it must be able to decrypt JWE when this setting is enabled.
135
+
90
136
  ## Security
91
137
 
92
- Please note that according to the JWT spec, the payload itself is ***not encrypted***, only *signed*. That is,
138
+ Please note that according to the JWT spec, a JWS payload is ***not encrypted***, only *signed*. That is,
93
139
  the Base64 Url Encoded payload is appended to the header. It can be decoded trivially (as base64 is not meant
94
- to be cryptographically secure), so **do not put any private information in the payload**. The header and
95
- payload themselves are *signed* against the secret, and NodeBB will only allow a JWT through if it has not been
96
- tampered with. That is, NodeBB will only continue with a login if the signature can be independently generated
97
- by the received payload and the secret.
140
+ to be cryptographically secure), so **do not put any private information in the payload** of a JWS token. The
141
+ header and payload themselves are *signed* against the secret, and NodeBB will only allow a JWT through if it
142
+ has not been tampered with. That is, NodeBB will only continue with a login if the signature can be
143
+ independently generated by the received payload and the secret.
144
+
145
+ **Mitigating payload exposure with JWE** — if the exposure above is a concern for your setup (for example,
146
+ your payload includes an `email` or `groups` you'd rather not be visible in a cookie, or you cannot guarantee
147
+ HTTPS end-to-end), switch the **Token Format** setting to JWE (see
148
+ [Token Format (JWS / JWE)](#token-format-jws--jwe)). A JWE token *encrypts* the payload — with `A256GCM` in
149
+ this plugin — so the identifying information is not visible in the cookie at all, and can only be decrypted by
150
+ NodeBB with the corresponding key. Note that JWE protects the payload's *confidentiality*; the token is still
151
+ only as trustworthy as the key or secret it was sealed with, and a stolen private key (or shared secret) can
152
+ still mint valid tokens — the rest of this section applies to both formats.
98
153
 
99
154
  Use secure cookies transmitted via HTTPS if at all possible.
100
155
 
package/eslint.config.mjs CHANGED
@@ -6,5 +6,13 @@ import publicConfig from 'eslint-config-nodebb/public';
6
6
  export default [
7
7
  ...publicConfig,
8
8
  ...serverConfig,
9
+ {
10
+ files: ['library.js', 'lib/**/*.js'],
11
+ languageOptions: {
12
+ globals: {
13
+ nodebb: 'readonly',
14
+ },
15
+ },
16
+ },
9
17
  ];
10
18
 
package/library.js CHANGED
@@ -4,9 +4,10 @@ const winston = nodebb.require('winston');
4
4
  const nconf = nodebb.require('nconf');
5
5
 
6
6
  const util = require('util');
7
+ const crypto = require('crypto');
7
8
 
8
9
  const _ = require('lodash');
9
- const jwt = require('jsonwebtoken');
10
+ const { jwtVerify, jwtDecrypt, SignJWT, EncryptJWT } = require('jose');
10
11
 
11
12
  const meta = nodebb.require('./src/meta');
12
13
  const user = nodebb.require('./src/user');
@@ -47,6 +48,10 @@ const plugin = {
47
48
  cookieName: 'token',
48
49
  cookieDomain: undefined,
49
50
  secret: '',
51
+ signingMethod: 'hmac',
52
+ tokenFormat: 'jws',
53
+ publicKey: '',
54
+ privateKey: '',
50
55
  behaviour: 'trust',
51
56
  adminRevalidate: 'off',
52
57
  noRegistration: 'off',
@@ -136,8 +141,93 @@ plugin.getUser = async (remoteId) => {
136
141
  return user.getUserFields(uid, ['username', 'userslug', 'picture']);
137
142
  };
138
143
 
144
+ /*
145
+ * JWE with a symmetric key ("dir" key method) requires the key to be exactly
146
+ * the length of the content encryption key (32 bytes for A256GCM). Shared
147
+ * secrets are arbitrary-length, so derive a fixed-length key with HKDF.
148
+ * Apps issuing JWE cookies must perform the same derivation.
149
+ */
150
+ const JWE_HKDF_SALT = 'nodebb-plugin-session-sharing';
151
+ const JWE_HKDF_INFO = 'A256GCM';
152
+
153
+ plugin.getJwsKey = (isPrivate) => {
154
+ if (plugin.settings.signingMethod === 'rsa') {
155
+ const pem = isPrivate ? plugin.settings.privateKey : plugin.settings.publicKey;
156
+ if (!pem) {
157
+ throw new Error(isPrivate ? 'No RSA private key configured' : 'No RSA public key configured');
158
+ }
159
+ // jose v6 expects a KeyObject, not a PEM string
160
+ return isPrivate ? crypto.createPrivateKey(pem) : crypto.createPublicKey(pem);
161
+ }
162
+ if (!plugin.settings.secret) {
163
+ throw new Error('No JWT secret configured');
164
+ }
165
+ return crypto.createSecretKey(plugin.settings.secret, 'utf8');
166
+ };
167
+
168
+ /*
169
+ * Note the key direction differs from JWS: JWE is encrypted with the public
170
+ * key and decrypted with the private key, so verifying an incoming JWE
171
+ * cookie requires the RSA private key.
172
+ */
173
+ plugin.getJweKey = (isDecryption) => {
174
+ if (plugin.settings.signingMethod === 'rsa') {
175
+ return plugin.getJwsKey(isDecryption);
176
+ }
177
+ if (!plugin.settings.secret) {
178
+ throw new Error('No JWT secret configured');
179
+ }
180
+ // jose expects a Uint8Array (not a Node Buffer) for symmetric JWE keys
181
+ return new Uint8Array(crypto.hkdfSync('sha256', plugin.settings.secret, JWE_HKDF_SALT, JWE_HKDF_INFO, 32));
182
+ };
183
+
184
+ /*
185
+ * Verifies an incoming token, accepting both JWS (3 segments) and JWE
186
+ * (5 segments) so that installs can migrate between the two formats
187
+ * without a hard cutover. Key selection is driven by the configured
188
+ * signing method, not the token header.
189
+ */
190
+ plugin.verifyToken = async (token) => {
191
+ if (typeof token !== 'string' || !token.length) {
192
+ throw new Error('payload-invalid');
193
+ }
194
+
195
+ const isJWE = token.split('.').length === 5;
196
+
197
+ try {
198
+ if (isJWE) {
199
+ const { payload } = await jwtDecrypt(token, plugin.getJweKey(true), {
200
+ algorithms: plugin.settings.signingMethod === 'rsa' ? ['RSA-OAEP-256'] : ['dir'],
201
+ });
202
+ return payload; // already JSON-parsed by jose
203
+ }
204
+
205
+ const { payload } = await jwtVerify(token, plugin.getJwsKey(false), {
206
+ algorithms: plugin.settings.signingMethod === 'rsa' ? ['RS256'] : ['HS256'],
207
+ });
208
+ return payload;
209
+ } catch (error) {
210
+ throw new Error('payload-invalid', { cause: error });
211
+ }
212
+ };
213
+
214
+ /* Creates a JWS or JWE token according to the configured token format */
215
+ plugin.createToken = async (payload) => {
216
+ if (plugin.settings.tokenFormat === 'jwe') {
217
+ const encrypted = new EncryptJWT(payload).setProtectedHeader(
218
+ plugin.settings.signingMethod === 'rsa' ? { alg: 'RSA-OAEP-256', enc: 'A256GCM' } : { alg: 'dir', enc: 'A256GCM' }
219
+ );
220
+ return encrypted.encrypt(plugin.getJweKey(false));
221
+ }
222
+
223
+ const signed = new SignJWT(payload).setProtectedHeader({
224
+ alg: plugin.settings.signingMethod === 'rsa' ? 'RS256' : 'HS256',
225
+ });
226
+ return signed.sign(plugin.getJwsKey(true));
227
+ };
228
+
139
229
  plugin.process = async (token) => {
140
- const payload = await jwt.verify(token, plugin.settings.secret, { algorithms: ['HS256'] });
230
+ const payload = await plugin.verifyToken(token);
141
231
  const userData = await plugin.normalizePayload(payload);
142
232
  const [uid, isNewUser] = await plugin.findOrCreateUser(userData);
143
233
  await plugin.updateUserProfile(uid, userData, isNewUser);
@@ -386,10 +476,15 @@ plugin.addMiddleware = async function ({ req, res }) {
386
476
  const { hostWhitelist, guestRedirect, editOverride, loginOverride, registerOverride } = await meta.settings.get('session-sharing');
387
477
 
388
478
  if (hostWhitelist) {
479
+ const hostHeader = req.headers.host;
480
+ if (!hostHeader) {
481
+ return;
482
+ }
483
+
389
484
  const hosts = hostWhitelist.split(',') || [hostWhitelist];
390
485
  let whitelisted = false;
391
486
  for (const host of hosts) {
392
- if (req.headers.host.includes(host)) {
487
+ if (hostHeader.includes(host)) {
393
488
  whitelisted = true;
394
489
  break;
395
490
  }
@@ -521,13 +616,11 @@ plugin.addMiddleware = async function ({ req, res }) {
521
616
  };
522
617
 
523
618
  plugin.cleanup = async (data) => {
524
- if (plugin.settings.cookieDomain) {
525
- winston.verbose('[session-sharing] Clearing cookie');
526
- data.res.clearCookie(plugin.settings.cookieName, {
527
- domain: plugin.settings.cookieDomain,
528
- path: '/',
529
- });
530
- }
619
+ winston.verbose('[session-sharing] Clearing cookie');
620
+ data.res.clearCookie(plugin.settings.cookieName, {
621
+ domain: plugin.settings.cookieDomain,
622
+ path: '/',
623
+ });
531
624
 
532
625
  data.res.clearCookie('nbb_token', {
533
626
  domain: plugin.settings.cookieDomain,
@@ -537,7 +630,7 @@ plugin.cleanup = async (data) => {
537
630
  return true;
538
631
  };
539
632
 
540
- plugin.generate = function (req, res) {
633
+ plugin.generate = async function (req, res) {
541
634
  if (!plugin.ready) {
542
635
  return res.sendStatus(404);
543
636
  }
@@ -563,7 +656,7 @@ plugin.generate = function (req, res) {
563
656
  payload = newPayload;
564
657
  }
565
658
 
566
- const token = jwt.sign(payload, plugin.settings.secret);
659
+ const token = await plugin.createToken(payload);
567
660
  res.cookie(plugin.settings.cookieName, token, {
568
661
  maxAge: 1000 * 60 * 60 * 24 * 21,
569
662
  httpOnly: true,
@@ -590,10 +683,24 @@ plugin.reloadSettings = async (data) => {
590
683
  }
591
684
 
592
685
  const settings = await meta.settings.get('session-sharing');
593
- if (!settings.hasOwnProperty('secret') || !settings.secret.length) {
686
+ const signingMethod = settings.signingMethod || 'hmac';
687
+ const tokenFormat = settings.tokenFormat || 'jws';
688
+ if (tokenFormat !== 'jws' && tokenFormat !== 'jwe') {
689
+ winston.error('[session-sharing] Invalid token format ("' + tokenFormat + '"), session sharing disabled.');
690
+ return;
691
+ }
692
+ if (signingMethod === 'rsa') {
693
+ if (!settings.publicKey || !settings.publicKey.length) {
694
+ winston.error('[session-sharing] RSA public key not found, session sharing disabled.');
695
+ return;
696
+ }
697
+ } else if (!settings.hasOwnProperty('secret') || !settings.secret.length) {
594
698
  winston.error('[session-sharing] JWT Secret not found, session sharing disabled.');
595
699
  return;
596
700
  }
701
+ if (signingMethod === 'rsa' && (!settings.privateKey || !settings.privateKey.length)) {
702
+ winston.warn('[session-sharing] RSA private key not configured; JWE (encrypted) cookies cannot be decrypted and reverse tokens cannot be signed.');
703
+ }
597
704
 
598
705
  // If "payload:parent" is found, but payloadParent is not, update the latter and delete the former
599
706
  if (!settings.payloadParent && settings['payload:parent']) {
@@ -636,7 +743,7 @@ plugin.saveReverseToken = async ({ req, userData: data }) => {
636
743
  const { res } = req;
637
744
  const userData = await user.getUserFields(data.uid, ['uid', 'username', 'picture', 'reputation', 'postcount', 'banned']);
638
745
  userData.groups = (await groups.getUserGroups([data.uid])).pop();
639
- const token = jwt.sign(userData, plugin.settings.secret);
746
+ const token = await plugin.createToken(userData);
640
747
 
641
748
  res.cookie('nbb_token', token, {
642
749
  maxAge: meta.getSessionTTLSeconds() * 1000,
package/package-lock.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "nodebb-plugin-session-sharing",
3
- "version": "8.0.5",
3
+ "version": "9.0.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "nodebb-plugin-session-sharing",
9
- "version": "8.0.5",
9
+ "version": "9.0.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@commitlint/cli": "^9.1.2",
13
13
  "async": "^3",
14
- "jsonwebtoken": "^8.5.1",
14
+ "jose": "^6.0.0",
15
15
  "lint-staged": "^10.0.9",
16
16
  "lodash": "^4.17.14"
17
17
  },
@@ -749,11 +749,6 @@
749
749
  "node": ">=8"
750
750
  }
751
751
  },
752
- "node_modules/buffer-equal-constant-time": {
753
- "version": "1.0.1",
754
- "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
755
- "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
756
- },
757
752
  "node_modules/caller-callsite": {
758
753
  "version": "2.0.0",
759
754
  "resolved": "https://registry.npmjs.org/caller-callsite/-/caller-callsite-2.0.0.tgz",
@@ -1162,14 +1157,6 @@
1162
1157
  "safer-buffer": "^2.1.0"
1163
1158
  }
1164
1159
  },
1165
- "node_modules/ecdsa-sig-formatter": {
1166
- "version": "1.0.11",
1167
- "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
1168
- "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
1169
- "dependencies": {
1170
- "safe-buffer": "^5.0.1"
1171
- }
1172
- },
1173
1160
  "node_modules/emoji-regex": {
1174
1161
  "version": "8.0.0",
1175
1162
  "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -2283,6 +2270,15 @@
2283
2270
  "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==",
2284
2271
  "dev": true
2285
2272
  },
2273
+ "node_modules/jose": {
2274
+ "version": "6.2.12",
2275
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
2276
+ "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==",
2277
+ "license": "MIT",
2278
+ "funding": {
2279
+ "url": "https://github.com/sponsors/panva"
2280
+ }
2281
+ },
2286
2282
  "node_modules/js-tokens": {
2287
2283
  "version": "4.0.0",
2288
2284
  "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -2371,35 +2367,6 @@
2371
2367
  "node": "*"
2372
2368
  }
2373
2369
  },
2374
- "node_modules/jsonwebtoken": {
2375
- "version": "8.5.1",
2376
- "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz",
2377
- "integrity": "sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==",
2378
- "dependencies": {
2379
- "jws": "^3.2.2",
2380
- "lodash.includes": "^4.3.0",
2381
- "lodash.isboolean": "^3.0.3",
2382
- "lodash.isinteger": "^4.0.4",
2383
- "lodash.isnumber": "^3.0.3",
2384
- "lodash.isplainobject": "^4.0.6",
2385
- "lodash.isstring": "^4.0.1",
2386
- "lodash.once": "^4.0.0",
2387
- "ms": "^2.1.1",
2388
- "semver": "^5.6.0"
2389
- },
2390
- "engines": {
2391
- "node": ">=4",
2392
- "npm": ">=1.4.28"
2393
- }
2394
- },
2395
- "node_modules/jsonwebtoken/node_modules/semver": {
2396
- "version": "5.7.2",
2397
- "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
2398
- "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
2399
- "bin": {
2400
- "semver": "bin/semver"
2401
- }
2402
- },
2403
2370
  "node_modules/jsprim": {
2404
2371
  "version": "1.4.2",
2405
2372
  "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz",
@@ -2415,25 +2382,6 @@
2415
2382
  "node": ">=0.6.0"
2416
2383
  }
2417
2384
  },
2418
- "node_modules/jwa": {
2419
- "version": "1.4.2",
2420
- "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
2421
- "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
2422
- "dependencies": {
2423
- "buffer-equal-constant-time": "^1.0.1",
2424
- "ecdsa-sig-formatter": "1.0.11",
2425
- "safe-buffer": "^5.0.1"
2426
- }
2427
- },
2428
- "node_modules/jws": {
2429
- "version": "3.2.2",
2430
- "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
2431
- "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
2432
- "dependencies": {
2433
- "jwa": "^1.4.1",
2434
- "safe-buffer": "^5.0.1"
2435
- }
2436
- },
2437
2385
  "node_modules/keyv": {
2438
2386
  "version": "4.5.4",
2439
2387
  "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -2622,41 +2570,6 @@
2622
2570
  "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
2623
2571
  "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
2624
2572
  },
2625
- "node_modules/lodash.includes": {
2626
- "version": "4.3.0",
2627
- "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
2628
- "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="
2629
- },
2630
- "node_modules/lodash.isboolean": {
2631
- "version": "3.0.3",
2632
- "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
2633
- "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="
2634
- },
2635
- "node_modules/lodash.isinteger": {
2636
- "version": "4.0.4",
2637
- "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
2638
- "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="
2639
- },
2640
- "node_modules/lodash.isnumber": {
2641
- "version": "3.0.3",
2642
- "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
2643
- "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="
2644
- },
2645
- "node_modules/lodash.isplainobject": {
2646
- "version": "4.0.6",
2647
- "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
2648
- "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="
2649
- },
2650
- "node_modules/lodash.isstring": {
2651
- "version": "4.0.1",
2652
- "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
2653
- "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="
2654
- },
2655
- "node_modules/lodash.once": {
2656
- "version": "4.1.1",
2657
- "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
2658
- "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="
2659
- },
2660
2573
  "node_modules/log-symbols": {
2661
2574
  "version": "4.1.0",
2662
2575
  "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
@@ -2852,7 +2765,8 @@
2852
2765
  "node_modules/ms": {
2853
2766
  "version": "2.1.3",
2854
2767
  "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
2855
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
2768
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
2769
+ "dev": true
2856
2770
  },
2857
2771
  "node_modules/natural-compare": {
2858
2772
  "version": "1.4.0",
@@ -3500,6 +3414,7 @@
3500
3414
  "version": "5.2.1",
3501
3415
  "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
3502
3416
  "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
3417
+ "dev": true,
3503
3418
  "funding": [
3504
3419
  {
3505
3420
  "type": "github",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-session-sharing",
3
- "version": "8.0.5",
3
+ "version": "9.0.0",
4
4
  "description": "Allows login sessions from your app to persist in NodeBB",
5
5
  "main": "library.js",
6
6
  "repository": {
@@ -41,7 +41,7 @@
41
41
  "dependencies": {
42
42
  "@commitlint/cli": "^9.1.2",
43
43
  "async": "^3",
44
- "jsonwebtoken": "^8.5.1",
44
+ "jose": "^6.0.0",
45
45
  "lint-staged": "^10.0.9",
46
46
  "lodash": "^4.17.14"
47
47
  },
@@ -4,7 +4,10 @@ define('admin/plugins/session-sharing', ['settings'], function (Settings) {
4
4
  const ACP = {};
5
5
 
6
6
  ACP.init = function () {
7
- Settings.load('session-sharing', $('.session-sharing-settings'));
7
+ // Settings.load is async: only toggle field visibility once the saved
8
+ // signingMethod value has been populated into the form.
9
+ Settings.load('session-sharing', $('.session-sharing-settings'), ACP.toggleSigningFields);
10
+ $('#signingMethod').on('change', ACP.toggleSigningFields);
8
11
 
9
12
  $('#save').on('click', function () {
10
13
  Settings.save('session-sharing', $('.session-sharing-settings'));
@@ -14,6 +17,19 @@ define('admin/plugins/session-sharing', ['settings'], function (Settings) {
14
17
  $('#remote_search').on('keyup', ACP.findUserByRemoteId);
15
18
  };
16
19
 
20
+ ACP.toggleSigningFields = function () {
21
+ const method = $('#signingMethod').val() || 'hmac';
22
+ if (method === 'rsa') {
23
+ $('#hmac-fields').hide();
24
+ $('#rsa-fields').show();
25
+ $('#rsa-sign-fields').show();
26
+ } else {
27
+ $('#hmac-fields').show();
28
+ $('#rsa-fields').hide();
29
+ $('#rsa-sign-fields').hide();
30
+ }
31
+ };
32
+
17
33
  ACP.showUserId = function () {
18
34
  if (ACP._searchDelay) {
19
35
  clearTimeout(ACP._searchDelay);
@@ -25,6 +25,44 @@
25
25
  </p>
26
26
  </div>
27
27
  <div class="mb-3">
28
+ <label class="form-label" for="hostWhitelist">Host Whitelist</label>
29
+ <input type="text" id="hostWhitelist" name="hostWhitelist" title="Host Whitelist" class="form-control" placeholder="localhost" />
30
+ <p class="form-text">
31
+ If set, session-sharing plugin works only on whitelisted domains. Separate with commas, for example: "localhost,test.domain.com".
32
+ </p>
33
+ </div>
34
+
35
+ <h5 class="fw-bold tracking-tight settings-header">Token Settings</h5>
36
+
37
+ <div class="mb-3">
38
+ <label class="form-label" for="signingMethod">Signing Method</label>
39
+ <select class="form-control" id="signingMethod" name="signingMethod">
40
+ <option value="hmac">HMAC (symmetric shared secret)</option>
41
+ <option value="rsa">RSA (asymmetric key pair)</option>
42
+ </select>
43
+ <p class="form-text">
44
+ HMAC uses a shared secret known to both parties. RSA uses an asymmetric key pair where only the
45
+ issuing server can sign, but any holder of the public key can verify.
46
+ </p>
47
+ </div>
48
+ <div class="mb-3">
49
+ <label class="form-label" for="tokenFormat">Token Format</label>
50
+ <select class="form-control" id="tokenFormat" name="tokenFormat">
51
+ <option value="jws">JWS (signed JWT)</option>
52
+ <option value="jwe">JWE (encrypted JWT)</option>
53
+ </select>
54
+ <p class="form-text">
55
+ JWS tokens are digitally signed: their payload is base64url-encoded and readable by anyone, but
56
+ tamper-proof. JWE tokens are encrypted: the payload is confidential and only decryptable with the
57
+ corresponding key. This setting controls the format of tokens <em>generated</em> by NodeBB (the
58
+ <code>nbb_token</code> reverse cookie and the development debug token).
59
+ </p>
60
+ <p class="form-text">
61
+ Incoming cookies are auto-detected, so <strong>both</strong> JWS and JWE tokens are accepted
62
+ regardless of this setting.
63
+ </p>
64
+ </div>
65
+ <div class="mb-3" id="hmac-fields">
28
66
  <label class="form-label" for="secret">JWT Secret</label>
29
67
  <input type="text" id="secret" name="secret" title="JWT Secret" class="form-control" />
30
68
  <p class="form-text">
@@ -32,13 +70,20 @@
32
70
  otherwise the JWT cannot be properly decoded.
33
71
  </p>
34
72
  </div>
35
- <div class="mb-3">
36
- <label class="form-label" for="hostWhitelist">Host Whitelist</label>
37
- <input type="text" id="hostWhitelist" name="hostWhitelist" title="Host Whitelist" class="form-control" placeholder="localhost" />
73
+ <div class="mb-3" id="rsa-fields" style="display: none;">
74
+ <label class="form-label" for="publicKey">RSA Public Key</label>
75
+ <textarea id="publicKey" name="publicKey" title="RSA Public Key" class="form-control" rows="5" placeholder="-----BEGIN PUBLIC KEY-----&#10;...&#10;-----END PUBLIC KEY-----"></textarea>
38
76
  <p class="form-text">
39
- If set, session-sharing plugin works only on whitelisted domains. Separate with commas, for example: "localhost,test.domain.com".
77
+ The public key used to verify JWT signatures. This key is shared with clients so they can verify tokens.
40
78
  </p>
41
79
  </div>
80
+ <div class="mb-3" id="rsa-sign-fields" style="display: none;">
81
+ <label class="form-label" for="privateKey">RSA Private Key</label>
82
+ <textarea id="privateKey" name="privateKey" title="RSA Private Key" class="form-control" rows="5" placeholder="-----BEGIN PRIVATE KEY-----&#10;...&#10;-----END PRIVATE KEY-----"></textarea>
83
+ <p class="form-text">
84
+ The private key used to sign reverse tokens (if enabled), and to decrypt JWE (encrypted) cookies. Keep this secret — only the issuing server should have access to it.
85
+ </p>
86
+ </div>
42
87
  </div>
43
88
 
44
89
  <div class="mb-4">
package/test/index.js CHANGED
@@ -6,6 +6,7 @@
6
6
  // Don't forget to add this plugin to `test_plugins` array in `config.json`
7
7
 
8
8
  const assert = require('assert');
9
+ const crypto = require('crypto');
9
10
  const url = require('url');
10
11
  const util = require('util');
11
12
  const request = require('request-promise-native');
@@ -15,6 +16,17 @@ const db = nodebb.require('./test/mocks/databasemock');
15
16
  const nconf = nodebb.require('nconf');
16
17
  const meta = nodebb.require('./src/meta');
17
18
 
19
+ /*
20
+ * meta.settings.setOne fires the `action:settings.set` hook without awaiting it,
21
+ * so the plugin's reloadSettings may still be running when setOne resolves.
22
+ * Awaiting reloadSettings directly keeps these tests deterministic.
23
+ * (Required lazily to avoid a circular dependency while mocha loads the suite.)
24
+ */
25
+ const setSetting = async (key, value) => {
26
+ await meta.settings.setOne('session-sharing', key, value);
27
+ await require('../library').reloadSettings();
28
+ };
29
+
18
30
  describe('nodebb-plugin-session-sharing', () => {
19
31
  const userJar = request.jar();
20
32
  const anonJar = request.jar();
@@ -30,7 +42,7 @@ describe('nodebb-plugin-session-sharing', () => {
30
42
 
31
43
  assert.strictEqual(response.statusCode, 404);
32
44
 
33
- await meta.settings.setOne('session-sharing', 'secret', 's3cr37c47');
45
+ await setSetting('secret', 's3cr37c47');
34
46
  });
35
47
 
36
48
  it('should generate a valid session when called', async () => {
@@ -76,12 +88,21 @@ describe('nodebb-plugin-session-sharing', () => {
76
88
  });
77
89
  assert.strictEqual(response.statusCode, 200);
78
90
 
91
+ // Core now initialises the guest session (CSRF token), so assert on the
92
+ // session contents: the guest must not have been logged in.
93
+ const getSession = util.promisify(db.sessionStore.get.bind(db.sessionStore));
79
94
  const cookies = anonJar.getCookies(`${protocol}//${hostname}/`);
80
- assert(cookies.every(cookie => cookie.key !== 'express.sid'));
95
+ const sidCookie = cookies.find(cookie => cookie.key === 'express.sid');
96
+ if (sidCookie) {
97
+ let sid = sidCookie.value.match(/s%3A([^.]+)/);
98
+ assert(sid && sid[1]);
99
+ const sessionObj = await getSession(sid[1]);
100
+ assert(!sessionObj || !sessionObj.passport || !sessionObj.passport.user);
101
+ }
81
102
  });
82
103
 
83
104
  it('should redirect a guest to a specified redirection target if configured', async () => {
84
- await meta.settings.setOne('session-sharing', 'guestRedirect', 'https://example.org');
105
+ await setSetting('guestRedirect', 'https://example.org');
85
106
  const response = await request(`${nconf.get('url')}`, {
86
107
  resolveWithFullResponse: true,
87
108
  jar: anonJar,
@@ -91,12 +112,12 @@ describe('nodebb-plugin-session-sharing', () => {
91
112
 
92
113
  assert(response.statusCode, 302);
93
114
  assert.strictEqual(response.headers.location, 'https://example.org');
94
- await meta.settings.setOne('session-sharing', 'guestRedirect', '');
115
+ await setSetting('guestRedirect', '');
95
116
  });
96
117
 
97
118
  it('should maintain the login if behaviour is "revalidate"', async () => {
98
119
  const getSession = util.promisify(db.sessionStore.get.bind(db.sessionStore));
99
- await meta.settings.setOne('session-sharing', 'behaviour', 'revalidate');
120
+ await setSetting('behaviour', 'revalidate');
100
121
 
101
122
  await request(`${nconf.get('url')}`, {
102
123
  resolveWithFullResponse: true,
@@ -152,7 +173,7 @@ describe('nodebb-plugin-session-sharing', () => {
152
173
 
153
174
  describe('login override', () => {
154
175
  it('should redirect a guest to a specified login override if configured', async () => {
155
- await meta.settings.setOne('session-sharing', 'loginOverride', 'https://example.org/login');
176
+ await setSetting('loginOverride', 'https://example.org/login');
156
177
  const response = await request(`${nconf.get('url')}/login`, {
157
178
  resolveWithFullResponse: true,
158
179
  jar: anonJar,
@@ -167,7 +188,7 @@ describe('nodebb-plugin-session-sharing', () => {
167
188
 
168
189
  describe('register override', () => {
169
190
  it('should redirect a guest to a specified register override if configured', async () => {
170
- await meta.settings.setOne('session-sharing', 'registerOverride', 'https://example.org/register');
191
+ await setSetting('registerOverride', 'https://example.org/register');
171
192
  const response = await request(`${nconf.get('url')}/register`, {
172
193
  resolveWithFullResponse: true,
173
194
  jar: anonJar,
@@ -179,4 +200,93 @@ describe('nodebb-plugin-session-sharing', () => {
179
200
  assert.strictEqual(response.headers.location, 'https://example.org/register');
180
201
  });
181
202
  });
203
+
204
+ describe('JWE token support', () => {
205
+ const getTokenCookie = (jar) => {
206
+ const cookies = jar.getCookies(`${protocol}//${hostname}/`);
207
+ return cookies.find(cookie => cookie.key === 'token');
208
+ };
209
+
210
+ const assertLoggedIn = async (jar) => {
211
+ const getSession = util.promisify(db.sessionStore.get.bind(db.sessionStore));
212
+ const response = await request(`${nconf.get('url')}`, {
213
+ resolveWithFullResponse: true,
214
+ jar,
215
+ });
216
+ assert.strictEqual(response.statusCode, 200);
217
+
218
+ const cookies = jar.getCookies(`${protocol}//${hostname}/`);
219
+ const sidCookie = cookies.find(cookie => cookie.key === 'express.sid');
220
+ assert(sidCookie);
221
+ const sid = sidCookie.value.match(/s%3A([^.]+)/)[1];
222
+ const sessionObj = await getSession(sid);
223
+
224
+ assert(sessionObj && sessionObj.passport && sessionObj.passport.user);
225
+ assert(parseInt(sessionObj.passport.user, 10) > 0);
226
+ };
227
+
228
+ it('should generate and accept an HMAC JWE token', async () => {
229
+ const jweJar = request.jar();
230
+ await setSetting('tokenFormat', 'jwe');
231
+
232
+ await request(`${nconf.get('url')}/debug/session`, { jar: jweJar });
233
+ const cookie = getTokenCookie(jweJar);
234
+ assert(cookie);
235
+ assert.strictEqual(cookie.value.split('.').length, 5);
236
+
237
+ await assertLoggedIn(jweJar);
238
+ });
239
+
240
+ it('should accept a JWS token even when tokenFormat is jwe (auto-detection)', async () => {
241
+ const jwsJar = request.jar();
242
+ await setSetting('tokenFormat', 'jws');
243
+
244
+ await request(`${nconf.get('url')}/debug/session`, { jar: jwsJar });
245
+ const cookie = getTokenCookie(jwsJar);
246
+ assert(cookie);
247
+ assert.strictEqual(cookie.value.split('.').length, 3);
248
+
249
+ await setSetting('tokenFormat', 'jwe');
250
+ await assertLoggedIn(jwsJar);
251
+ });
252
+
253
+ it('should generate and accept an RSA JWE token', async () => {
254
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
255
+ await setSetting('publicKey', publicKey.export({ type: 'spki', format: 'pem' }));
256
+ await setSetting('privateKey', privateKey.export({ type: 'pkcs8', format: 'pem' }));
257
+ await setSetting('signingMethod', 'rsa');
258
+ await setSetting('tokenFormat', 'jwe');
259
+
260
+ const rsaJar = request.jar();
261
+ await request(`${nconf.get('url')}/debug/session`, { jar: rsaJar });
262
+ const cookie = getTokenCookie(rsaJar);
263
+ assert(cookie);
264
+ assert.strictEqual(cookie.value.split('.').length, 5);
265
+
266
+ await assertLoggedIn(rsaJar);
267
+ });
268
+
269
+ it('should reject a JWE token decrypted with the wrong key', async () => {
270
+ const { EncryptJWT } = require('jose');
271
+ const wrongKey = new Uint8Array(crypto.hkdfSync('sha256', 'wrong-secret', 'nodebb-plugin-session-sharing', 'A256GCM', 32));
272
+ const token = await new EncryptJWT({ id: 99999, username: 'intruder' })
273
+ .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
274
+ .encrypt(wrongKey);
275
+
276
+ const badJar = request.jar();
277
+ badJar.setCookie(`token=${token}`, `${protocol}//${hostname}/`);
278
+ const response = await request(`${nconf.get('url')}`, {
279
+ resolveWithFullResponse: true,
280
+ jar: badJar,
281
+ simple: false,
282
+ });
283
+
284
+ assert.strictEqual(response.statusCode, 500);
285
+ });
286
+
287
+ it('should restore default signing settings', async () => {
288
+ await setSetting('signingMethod', 'hmac');
289
+ await setSetting('tokenFormat', 'jws');
290
+ });
291
+ });
182
292
  });