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 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);
@@ -526,13 +616,11 @@ plugin.addMiddleware = async function ({ req, res }) {
526
616
  };
527
617
 
528
618
  plugin.cleanup = async (data) => {
529
- if (plugin.settings.cookieDomain) {
530
- winston.verbose('[session-sharing] Clearing cookie');
531
- data.res.clearCookie(plugin.settings.cookieName, {
532
- domain: plugin.settings.cookieDomain,
533
- path: '/',
534
- });
535
- }
619
+ winston.verbose('[session-sharing] Clearing cookie');
620
+ data.res.clearCookie(plugin.settings.cookieName, {
621
+ domain: plugin.settings.cookieDomain,
622
+ path: '/',
623
+ });
536
624
 
537
625
  data.res.clearCookie('nbb_token', {
538
626
  domain: plugin.settings.cookieDomain,
@@ -542,7 +630,7 @@ plugin.cleanup = async (data) => {
542
630
  return true;
543
631
  };
544
632
 
545
- plugin.generate = function (req, res) {
633
+ plugin.generate = async function (req, res) {
546
634
  if (!plugin.ready) {
547
635
  return res.sendStatus(404);
548
636
  }
@@ -568,7 +656,7 @@ plugin.generate = function (req, res) {
568
656
  payload = newPayload;
569
657
  }
570
658
 
571
- const token = jwt.sign(payload, plugin.settings.secret);
659
+ const token = await plugin.createToken(payload);
572
660
  res.cookie(plugin.settings.cookieName, token, {
573
661
  maxAge: 1000 * 60 * 60 * 24 * 21,
574
662
  httpOnly: true,
@@ -595,10 +683,24 @@ plugin.reloadSettings = async (data) => {
595
683
  }
596
684
 
597
685
  const settings = await meta.settings.get('session-sharing');
598
- 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) {
599
698
  winston.error('[session-sharing] JWT Secret not found, session sharing disabled.');
600
699
  return;
601
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
+ }
602
704
 
603
705
  // If "payload:parent" is found, but payloadParent is not, update the latter and delete the former
604
706
  if (!settings.payloadParent && settings['payload:parent']) {
@@ -641,7 +743,7 @@ plugin.saveReverseToken = async ({ req, userData: data }) => {
641
743
  const { res } = req;
642
744
  const userData = await user.getUserFields(data.uid, ['uid', 'username', 'picture', 'reputation', 'postcount', 'banned']);
643
745
  userData.groups = (await groups.getUserGroups([data.uid])).pop();
644
- const token = jwt.sign(userData, plugin.settings.secret);
746
+ const token = await plugin.createToken(userData);
645
747
 
646
748
  res.cookie('nbb_token', token, {
647
749
  maxAge: meta.getSessionTTLSeconds() * 1000,