nodebb-plugin-session-sharing 8.0.6 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-session-sharing",
3
- "version": "8.0.6",
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
  });