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/README.md +65 -10
- package/eslint.config.mjs +8 -0
- package/library.js +115 -13
- package/package-lock.json +4010 -0
- package/package.json +2 -2
- package/static/lib/admin.js +17 -1
- package/static/templates/admin/plugins/session-sharing.tpl +49 -4
- package/test/index.js +117 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nodebb-plugin-session-sharing",
|
|
3
|
-
"version": "
|
|
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
|
-
"
|
|
44
|
+
"jose": "^6.0.0",
|
|
45
45
|
"lint-staged": "^10.0.9",
|
|
46
46
|
"lodash": "^4.17.14"
|
|
47
47
|
},
|
package/static/lib/admin.js
CHANGED
|
@@ -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
|
|
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="
|
|
37
|
-
<
|
|
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----- ... -----END PUBLIC KEY-----"></textarea>
|
|
38
76
|
<p class="form-text">
|
|
39
|
-
|
|
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----- ... -----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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
});
|