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/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
|
|
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
|
|
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,
|
|
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
|
|
95
|
-
payload themselves are *signed* against the secret, and NodeBB will only allow a JWT through if it
|
|
96
|
-
tampered with. That is, NodeBB will only continue with a login if the signature can be
|
|
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
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
|
|
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
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
746
|
+
const token = await plugin.createToken(userData);
|
|
645
747
|
|
|
646
748
|
res.cookie('nbb_token', token, {
|
|
647
749
|
maxAge: meta.getSessionTTLSeconds() * 1000,
|