@zero-server/sdk 0.9.1 → 0.9.2
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/LICENSE +21 -21
- package/README.md +460 -443
- package/index.js +414 -412
- package/lib/app.js +1172 -1172
- package/lib/auth/authorize.js +399 -399
- package/lib/auth/enrollment.js +367 -367
- package/lib/auth/index.js +57 -57
- package/lib/auth/jwt.js +731 -731
- package/lib/auth/oauth.js +362 -362
- package/lib/auth/session.js +588 -588
- package/lib/auth/trustedDevice.js +409 -409
- package/lib/auth/twoFactor.js +1150 -1150
- package/lib/auth/webauthn.js +946 -946
- package/lib/body/index.js +14 -14
- package/lib/body/json.js +109 -109
- package/lib/body/multipart.js +440 -440
- package/lib/body/raw.js +71 -71
- package/lib/body/rawBuffer.js +160 -160
- package/lib/body/sendError.js +25 -25
- package/lib/body/text.js +75 -75
- package/lib/body/typeMatch.js +41 -41
- package/lib/body/urlencoded.js +235 -235
- package/lib/cli.js +845 -845
- package/lib/cluster.js +666 -666
- package/lib/debug.js +372 -372
- package/lib/env/index.js +465 -465
- package/lib/errors.js +683 -683
- package/lib/fetch/index.js +256 -256
- package/lib/grpc/balancer.js +378 -378
- package/lib/grpc/call.js +708 -708
- package/lib/grpc/client.js +764 -764
- package/lib/grpc/codec.js +1221 -1221
- package/lib/grpc/credentials.js +398 -398
- package/lib/grpc/frame.js +262 -262
- package/lib/grpc/health.js +287 -287
- package/lib/grpc/index.js +121 -121
- package/lib/grpc/metadata.js +461 -461
- package/lib/grpc/proto.js +821 -821
- package/lib/grpc/reflection.js +590 -590
- package/lib/grpc/server.js +445 -445
- package/lib/grpc/status.js +118 -118
- package/lib/grpc/watch.js +173 -173
- package/lib/http/index.js +10 -10
- package/lib/http/request.js +727 -727
- package/lib/http/response.js +799 -799
- package/lib/lifecycle.js +557 -557
- package/lib/middleware/compress.js +230 -230
- package/lib/middleware/cookieParser.js +237 -237
- package/lib/middleware/cors.js +93 -93
- package/lib/middleware/csrf.js +137 -137
- package/lib/middleware/errorHandler.js +101 -101
- package/lib/middleware/helmet.js +175 -175
- package/lib/middleware/index.js +19 -17
- package/lib/middleware/logger.js +74 -74
- package/lib/middleware/rateLimit.js +88 -88
- package/lib/middleware/requestId.js +53 -53
- package/lib/middleware/static.js +326 -326
- package/lib/middleware/timeout.js +71 -71
- package/lib/middleware/validator.js +255 -255
- package/lib/observe/health.js +326 -326
- package/lib/observe/index.js +50 -50
- package/lib/observe/logger.js +359 -359
- package/lib/observe/metrics.js +805 -805
- package/lib/observe/tracing.js +592 -592
- package/lib/orm/adapters/json.js +290 -290
- package/lib/orm/adapters/memory.js +764 -764
- package/lib/orm/adapters/mongo.js +764 -764
- package/lib/orm/adapters/mysql.js +933 -933
- package/lib/orm/adapters/postgres.js +1144 -1144
- package/lib/orm/adapters/redis.js +1534 -1534
- package/lib/orm/adapters/sql-base.js +212 -212
- package/lib/orm/adapters/sqlite.js +858 -858
- package/lib/orm/audit.js +649 -649
- package/lib/orm/cache.js +394 -394
- package/lib/orm/geo.js +387 -387
- package/lib/orm/index.js +784 -784
- package/lib/orm/migrate.js +432 -432
- package/lib/orm/model.js +1706 -1706
- package/lib/orm/plugin.js +375 -375
- package/lib/orm/procedures.js +836 -836
- package/lib/orm/profiler.js +233 -233
- package/lib/orm/query.js +1772 -1772
- package/lib/orm/replicas.js +241 -241
- package/lib/orm/schema.js +307 -307
- package/lib/orm/search.js +380 -380
- package/lib/orm/seed/data/commerce.js +136 -136
- package/lib/orm/seed/data/internet.js +111 -111
- package/lib/orm/seed/data/locations.js +204 -204
- package/lib/orm/seed/data/names.js +338 -338
- package/lib/orm/seed/data/person.js +128 -128
- package/lib/orm/seed/data/phone.js +211 -211
- package/lib/orm/seed/data/words.js +134 -134
- package/lib/orm/seed/factory.js +178 -178
- package/lib/orm/seed/fake.js +1186 -1186
- package/lib/orm/seed/index.js +18 -18
- package/lib/orm/seed/rng.js +70 -70
- package/lib/orm/seed/seeder.js +124 -124
- package/lib/orm/seed/unique.js +68 -68
- package/lib/orm/snapshot.js +366 -366
- package/lib/orm/tenancy.js +605 -605
- package/lib/orm/views.js +350 -350
- package/lib/router/index.js +436 -436
- package/lib/sse/index.js +8 -8
- package/lib/sse/stream.js +349 -349
- package/lib/ws/connection.js +451 -451
- package/lib/ws/handshake.js +125 -125
- package/lib/ws/index.js +14 -14
- package/lib/ws/room.js +223 -223
- package/package.json +73 -73
- package/types/app.d.ts +223 -223
- package/types/auth.d.ts +520 -520
- package/types/cluster.d.ts +75 -75
- package/types/env.d.ts +80 -80
- package/types/errors.d.ts +316 -316
- package/types/fetch.d.ts +43 -43
- package/types/grpc.d.ts +432 -432
- package/types/index.d.ts +384 -384
- package/types/lifecycle.d.ts +60 -60
- package/types/middleware.d.ts +320 -320
- package/types/observe.d.ts +304 -304
- package/types/orm.d.ts +1887 -1887
- package/types/request.d.ts +109 -109
- package/types/response.d.ts +157 -157
- package/types/router.d.ts +78 -78
- package/types/sse.d.ts +78 -78
- package/types/websocket.d.ts +126 -126
package/lib/auth/session.js
CHANGED
|
@@ -1,588 +1,588 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module auth/session
|
|
3
|
-
* @description Zero-dependency session middleware.
|
|
4
|
-
* Supports encrypted cookie sessions (stateless, AES-256-GCM)
|
|
5
|
-
* and server-side session stores (memory and custom adapters).
|
|
6
|
-
*
|
|
7
|
-
* Cookie sessions embed the entire session in an encrypted cookie,
|
|
8
|
-
* so no server-side storage is needed. Server-side sessions store
|
|
9
|
-
* only a session ID in the cookie, keeping data on the server.
|
|
10
|
-
*
|
|
11
|
-
* @example
|
|
12
|
-
* const { createApp, json, session } = require('@zero-server/sdk');
|
|
13
|
-
* const app = createApp();
|
|
14
|
-
*
|
|
15
|
-
* app.use(session({ secret: process.env.SESSION_SECRET }));
|
|
16
|
-
*
|
|
17
|
-
* app.post('/login', json(), async (req, res) => {
|
|
18
|
-
* const user = await db.users.findOne({ email: req.body.email });
|
|
19
|
-
* if (!user) return res.status(401).json({ error: 'Invalid credentials' });
|
|
20
|
-
* req.session.set('userId', user.id);
|
|
21
|
-
* req.session.set('role', user.role);
|
|
22
|
-
* res.json({ ok: true });
|
|
23
|
-
* });
|
|
24
|
-
*
|
|
25
|
-
* app.get('/dashboard', (req, res) => {
|
|
26
|
-
* if (!req.session.get('userId'))
|
|
27
|
-
* return res.status(401).json({ error: 'Not logged in' });
|
|
28
|
-
* res.json({ userId: req.session.get('userId'), role: req.session.get('role') });
|
|
29
|
-
* });
|
|
30
|
-
*
|
|
31
|
-
* app.post('/logout', (req, res) => {
|
|
32
|
-
* req.session.destroy();
|
|
33
|
-
* res.json({ ok: true });
|
|
34
|
-
* });
|
|
35
|
-
*
|
|
36
|
-
* @example | Server-side Session with Memory Store
|
|
37
|
-
* app.use(session({
|
|
38
|
-
* secret: process.env.SESSION_SECRET,
|
|
39
|
-
* store: new MemoryStore(),
|
|
40
|
-
* cookie: { maxAge: 3600000 },
|
|
41
|
-
* }));
|
|
42
|
-
*/
|
|
43
|
-
const crypto = require('crypto');
|
|
44
|
-
const log = require('../debug')('zero:session');
|
|
45
|
-
|
|
46
|
-
// -- Constants ---------------------------------------------------
|
|
47
|
-
|
|
48
|
-
const DEFAULT_COOKIE_NAME = 'sid';
|
|
49
|
-
const DEFAULT_MAX_AGE = 86400000; // 24 hours in ms
|
|
50
|
-
const IV_LEN = 12; // AES-256-GCM IV
|
|
51
|
-
const AUTH_TAG_LEN = 16; // GCM auth tag
|
|
52
|
-
const KEY_LEN = 32; // AES-256 key
|
|
53
|
-
const MAX_COOKIE_SIZE = 4096; // Browser max cookie size
|
|
54
|
-
const SID_BYTES = 24; // Session ID entropy (base64url → 32 chars)
|
|
55
|
-
|
|
56
|
-
// -- Encryption helpers ------------------------------------------
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Derive an AES-256 key from a secret string.
|
|
60
|
-
* Uses HKDF with SHA-256 for proper key derivation.
|
|
61
|
-
*
|
|
62
|
-
* @param {string} secret - User-provided secret.
|
|
63
|
-
* @returns {Buffer} 32-byte key.
|
|
64
|
-
* @private
|
|
65
|
-
*/
|
|
66
|
-
function _deriveKey(secret)
|
|
67
|
-
{
|
|
68
|
-
return crypto.createHash('sha256').update(secret).digest();
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Encrypt plaintext with AES-256-GCM.
|
|
73
|
-
* Each encryption uses a unique random IV.
|
|
74
|
-
*
|
|
75
|
-
* @param {string} plaintext - Data to encrypt.
|
|
76
|
-
* @param {Buffer} key - 32-byte AES key.
|
|
77
|
-
* @returns {string} Base64url-encoded `iv.ciphertext.tag`.
|
|
78
|
-
* @private
|
|
79
|
-
*/
|
|
80
|
-
function _encrypt(plaintext, key)
|
|
81
|
-
{
|
|
82
|
-
const iv = crypto.randomBytes(IV_LEN);
|
|
83
|
-
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv, { authTagLength: AUTH_TAG_LEN });
|
|
84
|
-
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
85
|
-
const tag = cipher.getAuthTag();
|
|
86
|
-
// Pack: iv + ciphertext + tag
|
|
87
|
-
const packed = Buffer.concat([iv, enc, tag]);
|
|
88
|
-
return packed.toString('base64url');
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Decrypt AES-256-GCM ciphertext.
|
|
93
|
-
*
|
|
94
|
-
* @param {string} encoded - Base64url `iv+ciphertext+tag` string.
|
|
95
|
-
* @param {Buffer} key - 32-byte AES key.
|
|
96
|
-
* @returns {string|null} Decrypted plaintext or `null` on failure.
|
|
97
|
-
* @private
|
|
98
|
-
*/
|
|
99
|
-
function _decrypt(encoded, key)
|
|
100
|
-
{
|
|
101
|
-
try
|
|
102
|
-
{
|
|
103
|
-
const packed = Buffer.from(encoded, 'base64url');
|
|
104
|
-
if (packed.length < IV_LEN + AUTH_TAG_LEN + 1) return null;
|
|
105
|
-
|
|
106
|
-
const iv = packed.subarray(0, IV_LEN);
|
|
107
|
-
const tag = packed.subarray(packed.length - AUTH_TAG_LEN);
|
|
108
|
-
const enc = packed.subarray(IV_LEN, packed.length - AUTH_TAG_LEN);
|
|
109
|
-
|
|
110
|
-
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv, { authTagLength: AUTH_TAG_LEN });
|
|
111
|
-
decipher.setAuthTag(tag);
|
|
112
|
-
const dec = Buffer.concat([decipher.update(enc), decipher.final()]);
|
|
113
|
-
return dec.toString('utf8');
|
|
114
|
-
}
|
|
115
|
-
catch (_) { return null; }
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// -- Session class ------------------------------------------------
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Session data container with a Map-like API.
|
|
122
|
-
*
|
|
123
|
-
* @example
|
|
124
|
-
* req.session.set('user', { id: 1, name: 'Alice' });
|
|
125
|
-
* req.session.get('user'); // { id: 1, name: 'Alice' }
|
|
126
|
-
* req.session.has('user'); // true
|
|
127
|
-
* req.session.delete('user');
|
|
128
|
-
* req.session.destroy(); // wipe + expire cookie
|
|
129
|
-
*/
|
|
130
|
-
class Session
|
|
131
|
-
{
|
|
132
|
-
/**
|
|
133
|
-
* @param {string} id - Session ID.
|
|
134
|
-
* @param {object} [data] - Initial data.
|
|
135
|
-
*/
|
|
136
|
-
constructor(id, data = {})
|
|
137
|
-
{
|
|
138
|
-
this.id = id;
|
|
139
|
-
this._data = { ...data };
|
|
140
|
-
this._dirty = false;
|
|
141
|
-
this._destroyed = false;
|
|
142
|
-
this._regenerated = false;
|
|
143
|
-
this._flash = {};
|
|
144
|
-
this._flashOut = {};
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Get a session value by key.
|
|
149
|
-
* @param {string} key
|
|
150
|
-
* @returns {*}
|
|
151
|
-
*/
|
|
152
|
-
get(key) { return this._data[key]; }
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Set a session value.
|
|
156
|
-
* @param {string} key
|
|
157
|
-
* @param {*} value
|
|
158
|
-
* @returns {Session} this (chainable)
|
|
159
|
-
*/
|
|
160
|
-
set(key, value)
|
|
161
|
-
{
|
|
162
|
-
this._data[key] = value;
|
|
163
|
-
this._dirty = true;
|
|
164
|
-
return this;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Check if a key exists in the session.
|
|
169
|
-
* @param {string} key
|
|
170
|
-
* @returns {boolean}
|
|
171
|
-
*/
|
|
172
|
-
has(key) { return key in this._data; }
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Delete a session key.
|
|
176
|
-
* @param {string} key
|
|
177
|
-
* @returns {boolean} true if the key existed.
|
|
178
|
-
*/
|
|
179
|
-
delete(key)
|
|
180
|
-
{
|
|
181
|
-
const existed = key in this._data;
|
|
182
|
-
delete this._data[key];
|
|
183
|
-
if (existed) this._dirty = true;
|
|
184
|
-
return existed;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Get all session data as a plain object.
|
|
189
|
-
* @returns {object}
|
|
190
|
-
*/
|
|
191
|
-
all() { return { ...this._data }; }
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Number of session entries.
|
|
195
|
-
* @returns {number}
|
|
196
|
-
*/
|
|
197
|
-
get size() { return Object.keys(this._data).length; }
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Clear all session data.
|
|
201
|
-
* @returns {Session}
|
|
202
|
-
*/
|
|
203
|
-
clear()
|
|
204
|
-
{
|
|
205
|
-
this._data = {};
|
|
206
|
-
this._dirty = true;
|
|
207
|
-
return this;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* Destroy the session.
|
|
212
|
-
* Clears all data and marks cookie for expiry.
|
|
213
|
-
*/
|
|
214
|
-
destroy()
|
|
215
|
-
{
|
|
216
|
-
this._data = {};
|
|
217
|
-
this._flash = {};
|
|
218
|
-
this._flashOut = {};
|
|
219
|
-
this._dirty = true;
|
|
220
|
-
this._destroyed = true;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Regenerate the session ID (prevents session fixation).
|
|
225
|
-
* Preserves existing data under a new ID.
|
|
226
|
-
*/
|
|
227
|
-
regenerate()
|
|
228
|
-
{
|
|
229
|
-
this.id = _generateSid();
|
|
230
|
-
this._regenerated = true;
|
|
231
|
-
this._dirty = true;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Set a flash message (available only on the next request).
|
|
236
|
-
*
|
|
237
|
-
* @param {string} key - Flash key (e.g. `'success'`, `'error'`).
|
|
238
|
-
* @param {*} value - Flash value.
|
|
239
|
-
* @returns {Session}
|
|
240
|
-
*
|
|
241
|
-
* @example
|
|
242
|
-
* req.session.flash('success', 'Post created!');
|
|
243
|
-
* // Next request:
|
|
244
|
-
* req.session.flashes('success'); // ['Post created!']
|
|
245
|
-
*/
|
|
246
|
-
flash(key, value)
|
|
247
|
-
{
|
|
248
|
-
if (!this._flashOut[key]) this._flashOut[key] = [];
|
|
249
|
-
this._flashOut[key].push(value);
|
|
250
|
-
this._dirty = true;
|
|
251
|
-
return this;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* Read flash messages for a key (consumes them).
|
|
256
|
-
*
|
|
257
|
-
* @param {string} [key] - Flash key. If omitted, returns all flashes.
|
|
258
|
-
* @returns {*[]|object} Array of messages for key, or object of all flashes.
|
|
259
|
-
*/
|
|
260
|
-
flashes(key)
|
|
261
|
-
{
|
|
262
|
-
if (key) return this._flash[key] || [];
|
|
263
|
-
return { ...this._flash };
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/** @private — serialize to JSON for cookie/store */
|
|
267
|
-
_serialize()
|
|
268
|
-
{
|
|
269
|
-
const obj = { d: this._data };
|
|
270
|
-
if (Object.keys(this._flashOut).length) obj.f = this._flashOut;
|
|
271
|
-
return JSON.stringify(obj);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/** @private — deserialize from JSON */
|
|
275
|
-
static _deserialize(json, id)
|
|
276
|
-
{
|
|
277
|
-
try
|
|
278
|
-
{
|
|
279
|
-
const obj = typeof json === 'string' ? JSON.parse(json) : json;
|
|
280
|
-
const sess = new Session(id, obj.d || {});
|
|
281
|
-
sess._flash = obj.f || {};
|
|
282
|
-
return sess;
|
|
283
|
-
}
|
|
284
|
-
catch (_) { return new Session(id); }
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// -- Memory Store ------------------------------------------------
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* In-memory session store.
|
|
292
|
-
* Suitable for development and single-process deployments.
|
|
293
|
-
* Sessions are lost on restart.
|
|
294
|
-
*
|
|
295
|
-
* @example
|
|
296
|
-
* const store = new MemoryStore({ ttl: 3600000 });
|
|
297
|
-
* app.use(session({ secret: 's3cret', store }));
|
|
298
|
-
*/
|
|
299
|
-
class MemoryStore
|
|
300
|
-
{
|
|
301
|
-
/**
|
|
302
|
-
* @param {object} [opts]
|
|
303
|
-
* @param {number} [opts.ttl=86400000] - Session TTL in ms (default 24h).
|
|
304
|
-
* @param {number} [opts.pruneInterval=300000] - Cleanup interval in ms (default 5min).
|
|
305
|
-
* @param {number} [opts.maxSessions=10000] - Maximum stored sessions.
|
|
306
|
-
*/
|
|
307
|
-
constructor(opts = {})
|
|
308
|
-
{
|
|
309
|
-
this._sessions = new Map();
|
|
310
|
-
this._ttl = opts.ttl || DEFAULT_MAX_AGE;
|
|
311
|
-
this._maxSessions = opts.maxSessions || 10000;
|
|
312
|
-
this._pruneTimer = null;
|
|
313
|
-
|
|
314
|
-
const pruneMs = opts.pruneInterval || 300000;
|
|
315
|
-
if (pruneMs > 0)
|
|
316
|
-
{
|
|
317
|
-
this._pruneTimer = setInterval(() => this._prune(), pruneMs);
|
|
318
|
-
if (this._pruneTimer.unref) this._pruneTimer.unref();
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/** @param {string} sid */
|
|
323
|
-
async get(sid)
|
|
324
|
-
{
|
|
325
|
-
const entry = this._sessions.get(sid);
|
|
326
|
-
if (!entry) return null;
|
|
327
|
-
if (Date.now() > entry.expires)
|
|
328
|
-
{
|
|
329
|
-
this._sessions.delete(sid);
|
|
330
|
-
return null;
|
|
331
|
-
}
|
|
332
|
-
return entry.data;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/**
|
|
336
|
-
* @param {string} sid
|
|
337
|
-
* @param {string} data - Serialized session.
|
|
338
|
-
* @param {number} [maxAge] - TTL in ms.
|
|
339
|
-
*/
|
|
340
|
-
async set(sid, data, maxAge)
|
|
341
|
-
{
|
|
342
|
-
if (this._sessions.size >= this._maxSessions && !this._sessions.has(sid))
|
|
343
|
-
{
|
|
344
|
-
this._prune();
|
|
345
|
-
if (this._sessions.size >= this._maxSessions)
|
|
346
|
-
{
|
|
347
|
-
log.warn('MemoryStore at capacity (%d), rejecting new session', this._maxSessions);
|
|
348
|
-
return;
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
this._sessions.set(sid, {
|
|
352
|
-
data,
|
|
353
|
-
expires: Date.now() + (maxAge || this._ttl),
|
|
354
|
-
});
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
/** @param {string} sid */
|
|
358
|
-
async destroy(sid)
|
|
359
|
-
{
|
|
360
|
-
this._sessions.delete(sid);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/** Prune expired sessions. */
|
|
364
|
-
_prune()
|
|
365
|
-
{
|
|
366
|
-
const now = Date.now();
|
|
367
|
-
for (const [sid, entry] of this._sessions)
|
|
368
|
-
{
|
|
369
|
-
if (now > entry.expires) this._sessions.delete(sid);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/** Number of active sessions. */
|
|
374
|
-
get length() { return this._sessions.size; }
|
|
375
|
-
|
|
376
|
-
/** Clear all sessions. */
|
|
377
|
-
clear()
|
|
378
|
-
{
|
|
379
|
-
this._sessions.clear();
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
/** Stop the prune timer. */
|
|
383
|
-
close()
|
|
384
|
-
{
|
|
385
|
-
if (this._pruneTimer) { clearInterval(this._pruneTimer); this._pruneTimer = null; }
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
// -- Session ID --------------------------------------------------
|
|
390
|
-
|
|
391
|
-
/** @private */
|
|
392
|
-
function _generateSid()
|
|
393
|
-
{
|
|
394
|
-
return crypto.randomBytes(SID_BYTES).toString('base64url');
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// -- Session Middleware ------------------------------------------
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* Create session middleware.
|
|
401
|
-
*
|
|
402
|
-
* Two modes:
|
|
403
|
-
* 1. **Cookie session** (no `store`): Entire session encrypted in a cookie.
|
|
404
|
-
* Great for small payloads (< 4 KB). Zero server state.
|
|
405
|
-
* 2. **Server-side session** (with `store`): Only session ID in cookie,
|
|
406
|
-
* data lives in the store. Scales to large payloads.
|
|
407
|
-
*
|
|
408
|
-
* @param {object} opts - Configuration.
|
|
409
|
-
* @param {string|string[]} opts.secret - Encryption secret(s). First secret used for
|
|
410
|
-
* encryption, all are tried for decryption (supports rotation).
|
|
411
|
-
* @param {object} [opts.store] - Server-side session store (must implement `get`, `set`, `destroy`).
|
|
412
|
-
* @param {string} [opts.name='sid'] - Cookie name.
|
|
413
|
-
* @param {object} [opts.cookie] - Cookie options.
|
|
414
|
-
* @param {number} [opts.cookie.maxAge=86400000] - Cookie max-age in ms (default 24h).
|
|
415
|
-
* @param {string} [opts.cookie.path='/'] - Cookie path.
|
|
416
|
-
* @param {string} [opts.cookie.domain] - Cookie domain.
|
|
417
|
-
* @param {boolean} [opts.cookie.secure] - Secure-only flag (default: auto-detect via `req.secure`).
|
|
418
|
-
* @param {boolean} [opts.cookie.httpOnly=true] - HttpOnly flag.
|
|
419
|
-
* @param {string} [opts.cookie.sameSite='Lax'] - SameSite attribute.
|
|
420
|
-
* @param {boolean} [opts.rolling=false] - Reset cookie maxAge on every response.
|
|
421
|
-
* @param {Function} [opts.genid] - Custom session ID generator.
|
|
422
|
-
* @returns {Function} Middleware `(req, res, next) => void`.
|
|
423
|
-
*
|
|
424
|
-
* @example | Cookie Session with Rotation
|
|
425
|
-
* app.use(session({
|
|
426
|
-
* secret: ['new-key', 'old-key'],
|
|
427
|
-
* cookie: { maxAge: 3600000, secure: true },
|
|
428
|
-
* }));
|
|
429
|
-
*
|
|
430
|
-
* @example | Server-side with Memory Store
|
|
431
|
-
* const store = new MemoryStore({ ttl: 3600000 });
|
|
432
|
-
* app.use(session({ secret: 'key', store, rolling: true }));
|
|
433
|
-
*/
|
|
434
|
-
function session(opts = {})
|
|
435
|
-
{
|
|
436
|
-
if (!opts.secret) throw new Error('session() requires a secret');
|
|
437
|
-
|
|
438
|
-
const secrets = Array.isArray(opts.secret) ? opts.secret : [opts.secret];
|
|
439
|
-
const keys = secrets.map(s => _deriveKey(s));
|
|
440
|
-
const store = opts.store || null;
|
|
441
|
-
const cookieName = opts.name || DEFAULT_COOKIE_NAME;
|
|
442
|
-
const rolling = opts.rolling === true;
|
|
443
|
-
const genid = typeof opts.genid === 'function' ? opts.genid : _generateSid;
|
|
444
|
-
|
|
445
|
-
const cookieOpts = {
|
|
446
|
-
path: '/',
|
|
447
|
-
httpOnly: true,
|
|
448
|
-
sameSite: 'Lax',
|
|
449
|
-
...(opts.cookie || {}),
|
|
450
|
-
};
|
|
451
|
-
const maxAge = cookieOpts.maxAge || DEFAULT_MAX_AGE;
|
|
452
|
-
|
|
453
|
-
return async function sessionMiddleware(req, res, next)
|
|
454
|
-
{
|
|
455
|
-
// Prevent double-initialisation
|
|
456
|
-
if (req.session) return next();
|
|
457
|
-
|
|
458
|
-
const rawCookie = req.cookies?.[cookieName] || req.signedCookies?.[cookieName];
|
|
459
|
-
let sess = null;
|
|
460
|
-
|
|
461
|
-
if (store)
|
|
462
|
-
{
|
|
463
|
-
// Server-side mode: cookie holds the session ID
|
|
464
|
-
sess = await _loadServerSession(rawCookie, store, genid);
|
|
465
|
-
}
|
|
466
|
-
else
|
|
467
|
-
{
|
|
468
|
-
// Cookie mode: decrypt session from cookie
|
|
469
|
-
sess = _loadCookieSession(rawCookie, keys, genid);
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
req.session = sess;
|
|
473
|
-
|
|
474
|
-
// Intercept response to persist session
|
|
475
|
-
// Hook into res.raw.end (Node ServerResponse) — the Response wrapper
|
|
476
|
-
// has no .end() method; its .send()/.json() helpers call raw.end().
|
|
477
|
-
const raw = res.raw;
|
|
478
|
-
const origEnd = raw.end.bind(raw);
|
|
479
|
-
raw.end = function sessionEnd(...args)
|
|
480
|
-
{
|
|
481
|
-
try
|
|
482
|
-
{
|
|
483
|
-
// _saveSession calls res.cookie() / res.clearCookie() which set
|
|
484
|
-
// Set-Cookie headers on raw via raw.setHeader — safe because
|
|
485
|
-
// headers aren't flushed until the original end() runs.
|
|
486
|
-
// NOTE: store-based sessions are sync-compatible because
|
|
487
|
-
// MemoryStore.set/get return resolved promises synchronously
|
|
488
|
-
// for the in-process case. For truly async stores the cookie
|
|
489
|
-
// will still be set correctly because setHeader precedes end().
|
|
490
|
-
_saveSession(req, res, sess, {
|
|
491
|
-
store, keys, cookieName, cookieOpts, maxAge, rolling,
|
|
492
|
-
});
|
|
493
|
-
}
|
|
494
|
-
catch (err)
|
|
495
|
-
{
|
|
496
|
-
log.error('session save error: %s', err.message);
|
|
497
|
-
}
|
|
498
|
-
return origEnd(...args);
|
|
499
|
-
};
|
|
500
|
-
|
|
501
|
-
next();
|
|
502
|
-
};
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// -- Internal load/save ------------------------------------------
|
|
506
|
-
|
|
507
|
-
/** @private */
|
|
508
|
-
async function _loadServerSession(rawCookie, store, genid)
|
|
509
|
-
{
|
|
510
|
-
if (rawCookie)
|
|
511
|
-
{
|
|
512
|
-
const data = await store.get(rawCookie);
|
|
513
|
-
if (data)
|
|
514
|
-
{
|
|
515
|
-
log.debug('session loaded: %s', rawCookie);
|
|
516
|
-
return Session._deserialize(data, rawCookie);
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
// New session
|
|
520
|
-
const id = genid();
|
|
521
|
-
log.debug('new session: %s', id);
|
|
522
|
-
return new Session(id);
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
/** @private */
|
|
526
|
-
function _loadCookieSession(rawCookie, keys, genid)
|
|
527
|
-
{
|
|
528
|
-
if (rawCookie)
|
|
529
|
-
{
|
|
530
|
-
// Try each key for decryption (rotation support)
|
|
531
|
-
for (const key of keys)
|
|
532
|
-
{
|
|
533
|
-
const json = _decrypt(rawCookie, key);
|
|
534
|
-
if (json)
|
|
535
|
-
{
|
|
536
|
-
const sess = Session._deserialize(json, 'cookie');
|
|
537
|
-
log.debug('cookie session decrypted');
|
|
538
|
-
return sess;
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
return new Session(genid());
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
/** @private */
|
|
546
|
-
function _saveSession(req, res, sess, ctx)
|
|
547
|
-
{
|
|
548
|
-
if (sess._destroyed)
|
|
549
|
-
{
|
|
550
|
-
// Clear cookie and destroy store entry
|
|
551
|
-
res.clearCookie(ctx.cookieName, { path: ctx.cookieOpts.path || '/' });
|
|
552
|
-
if (ctx.store) ctx.store.destroy(sess.id);
|
|
553
|
-
log.debug('session destroyed: %s', sess.id);
|
|
554
|
-
return;
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
const shouldSave = sess._dirty || ctx.rolling;
|
|
558
|
-
if (!shouldSave) return;
|
|
559
|
-
|
|
560
|
-
const cOpts = { ...ctx.cookieOpts, maxAge: Math.floor(ctx.maxAge / 1000) };
|
|
561
|
-
if (cOpts.secure === undefined) cOpts.secure = req.secure;
|
|
562
|
-
|
|
563
|
-
if (ctx.store)
|
|
564
|
-
{
|
|
565
|
-
// Server-side: persist data in store, session ID in cookie
|
|
566
|
-
ctx.store.set(sess.id, sess._serialize(), ctx.maxAge);
|
|
567
|
-
res.cookie(ctx.cookieName, sess.id, cOpts);
|
|
568
|
-
log.debug('server session saved: %s', sess.id);
|
|
569
|
-
}
|
|
570
|
-
else
|
|
571
|
-
{
|
|
572
|
-
// Cookie mode: encrypt session and set as cookie
|
|
573
|
-
const payload = sess._serialize();
|
|
574
|
-
const encrypted = _encrypt(payload, ctx.keys[0]);
|
|
575
|
-
if (encrypted.length > MAX_COOKIE_SIZE)
|
|
576
|
-
{
|
|
577
|
-
log.warn('session cookie exceeds %d bytes — consider using a store', MAX_COOKIE_SIZE);
|
|
578
|
-
}
|
|
579
|
-
res.cookie(ctx.cookieName, encrypted, cOpts);
|
|
580
|
-
log.debug('cookie session saved');
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
module.exports = {
|
|
585
|
-
session,
|
|
586
|
-
Session,
|
|
587
|
-
MemoryStore,
|
|
588
|
-
};
|
|
1
|
+
/**
|
|
2
|
+
* @module auth/session
|
|
3
|
+
* @description Zero-dependency session middleware.
|
|
4
|
+
* Supports encrypted cookie sessions (stateless, AES-256-GCM)
|
|
5
|
+
* and server-side session stores (memory and custom adapters).
|
|
6
|
+
*
|
|
7
|
+
* Cookie sessions embed the entire session in an encrypted cookie,
|
|
8
|
+
* so no server-side storage is needed. Server-side sessions store
|
|
9
|
+
* only a session ID in the cookie, keeping data on the server.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* const { createApp, json, session } = require('@zero-server/sdk');
|
|
13
|
+
* const app = createApp();
|
|
14
|
+
*
|
|
15
|
+
* app.use(session({ secret: process.env.SESSION_SECRET }));
|
|
16
|
+
*
|
|
17
|
+
* app.post('/login', json(), async (req, res) => {
|
|
18
|
+
* const user = await db.users.findOne({ email: req.body.email });
|
|
19
|
+
* if (!user) return res.status(401).json({ error: 'Invalid credentials' });
|
|
20
|
+
* req.session.set('userId', user.id);
|
|
21
|
+
* req.session.set('role', user.role);
|
|
22
|
+
* res.json({ ok: true });
|
|
23
|
+
* });
|
|
24
|
+
*
|
|
25
|
+
* app.get('/dashboard', (req, res) => {
|
|
26
|
+
* if (!req.session.get('userId'))
|
|
27
|
+
* return res.status(401).json({ error: 'Not logged in' });
|
|
28
|
+
* res.json({ userId: req.session.get('userId'), role: req.session.get('role') });
|
|
29
|
+
* });
|
|
30
|
+
*
|
|
31
|
+
* app.post('/logout', (req, res) => {
|
|
32
|
+
* req.session.destroy();
|
|
33
|
+
* res.json({ ok: true });
|
|
34
|
+
* });
|
|
35
|
+
*
|
|
36
|
+
* @example | Server-side Session with Memory Store
|
|
37
|
+
* app.use(session({
|
|
38
|
+
* secret: process.env.SESSION_SECRET,
|
|
39
|
+
* store: new MemoryStore(),
|
|
40
|
+
* cookie: { maxAge: 3600000 },
|
|
41
|
+
* }));
|
|
42
|
+
*/
|
|
43
|
+
const crypto = require('crypto');
|
|
44
|
+
const log = require('../debug')('zero:session');
|
|
45
|
+
|
|
46
|
+
// -- Constants ---------------------------------------------------
|
|
47
|
+
|
|
48
|
+
const DEFAULT_COOKIE_NAME = 'sid';
|
|
49
|
+
const DEFAULT_MAX_AGE = 86400000; // 24 hours in ms
|
|
50
|
+
const IV_LEN = 12; // AES-256-GCM IV
|
|
51
|
+
const AUTH_TAG_LEN = 16; // GCM auth tag
|
|
52
|
+
const KEY_LEN = 32; // AES-256 key
|
|
53
|
+
const MAX_COOKIE_SIZE = 4096; // Browser max cookie size
|
|
54
|
+
const SID_BYTES = 24; // Session ID entropy (base64url → 32 chars)
|
|
55
|
+
|
|
56
|
+
// -- Encryption helpers ------------------------------------------
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Derive an AES-256 key from a secret string.
|
|
60
|
+
* Uses HKDF with SHA-256 for proper key derivation.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} secret - User-provided secret.
|
|
63
|
+
* @returns {Buffer} 32-byte key.
|
|
64
|
+
* @private
|
|
65
|
+
*/
|
|
66
|
+
function _deriveKey(secret)
|
|
67
|
+
{
|
|
68
|
+
return crypto.createHash('sha256').update(secret).digest();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Encrypt plaintext with AES-256-GCM.
|
|
73
|
+
* Each encryption uses a unique random IV.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} plaintext - Data to encrypt.
|
|
76
|
+
* @param {Buffer} key - 32-byte AES key.
|
|
77
|
+
* @returns {string} Base64url-encoded `iv.ciphertext.tag`.
|
|
78
|
+
* @private
|
|
79
|
+
*/
|
|
80
|
+
function _encrypt(plaintext, key)
|
|
81
|
+
{
|
|
82
|
+
const iv = crypto.randomBytes(IV_LEN);
|
|
83
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv, { authTagLength: AUTH_TAG_LEN });
|
|
84
|
+
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
85
|
+
const tag = cipher.getAuthTag();
|
|
86
|
+
// Pack: iv + ciphertext + tag
|
|
87
|
+
const packed = Buffer.concat([iv, enc, tag]);
|
|
88
|
+
return packed.toString('base64url');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Decrypt AES-256-GCM ciphertext.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} encoded - Base64url `iv+ciphertext+tag` string.
|
|
95
|
+
* @param {Buffer} key - 32-byte AES key.
|
|
96
|
+
* @returns {string|null} Decrypted plaintext or `null` on failure.
|
|
97
|
+
* @private
|
|
98
|
+
*/
|
|
99
|
+
function _decrypt(encoded, key)
|
|
100
|
+
{
|
|
101
|
+
try
|
|
102
|
+
{
|
|
103
|
+
const packed = Buffer.from(encoded, 'base64url');
|
|
104
|
+
if (packed.length < IV_LEN + AUTH_TAG_LEN + 1) return null;
|
|
105
|
+
|
|
106
|
+
const iv = packed.subarray(0, IV_LEN);
|
|
107
|
+
const tag = packed.subarray(packed.length - AUTH_TAG_LEN);
|
|
108
|
+
const enc = packed.subarray(IV_LEN, packed.length - AUTH_TAG_LEN);
|
|
109
|
+
|
|
110
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv, { authTagLength: AUTH_TAG_LEN });
|
|
111
|
+
decipher.setAuthTag(tag);
|
|
112
|
+
const dec = Buffer.concat([decipher.update(enc), decipher.final()]);
|
|
113
|
+
return dec.toString('utf8');
|
|
114
|
+
}
|
|
115
|
+
catch (_) { return null; }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// -- Session class ------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Session data container with a Map-like API.
|
|
122
|
+
*
|
|
123
|
+
* @example
|
|
124
|
+
* req.session.set('user', { id: 1, name: 'Alice' });
|
|
125
|
+
* req.session.get('user'); // { id: 1, name: 'Alice' }
|
|
126
|
+
* req.session.has('user'); // true
|
|
127
|
+
* req.session.delete('user');
|
|
128
|
+
* req.session.destroy(); // wipe + expire cookie
|
|
129
|
+
*/
|
|
130
|
+
class Session
|
|
131
|
+
{
|
|
132
|
+
/**
|
|
133
|
+
* @param {string} id - Session ID.
|
|
134
|
+
* @param {object} [data] - Initial data.
|
|
135
|
+
*/
|
|
136
|
+
constructor(id, data = {})
|
|
137
|
+
{
|
|
138
|
+
this.id = id;
|
|
139
|
+
this._data = { ...data };
|
|
140
|
+
this._dirty = false;
|
|
141
|
+
this._destroyed = false;
|
|
142
|
+
this._regenerated = false;
|
|
143
|
+
this._flash = {};
|
|
144
|
+
this._flashOut = {};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Get a session value by key.
|
|
149
|
+
* @param {string} key
|
|
150
|
+
* @returns {*}
|
|
151
|
+
*/
|
|
152
|
+
get(key) { return this._data[key]; }
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Set a session value.
|
|
156
|
+
* @param {string} key
|
|
157
|
+
* @param {*} value
|
|
158
|
+
* @returns {Session} this (chainable)
|
|
159
|
+
*/
|
|
160
|
+
set(key, value)
|
|
161
|
+
{
|
|
162
|
+
this._data[key] = value;
|
|
163
|
+
this._dirty = true;
|
|
164
|
+
return this;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Check if a key exists in the session.
|
|
169
|
+
* @param {string} key
|
|
170
|
+
* @returns {boolean}
|
|
171
|
+
*/
|
|
172
|
+
has(key) { return key in this._data; }
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Delete a session key.
|
|
176
|
+
* @param {string} key
|
|
177
|
+
* @returns {boolean} true if the key existed.
|
|
178
|
+
*/
|
|
179
|
+
delete(key)
|
|
180
|
+
{
|
|
181
|
+
const existed = key in this._data;
|
|
182
|
+
delete this._data[key];
|
|
183
|
+
if (existed) this._dirty = true;
|
|
184
|
+
return existed;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Get all session data as a plain object.
|
|
189
|
+
* @returns {object}
|
|
190
|
+
*/
|
|
191
|
+
all() { return { ...this._data }; }
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Number of session entries.
|
|
195
|
+
* @returns {number}
|
|
196
|
+
*/
|
|
197
|
+
get size() { return Object.keys(this._data).length; }
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Clear all session data.
|
|
201
|
+
* @returns {Session}
|
|
202
|
+
*/
|
|
203
|
+
clear()
|
|
204
|
+
{
|
|
205
|
+
this._data = {};
|
|
206
|
+
this._dirty = true;
|
|
207
|
+
return this;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Destroy the session.
|
|
212
|
+
* Clears all data and marks cookie for expiry.
|
|
213
|
+
*/
|
|
214
|
+
destroy()
|
|
215
|
+
{
|
|
216
|
+
this._data = {};
|
|
217
|
+
this._flash = {};
|
|
218
|
+
this._flashOut = {};
|
|
219
|
+
this._dirty = true;
|
|
220
|
+
this._destroyed = true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Regenerate the session ID (prevents session fixation).
|
|
225
|
+
* Preserves existing data under a new ID.
|
|
226
|
+
*/
|
|
227
|
+
regenerate()
|
|
228
|
+
{
|
|
229
|
+
this.id = _generateSid();
|
|
230
|
+
this._regenerated = true;
|
|
231
|
+
this._dirty = true;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Set a flash message (available only on the next request).
|
|
236
|
+
*
|
|
237
|
+
* @param {string} key - Flash key (e.g. `'success'`, `'error'`).
|
|
238
|
+
* @param {*} value - Flash value.
|
|
239
|
+
* @returns {Session}
|
|
240
|
+
*
|
|
241
|
+
* @example
|
|
242
|
+
* req.session.flash('success', 'Post created!');
|
|
243
|
+
* // Next request:
|
|
244
|
+
* req.session.flashes('success'); // ['Post created!']
|
|
245
|
+
*/
|
|
246
|
+
flash(key, value)
|
|
247
|
+
{
|
|
248
|
+
if (!this._flashOut[key]) this._flashOut[key] = [];
|
|
249
|
+
this._flashOut[key].push(value);
|
|
250
|
+
this._dirty = true;
|
|
251
|
+
return this;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Read flash messages for a key (consumes them).
|
|
256
|
+
*
|
|
257
|
+
* @param {string} [key] - Flash key. If omitted, returns all flashes.
|
|
258
|
+
* @returns {*[]|object} Array of messages for key, or object of all flashes.
|
|
259
|
+
*/
|
|
260
|
+
flashes(key)
|
|
261
|
+
{
|
|
262
|
+
if (key) return this._flash[key] || [];
|
|
263
|
+
return { ...this._flash };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** @private — serialize to JSON for cookie/store */
|
|
267
|
+
_serialize()
|
|
268
|
+
{
|
|
269
|
+
const obj = { d: this._data };
|
|
270
|
+
if (Object.keys(this._flashOut).length) obj.f = this._flashOut;
|
|
271
|
+
return JSON.stringify(obj);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** @private — deserialize from JSON */
|
|
275
|
+
static _deserialize(json, id)
|
|
276
|
+
{
|
|
277
|
+
try
|
|
278
|
+
{
|
|
279
|
+
const obj = typeof json === 'string' ? JSON.parse(json) : json;
|
|
280
|
+
const sess = new Session(id, obj.d || {});
|
|
281
|
+
sess._flash = obj.f || {};
|
|
282
|
+
return sess;
|
|
283
|
+
}
|
|
284
|
+
catch (_) { return new Session(id); }
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// -- Memory Store ------------------------------------------------
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* In-memory session store.
|
|
292
|
+
* Suitable for development and single-process deployments.
|
|
293
|
+
* Sessions are lost on restart.
|
|
294
|
+
*
|
|
295
|
+
* @example
|
|
296
|
+
* const store = new MemoryStore({ ttl: 3600000 });
|
|
297
|
+
* app.use(session({ secret: 's3cret', store }));
|
|
298
|
+
*/
|
|
299
|
+
class MemoryStore
|
|
300
|
+
{
|
|
301
|
+
/**
|
|
302
|
+
* @param {object} [opts]
|
|
303
|
+
* @param {number} [opts.ttl=86400000] - Session TTL in ms (default 24h).
|
|
304
|
+
* @param {number} [opts.pruneInterval=300000] - Cleanup interval in ms (default 5min).
|
|
305
|
+
* @param {number} [opts.maxSessions=10000] - Maximum stored sessions.
|
|
306
|
+
*/
|
|
307
|
+
constructor(opts = {})
|
|
308
|
+
{
|
|
309
|
+
this._sessions = new Map();
|
|
310
|
+
this._ttl = opts.ttl || DEFAULT_MAX_AGE;
|
|
311
|
+
this._maxSessions = opts.maxSessions || 10000;
|
|
312
|
+
this._pruneTimer = null;
|
|
313
|
+
|
|
314
|
+
const pruneMs = opts.pruneInterval || 300000;
|
|
315
|
+
if (pruneMs > 0)
|
|
316
|
+
{
|
|
317
|
+
this._pruneTimer = setInterval(() => this._prune(), pruneMs);
|
|
318
|
+
if (this._pruneTimer.unref) this._pruneTimer.unref();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** @param {string} sid */
|
|
323
|
+
async get(sid)
|
|
324
|
+
{
|
|
325
|
+
const entry = this._sessions.get(sid);
|
|
326
|
+
if (!entry) return null;
|
|
327
|
+
if (Date.now() > entry.expires)
|
|
328
|
+
{
|
|
329
|
+
this._sessions.delete(sid);
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
return entry.data;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* @param {string} sid
|
|
337
|
+
* @param {string} data - Serialized session.
|
|
338
|
+
* @param {number} [maxAge] - TTL in ms.
|
|
339
|
+
*/
|
|
340
|
+
async set(sid, data, maxAge)
|
|
341
|
+
{
|
|
342
|
+
if (this._sessions.size >= this._maxSessions && !this._sessions.has(sid))
|
|
343
|
+
{
|
|
344
|
+
this._prune();
|
|
345
|
+
if (this._sessions.size >= this._maxSessions)
|
|
346
|
+
{
|
|
347
|
+
log.warn('MemoryStore at capacity (%d), rejecting new session', this._maxSessions);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
this._sessions.set(sid, {
|
|
352
|
+
data,
|
|
353
|
+
expires: Date.now() + (maxAge || this._ttl),
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** @param {string} sid */
|
|
358
|
+
async destroy(sid)
|
|
359
|
+
{
|
|
360
|
+
this._sessions.delete(sid);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Prune expired sessions. */
|
|
364
|
+
_prune()
|
|
365
|
+
{
|
|
366
|
+
const now = Date.now();
|
|
367
|
+
for (const [sid, entry] of this._sessions)
|
|
368
|
+
{
|
|
369
|
+
if (now > entry.expires) this._sessions.delete(sid);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Number of active sessions. */
|
|
374
|
+
get length() { return this._sessions.size; }
|
|
375
|
+
|
|
376
|
+
/** Clear all sessions. */
|
|
377
|
+
clear()
|
|
378
|
+
{
|
|
379
|
+
this._sessions.clear();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Stop the prune timer. */
|
|
383
|
+
close()
|
|
384
|
+
{
|
|
385
|
+
if (this._pruneTimer) { clearInterval(this._pruneTimer); this._pruneTimer = null; }
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// -- Session ID --------------------------------------------------
|
|
390
|
+
|
|
391
|
+
/** @private */
|
|
392
|
+
function _generateSid()
|
|
393
|
+
{
|
|
394
|
+
return crypto.randomBytes(SID_BYTES).toString('base64url');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// -- Session Middleware ------------------------------------------
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Create session middleware.
|
|
401
|
+
*
|
|
402
|
+
* Two modes:
|
|
403
|
+
* 1. **Cookie session** (no `store`): Entire session encrypted in a cookie.
|
|
404
|
+
* Great for small payloads (< 4 KB). Zero server state.
|
|
405
|
+
* 2. **Server-side session** (with `store`): Only session ID in cookie,
|
|
406
|
+
* data lives in the store. Scales to large payloads.
|
|
407
|
+
*
|
|
408
|
+
* @param {object} opts - Configuration.
|
|
409
|
+
* @param {string|string[]} opts.secret - Encryption secret(s). First secret used for
|
|
410
|
+
* encryption, all are tried for decryption (supports rotation).
|
|
411
|
+
* @param {object} [opts.store] - Server-side session store (must implement `get`, `set`, `destroy`).
|
|
412
|
+
* @param {string} [opts.name='sid'] - Cookie name.
|
|
413
|
+
* @param {object} [opts.cookie] - Cookie options.
|
|
414
|
+
* @param {number} [opts.cookie.maxAge=86400000] - Cookie max-age in ms (default 24h).
|
|
415
|
+
* @param {string} [opts.cookie.path='/'] - Cookie path.
|
|
416
|
+
* @param {string} [opts.cookie.domain] - Cookie domain.
|
|
417
|
+
* @param {boolean} [opts.cookie.secure] - Secure-only flag (default: auto-detect via `req.secure`).
|
|
418
|
+
* @param {boolean} [opts.cookie.httpOnly=true] - HttpOnly flag.
|
|
419
|
+
* @param {string} [opts.cookie.sameSite='Lax'] - SameSite attribute.
|
|
420
|
+
* @param {boolean} [opts.rolling=false] - Reset cookie maxAge on every response.
|
|
421
|
+
* @param {Function} [opts.genid] - Custom session ID generator.
|
|
422
|
+
* @returns {Function} Middleware `(req, res, next) => void`.
|
|
423
|
+
*
|
|
424
|
+
* @example | Cookie Session with Rotation
|
|
425
|
+
* app.use(session({
|
|
426
|
+
* secret: ['new-key', 'old-key'],
|
|
427
|
+
* cookie: { maxAge: 3600000, secure: true },
|
|
428
|
+
* }));
|
|
429
|
+
*
|
|
430
|
+
* @example | Server-side with Memory Store
|
|
431
|
+
* const store = new MemoryStore({ ttl: 3600000 });
|
|
432
|
+
* app.use(session({ secret: 'key', store, rolling: true }));
|
|
433
|
+
*/
|
|
434
|
+
function session(opts = {})
|
|
435
|
+
{
|
|
436
|
+
if (!opts.secret) throw new Error('session() requires a secret');
|
|
437
|
+
|
|
438
|
+
const secrets = Array.isArray(opts.secret) ? opts.secret : [opts.secret];
|
|
439
|
+
const keys = secrets.map(s => _deriveKey(s));
|
|
440
|
+
const store = opts.store || null;
|
|
441
|
+
const cookieName = opts.name || DEFAULT_COOKIE_NAME;
|
|
442
|
+
const rolling = opts.rolling === true;
|
|
443
|
+
const genid = typeof opts.genid === 'function' ? opts.genid : _generateSid;
|
|
444
|
+
|
|
445
|
+
const cookieOpts = {
|
|
446
|
+
path: '/',
|
|
447
|
+
httpOnly: true,
|
|
448
|
+
sameSite: 'Lax',
|
|
449
|
+
...(opts.cookie || {}),
|
|
450
|
+
};
|
|
451
|
+
const maxAge = cookieOpts.maxAge || DEFAULT_MAX_AGE;
|
|
452
|
+
|
|
453
|
+
return async function sessionMiddleware(req, res, next)
|
|
454
|
+
{
|
|
455
|
+
// Prevent double-initialisation
|
|
456
|
+
if (req.session) return next();
|
|
457
|
+
|
|
458
|
+
const rawCookie = req.cookies?.[cookieName] || req.signedCookies?.[cookieName];
|
|
459
|
+
let sess = null;
|
|
460
|
+
|
|
461
|
+
if (store)
|
|
462
|
+
{
|
|
463
|
+
// Server-side mode: cookie holds the session ID
|
|
464
|
+
sess = await _loadServerSession(rawCookie, store, genid);
|
|
465
|
+
}
|
|
466
|
+
else
|
|
467
|
+
{
|
|
468
|
+
// Cookie mode: decrypt session from cookie
|
|
469
|
+
sess = _loadCookieSession(rawCookie, keys, genid);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
req.session = sess;
|
|
473
|
+
|
|
474
|
+
// Intercept response to persist session
|
|
475
|
+
// Hook into res.raw.end (Node ServerResponse) — the Response wrapper
|
|
476
|
+
// has no .end() method; its .send()/.json() helpers call raw.end().
|
|
477
|
+
const raw = res.raw;
|
|
478
|
+
const origEnd = raw.end.bind(raw);
|
|
479
|
+
raw.end = function sessionEnd(...args)
|
|
480
|
+
{
|
|
481
|
+
try
|
|
482
|
+
{
|
|
483
|
+
// _saveSession calls res.cookie() / res.clearCookie() which set
|
|
484
|
+
// Set-Cookie headers on raw via raw.setHeader — safe because
|
|
485
|
+
// headers aren't flushed until the original end() runs.
|
|
486
|
+
// NOTE: store-based sessions are sync-compatible because
|
|
487
|
+
// MemoryStore.set/get return resolved promises synchronously
|
|
488
|
+
// for the in-process case. For truly async stores the cookie
|
|
489
|
+
// will still be set correctly because setHeader precedes end().
|
|
490
|
+
_saveSession(req, res, sess, {
|
|
491
|
+
store, keys, cookieName, cookieOpts, maxAge, rolling,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
catch (err)
|
|
495
|
+
{
|
|
496
|
+
log.error('session save error: %s', err.message);
|
|
497
|
+
}
|
|
498
|
+
return origEnd(...args);
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
next();
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// -- Internal load/save ------------------------------------------
|
|
506
|
+
|
|
507
|
+
/** @private */
|
|
508
|
+
async function _loadServerSession(rawCookie, store, genid)
|
|
509
|
+
{
|
|
510
|
+
if (rawCookie)
|
|
511
|
+
{
|
|
512
|
+
const data = await store.get(rawCookie);
|
|
513
|
+
if (data)
|
|
514
|
+
{
|
|
515
|
+
log.debug('session loaded: %s', rawCookie);
|
|
516
|
+
return Session._deserialize(data, rawCookie);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
// New session
|
|
520
|
+
const id = genid();
|
|
521
|
+
log.debug('new session: %s', id);
|
|
522
|
+
return new Session(id);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** @private */
|
|
526
|
+
function _loadCookieSession(rawCookie, keys, genid)
|
|
527
|
+
{
|
|
528
|
+
if (rawCookie)
|
|
529
|
+
{
|
|
530
|
+
// Try each key for decryption (rotation support)
|
|
531
|
+
for (const key of keys)
|
|
532
|
+
{
|
|
533
|
+
const json = _decrypt(rawCookie, key);
|
|
534
|
+
if (json)
|
|
535
|
+
{
|
|
536
|
+
const sess = Session._deserialize(json, 'cookie');
|
|
537
|
+
log.debug('cookie session decrypted');
|
|
538
|
+
return sess;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return new Session(genid());
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** @private */
|
|
546
|
+
function _saveSession(req, res, sess, ctx)
|
|
547
|
+
{
|
|
548
|
+
if (sess._destroyed)
|
|
549
|
+
{
|
|
550
|
+
// Clear cookie and destroy store entry
|
|
551
|
+
res.clearCookie(ctx.cookieName, { path: ctx.cookieOpts.path || '/' });
|
|
552
|
+
if (ctx.store) ctx.store.destroy(sess.id);
|
|
553
|
+
log.debug('session destroyed: %s', sess.id);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const shouldSave = sess._dirty || ctx.rolling;
|
|
558
|
+
if (!shouldSave) return;
|
|
559
|
+
|
|
560
|
+
const cOpts = { ...ctx.cookieOpts, maxAge: Math.floor(ctx.maxAge / 1000) };
|
|
561
|
+
if (cOpts.secure === undefined) cOpts.secure = req.secure;
|
|
562
|
+
|
|
563
|
+
if (ctx.store)
|
|
564
|
+
{
|
|
565
|
+
// Server-side: persist data in store, session ID in cookie
|
|
566
|
+
ctx.store.set(sess.id, sess._serialize(), ctx.maxAge);
|
|
567
|
+
res.cookie(ctx.cookieName, sess.id, cOpts);
|
|
568
|
+
log.debug('server session saved: %s', sess.id);
|
|
569
|
+
}
|
|
570
|
+
else
|
|
571
|
+
{
|
|
572
|
+
// Cookie mode: encrypt session and set as cookie
|
|
573
|
+
const payload = sess._serialize();
|
|
574
|
+
const encrypted = _encrypt(payload, ctx.keys[0]);
|
|
575
|
+
if (encrypted.length > MAX_COOKIE_SIZE)
|
|
576
|
+
{
|
|
577
|
+
log.warn('session cookie exceeds %d bytes — consider using a store', MAX_COOKIE_SIZE);
|
|
578
|
+
}
|
|
579
|
+
res.cookie(ctx.cookieName, encrypted, cOpts);
|
|
580
|
+
log.debug('cookie session saved');
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
module.exports = {
|
|
585
|
+
session,
|
|
586
|
+
Session,
|
|
587
|
+
MemoryStore,
|
|
588
|
+
};
|