@veloxts/auth 0.3.3 → 0.3.4
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 +755 -30
- package/dist/adapter.d.ts +710 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +581 -0
- package/dist/adapter.js.map +1 -0
- package/dist/adapters/better-auth.d.ts +271 -0
- package/dist/adapters/better-auth.d.ts.map +1 -0
- package/dist/adapters/better-auth.js +341 -0
- package/dist/adapters/better-auth.js.map +1 -0
- package/dist/adapters/index.d.ts +28 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +28 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/csrf.d.ts +294 -0
- package/dist/csrf.d.ts.map +1 -0
- package/dist/csrf.js +396 -0
- package/dist/csrf.js.map +1 -0
- package/dist/guards.d.ts +139 -0
- package/dist/guards.d.ts.map +1 -0
- package/dist/guards.js +247 -0
- package/dist/guards.js.map +1 -0
- package/dist/hash.d.ts +85 -0
- package/dist/hash.d.ts.map +1 -0
- package/dist/hash.js +220 -0
- package/dist/hash.js.map +1 -0
- package/dist/index.d.ts +25 -32
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +63 -36
- package/dist/index.js.map +1 -1
- package/dist/jwt.d.ts +128 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +363 -0
- package/dist/jwt.js.map +1 -0
- package/dist/middleware.d.ts +87 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +241 -0
- package/dist/middleware.js.map +1 -0
- package/dist/plugin.d.ts +107 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +174 -0
- package/dist/plugin.js.map +1 -0
- package/dist/policies.d.ts +137 -0
- package/dist/policies.d.ts.map +1 -0
- package/dist/policies.js +240 -0
- package/dist/policies.js.map +1 -0
- package/dist/session.d.ts +494 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +795 -0
- package/dist/session.js.map +1 -0
- package/dist/types.d.ts +251 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +33 -0
- package/dist/types.js.map +1 -0
- package/package.json +38 -7
package/dist/session.js
ADDED
|
@@ -0,0 +1,795 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cookie-based Session Management for @veloxts/auth
|
|
3
|
+
*
|
|
4
|
+
* Provides server-side session storage with pluggable backends,
|
|
5
|
+
* secure session ID generation, and automatic session lifecycle management.
|
|
6
|
+
*
|
|
7
|
+
* Alternative to JWT authentication - users choose one or the other.
|
|
8
|
+
*
|
|
9
|
+
* @module auth/session
|
|
10
|
+
*/
|
|
11
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
12
|
+
import { AuthError } from './types.js';
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Constants
|
|
15
|
+
// ============================================================================
|
|
16
|
+
/**
|
|
17
|
+
* Session ID entropy in bytes (32 bytes = 256 bits)
|
|
18
|
+
* Provides sufficient entropy to prevent brute-force attacks
|
|
19
|
+
*/
|
|
20
|
+
const SESSION_ID_BYTES = 32;
|
|
21
|
+
/**
|
|
22
|
+
* Minimum secret length for session ID signing (32 characters)
|
|
23
|
+
*/
|
|
24
|
+
const MIN_SECRET_LENGTH = 32;
|
|
25
|
+
/**
|
|
26
|
+
* Default session TTL (24 hours in seconds)
|
|
27
|
+
*/
|
|
28
|
+
const DEFAULT_SESSION_TTL = 86400;
|
|
29
|
+
/**
|
|
30
|
+
* Default cookie name
|
|
31
|
+
*/
|
|
32
|
+
const DEFAULT_COOKIE_NAME = 'velox.session';
|
|
33
|
+
// ============================================================================
|
|
34
|
+
// In-Memory Session Store
|
|
35
|
+
// ============================================================================
|
|
36
|
+
/**
|
|
37
|
+
* In-memory session store for development and testing
|
|
38
|
+
*
|
|
39
|
+
* WARNING: NOT suitable for production!
|
|
40
|
+
* - Sessions are lost on server restart
|
|
41
|
+
* - Does not work across multiple server instances
|
|
42
|
+
* - No persistence mechanism
|
|
43
|
+
*
|
|
44
|
+
* For production, use Redis or database-backed storage.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```typescript
|
|
48
|
+
* const store = createInMemorySessionStore();
|
|
49
|
+
*
|
|
50
|
+
* const sessionManager = createSessionManager({
|
|
51
|
+
* store,
|
|
52
|
+
* secret: process.env.SESSION_SECRET!,
|
|
53
|
+
* });
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export function createInMemorySessionStore() {
|
|
57
|
+
const sessions = new Map();
|
|
58
|
+
const userSessions = new Map();
|
|
59
|
+
/**
|
|
60
|
+
* Clean up expired sessions
|
|
61
|
+
*/
|
|
62
|
+
function cleanup() {
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
for (const [id, session] of sessions) {
|
|
65
|
+
if (session.expiresAt <= now) {
|
|
66
|
+
// Remove from user index
|
|
67
|
+
const userId = session.data.userId;
|
|
68
|
+
if (userId) {
|
|
69
|
+
const userSessionSet = userSessions.get(userId);
|
|
70
|
+
if (userSessionSet) {
|
|
71
|
+
userSessionSet.delete(id);
|
|
72
|
+
if (userSessionSet.size === 0) {
|
|
73
|
+
userSessions.delete(userId);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
sessions.delete(id);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Run cleanup periodically (every 5 minutes)
|
|
82
|
+
const cleanupInterval = setInterval(cleanup, 5 * 60 * 1000);
|
|
83
|
+
// Don't prevent process exit
|
|
84
|
+
cleanupInterval.unref();
|
|
85
|
+
return {
|
|
86
|
+
get(sessionId) {
|
|
87
|
+
const session = sessions.get(sessionId);
|
|
88
|
+
if (!session) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
// Check expiration
|
|
92
|
+
if (session.expiresAt <= Date.now()) {
|
|
93
|
+
sessions.delete(sessionId);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return session;
|
|
97
|
+
},
|
|
98
|
+
set(sessionId, session) {
|
|
99
|
+
// Track user sessions for getSessionsByUser
|
|
100
|
+
const existingSession = sessions.get(sessionId);
|
|
101
|
+
const oldUserId = existingSession?.data.userId;
|
|
102
|
+
const newUserId = session.data.userId;
|
|
103
|
+
// Update user index if userId changed
|
|
104
|
+
if (oldUserId && oldUserId !== newUserId) {
|
|
105
|
+
const oldSet = userSessions.get(oldUserId);
|
|
106
|
+
if (oldSet) {
|
|
107
|
+
oldSet.delete(sessionId);
|
|
108
|
+
if (oldSet.size === 0) {
|
|
109
|
+
userSessions.delete(oldUserId);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (newUserId) {
|
|
114
|
+
let userSet = userSessions.get(newUserId);
|
|
115
|
+
if (!userSet) {
|
|
116
|
+
userSet = new Set();
|
|
117
|
+
userSessions.set(newUserId, userSet);
|
|
118
|
+
}
|
|
119
|
+
userSet.add(sessionId);
|
|
120
|
+
}
|
|
121
|
+
sessions.set(sessionId, session);
|
|
122
|
+
},
|
|
123
|
+
delete(sessionId) {
|
|
124
|
+
const session = sessions.get(sessionId);
|
|
125
|
+
if (session?.data.userId) {
|
|
126
|
+
const userSet = userSessions.get(session.data.userId);
|
|
127
|
+
if (userSet) {
|
|
128
|
+
userSet.delete(sessionId);
|
|
129
|
+
if (userSet.size === 0) {
|
|
130
|
+
userSessions.delete(session.data.userId);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
sessions.delete(sessionId);
|
|
135
|
+
},
|
|
136
|
+
touch(sessionId, expiresAt) {
|
|
137
|
+
const session = sessions.get(sessionId);
|
|
138
|
+
if (session) {
|
|
139
|
+
session.expiresAt = expiresAt;
|
|
140
|
+
session.data._lastAccessedAt = Date.now();
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
clear() {
|
|
144
|
+
sessions.clear();
|
|
145
|
+
userSessions.clear();
|
|
146
|
+
},
|
|
147
|
+
getSessionsByUser(userId) {
|
|
148
|
+
const userSet = userSessions.get(userId);
|
|
149
|
+
return userSet ? [...userSet] : [];
|
|
150
|
+
},
|
|
151
|
+
deleteSessionsByUser(userId) {
|
|
152
|
+
const userSet = userSessions.get(userId);
|
|
153
|
+
if (userSet) {
|
|
154
|
+
for (const sessionId of userSet) {
|
|
155
|
+
sessions.delete(sessionId);
|
|
156
|
+
}
|
|
157
|
+
userSessions.delete(userId);
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
// ============================================================================
|
|
163
|
+
// Session ID Generation and Signing
|
|
164
|
+
// ============================================================================
|
|
165
|
+
/**
|
|
166
|
+
* Generate a cryptographically secure session ID
|
|
167
|
+
*/
|
|
168
|
+
function generateSessionId() {
|
|
169
|
+
const bytes = randomBytes(SESSION_ID_BYTES);
|
|
170
|
+
// Use base64url encoding for URL-safe session IDs
|
|
171
|
+
return bytes.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Sign a session ID with HMAC-SHA256
|
|
175
|
+
*/
|
|
176
|
+
function signSessionId(sessionId, secret) {
|
|
177
|
+
const hmac = createHmac('sha256', secret);
|
|
178
|
+
hmac.update(sessionId);
|
|
179
|
+
const signature = hmac.digest('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
|
180
|
+
return `${sessionId}.${signature}`;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Verify and extract session ID from signed value
|
|
184
|
+
* Uses timing-safe comparison to prevent timing attacks
|
|
185
|
+
*/
|
|
186
|
+
function verifySessionId(signedId, secret) {
|
|
187
|
+
const dotIndex = signedId.lastIndexOf('.');
|
|
188
|
+
if (dotIndex === -1) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
const sessionId = signedId.slice(0, dotIndex);
|
|
192
|
+
const signature = signedId.slice(dotIndex + 1);
|
|
193
|
+
// Recompute expected signature
|
|
194
|
+
const hmac = createHmac('sha256', secret);
|
|
195
|
+
hmac.update(sessionId);
|
|
196
|
+
const expectedSignature = hmac
|
|
197
|
+
.digest('base64')
|
|
198
|
+
.replace(/\+/g, '-')
|
|
199
|
+
.replace(/\//g, '_')
|
|
200
|
+
.replace(/=/g, '');
|
|
201
|
+
// Timing-safe comparison
|
|
202
|
+
const sigBuffer = Buffer.from(signature, 'utf8');
|
|
203
|
+
const expectedBuffer = Buffer.from(expectedSignature, 'utf8');
|
|
204
|
+
if (sigBuffer.length !== expectedBuffer.length) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
if (!timingSafeEqual(sigBuffer, expectedBuffer)) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
return sessionId;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Validate session ID entropy (prevent weak session IDs)
|
|
214
|
+
*/
|
|
215
|
+
function validateSessionIdEntropy(sessionId) {
|
|
216
|
+
// Session ID should be at least 32 bytes when decoded
|
|
217
|
+
// Our base64url encoding produces ~43 characters for 32 bytes
|
|
218
|
+
if (sessionId.length < 40) {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
// Check for sufficient character variety (basic entropy check)
|
|
222
|
+
const uniqueChars = new Set(sessionId).size;
|
|
223
|
+
return uniqueChars >= 16;
|
|
224
|
+
}
|
|
225
|
+
// ============================================================================
|
|
226
|
+
// Session Manager Implementation
|
|
227
|
+
// ============================================================================
|
|
228
|
+
/**
|
|
229
|
+
* Creates a session manager
|
|
230
|
+
*
|
|
231
|
+
* @example
|
|
232
|
+
* ```typescript
|
|
233
|
+
* const sessionManager = createSessionManager({
|
|
234
|
+
* secret: process.env.SESSION_SECRET!,
|
|
235
|
+
* cookie: {
|
|
236
|
+
* name: 'myapp.session',
|
|
237
|
+
* secure: true,
|
|
238
|
+
* sameSite: 'strict',
|
|
239
|
+
* },
|
|
240
|
+
* expiration: {
|
|
241
|
+
* ttl: 3600, // 1 hour
|
|
242
|
+
* sliding: true,
|
|
243
|
+
* },
|
|
244
|
+
* });
|
|
245
|
+
* ```
|
|
246
|
+
*/
|
|
247
|
+
export function createSessionManager(config) {
|
|
248
|
+
// Validate secret
|
|
249
|
+
if (!config.secret || config.secret.length < MIN_SECRET_LENGTH) {
|
|
250
|
+
throw new Error(`Session secret must be at least ${MIN_SECRET_LENGTH} characters. ` +
|
|
251
|
+
'Generate with: openssl rand -base64 32');
|
|
252
|
+
}
|
|
253
|
+
// Validate secret entropy
|
|
254
|
+
const uniqueChars = new Set(config.secret).size;
|
|
255
|
+
if (uniqueChars < 16) {
|
|
256
|
+
throw new Error(`Session secret has insufficient entropy (only ${uniqueChars} unique characters). ` +
|
|
257
|
+
'Use cryptographically random data with at least 16 unique characters.');
|
|
258
|
+
}
|
|
259
|
+
// Initialize store
|
|
260
|
+
const store = config.store ?? createInMemorySessionStore();
|
|
261
|
+
// Cookie configuration
|
|
262
|
+
const cookieName = config.cookie?.name ?? DEFAULT_COOKIE_NAME;
|
|
263
|
+
const cookiePath = config.cookie?.path ?? '/';
|
|
264
|
+
const cookieDomain = config.cookie?.domain;
|
|
265
|
+
const cookieSecure = config.cookie?.secure ?? process.env.NODE_ENV === 'production';
|
|
266
|
+
const cookieHttpOnly = config.cookie?.httpOnly ?? true;
|
|
267
|
+
const cookieSameSite = config.cookie?.sameSite ?? 'lax';
|
|
268
|
+
// Expiration configuration
|
|
269
|
+
const ttl = config.expiration?.ttl ?? DEFAULT_SESSION_TTL;
|
|
270
|
+
const sliding = config.expiration?.sliding ?? true;
|
|
271
|
+
const absoluteTimeout = config.expiration?.absoluteTimeout;
|
|
272
|
+
// Security validation: SameSite=none requires Secure
|
|
273
|
+
if (cookieSameSite === 'none' && !cookieSecure) {
|
|
274
|
+
throw new Error('Session cookie with SameSite=none requires Secure flag. ' +
|
|
275
|
+
'Set cookie.secure: true or use a different SameSite policy.');
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Set session cookie
|
|
279
|
+
*/
|
|
280
|
+
function setCookie(reply, signedId) {
|
|
281
|
+
reply.cookie(cookieName, signedId, {
|
|
282
|
+
path: cookiePath,
|
|
283
|
+
domain: cookieDomain,
|
|
284
|
+
secure: cookieSecure,
|
|
285
|
+
httpOnly: cookieHttpOnly,
|
|
286
|
+
sameSite: cookieSameSite,
|
|
287
|
+
maxAge: ttl,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Clear session cookie
|
|
292
|
+
*/
|
|
293
|
+
function clearCookie(reply) {
|
|
294
|
+
reply.clearCookie(cookieName, {
|
|
295
|
+
path: cookiePath,
|
|
296
|
+
domain: cookieDomain,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Create a Session handle
|
|
301
|
+
*/
|
|
302
|
+
function createSessionHandle(sessionId, data, expiresAt, isNew, reply) {
|
|
303
|
+
let modified = isNew;
|
|
304
|
+
let destroyed = false;
|
|
305
|
+
let currentId = sessionId;
|
|
306
|
+
let currentData = { ...data };
|
|
307
|
+
let currentExpiresAt = expiresAt;
|
|
308
|
+
// Move flash data to old flash for reading
|
|
309
|
+
if (currentData._flash) {
|
|
310
|
+
currentData._flashOld = currentData._flash;
|
|
311
|
+
delete currentData._flash;
|
|
312
|
+
modified = true;
|
|
313
|
+
}
|
|
314
|
+
const session = {
|
|
315
|
+
get id() {
|
|
316
|
+
return currentId;
|
|
317
|
+
},
|
|
318
|
+
get isNew() {
|
|
319
|
+
return isNew;
|
|
320
|
+
},
|
|
321
|
+
get isModified() {
|
|
322
|
+
return modified;
|
|
323
|
+
},
|
|
324
|
+
get isDestroyed() {
|
|
325
|
+
return destroyed;
|
|
326
|
+
},
|
|
327
|
+
get data() {
|
|
328
|
+
return currentData;
|
|
329
|
+
},
|
|
330
|
+
get(key) {
|
|
331
|
+
return currentData[key];
|
|
332
|
+
},
|
|
333
|
+
set(key, value) {
|
|
334
|
+
if (destroyed) {
|
|
335
|
+
throw new AuthError('Cannot modify destroyed session', 400, 'SESSION_DESTROYED');
|
|
336
|
+
}
|
|
337
|
+
currentData[key] = value;
|
|
338
|
+
modified = true;
|
|
339
|
+
},
|
|
340
|
+
delete(key) {
|
|
341
|
+
if (destroyed) {
|
|
342
|
+
throw new AuthError('Cannot modify destroyed session', 400, 'SESSION_DESTROYED');
|
|
343
|
+
}
|
|
344
|
+
delete currentData[key];
|
|
345
|
+
modified = true;
|
|
346
|
+
},
|
|
347
|
+
has(key) {
|
|
348
|
+
return key in currentData;
|
|
349
|
+
},
|
|
350
|
+
flash(key, value) {
|
|
351
|
+
if (destroyed) {
|
|
352
|
+
throw new AuthError('Cannot modify destroyed session', 400, 'SESSION_DESTROYED');
|
|
353
|
+
}
|
|
354
|
+
if (!currentData._flash) {
|
|
355
|
+
currentData._flash = {};
|
|
356
|
+
}
|
|
357
|
+
currentData._flash[key] = value;
|
|
358
|
+
modified = true;
|
|
359
|
+
},
|
|
360
|
+
getFlash(key) {
|
|
361
|
+
const value = currentData._flashOld?.[key];
|
|
362
|
+
return value;
|
|
363
|
+
},
|
|
364
|
+
getAllFlash() {
|
|
365
|
+
return currentData._flashOld ?? {};
|
|
366
|
+
},
|
|
367
|
+
async regenerate() {
|
|
368
|
+
if (destroyed) {
|
|
369
|
+
throw new AuthError('Cannot regenerate destroyed session', 400, 'SESSION_DESTROYED');
|
|
370
|
+
}
|
|
371
|
+
// Delete old session
|
|
372
|
+
await store.delete(currentId);
|
|
373
|
+
// Generate new ID
|
|
374
|
+
const newSessionId = generateSessionId();
|
|
375
|
+
const newSignedId = signSessionId(newSessionId, config.secret);
|
|
376
|
+
// Update tracking
|
|
377
|
+
currentId = newSessionId;
|
|
378
|
+
currentData._lastAccessedAt = Date.now();
|
|
379
|
+
modified = true;
|
|
380
|
+
// Set new cookie
|
|
381
|
+
setCookie(reply, newSignedId);
|
|
382
|
+
// Save with new ID
|
|
383
|
+
await store.set(currentId, {
|
|
384
|
+
id: currentId,
|
|
385
|
+
data: currentData,
|
|
386
|
+
expiresAt: currentExpiresAt,
|
|
387
|
+
});
|
|
388
|
+
},
|
|
389
|
+
async destroy() {
|
|
390
|
+
await store.delete(currentId);
|
|
391
|
+
clearCookie(reply);
|
|
392
|
+
destroyed = true;
|
|
393
|
+
currentData = {};
|
|
394
|
+
},
|
|
395
|
+
async save() {
|
|
396
|
+
if (destroyed) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
// Clear old flash data after it's been read
|
|
400
|
+
delete currentData._flashOld;
|
|
401
|
+
// Update last accessed time
|
|
402
|
+
currentData._lastAccessedAt = Date.now();
|
|
403
|
+
// Check absolute timeout
|
|
404
|
+
if (absoluteTimeout) {
|
|
405
|
+
const absoluteExpiresAt = currentData._createdAt + absoluteTimeout * 1000;
|
|
406
|
+
if (Date.now() >= absoluteExpiresAt) {
|
|
407
|
+
await session.destroy();
|
|
408
|
+
throw new AuthError('Session has reached absolute timeout', 401, 'SESSION_EXPIRED');
|
|
409
|
+
}
|
|
410
|
+
// Cap expiration at absolute timeout
|
|
411
|
+
currentExpiresAt = Math.min(currentExpiresAt, absoluteExpiresAt);
|
|
412
|
+
}
|
|
413
|
+
// Update sliding expiration
|
|
414
|
+
if (sliding) {
|
|
415
|
+
currentExpiresAt = Date.now() + ttl * 1000;
|
|
416
|
+
}
|
|
417
|
+
await store.set(currentId, {
|
|
418
|
+
id: currentId,
|
|
419
|
+
data: currentData,
|
|
420
|
+
expiresAt: currentExpiresAt,
|
|
421
|
+
});
|
|
422
|
+
// Refresh cookie with new expiration
|
|
423
|
+
const signedId = signSessionId(currentId, config.secret);
|
|
424
|
+
setCookie(reply, signedId);
|
|
425
|
+
modified = false;
|
|
426
|
+
},
|
|
427
|
+
async reload() {
|
|
428
|
+
if (destroyed) {
|
|
429
|
+
throw new AuthError('Cannot reload destroyed session', 400, 'SESSION_DESTROYED');
|
|
430
|
+
}
|
|
431
|
+
const stored = await store.get(currentId);
|
|
432
|
+
if (!stored) {
|
|
433
|
+
throw new AuthError('Session not found', 401, 'SESSION_NOT_FOUND');
|
|
434
|
+
}
|
|
435
|
+
currentData = { ...stored.data };
|
|
436
|
+
currentExpiresAt = stored.expiresAt;
|
|
437
|
+
modified = false;
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
return session;
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
store,
|
|
444
|
+
createSession(reply) {
|
|
445
|
+
const sessionId = generateSessionId();
|
|
446
|
+
const signedId = signSessionId(sessionId, config.secret);
|
|
447
|
+
const now = Date.now();
|
|
448
|
+
const expiresAt = now + ttl * 1000;
|
|
449
|
+
const data = {
|
|
450
|
+
_createdAt: now,
|
|
451
|
+
_lastAccessedAt: now,
|
|
452
|
+
};
|
|
453
|
+
// Set cookie
|
|
454
|
+
setCookie(reply, signedId);
|
|
455
|
+
return createSessionHandle(sessionId, data, expiresAt, true, reply);
|
|
456
|
+
},
|
|
457
|
+
async loadSession(request) {
|
|
458
|
+
const signedId = request.cookies[cookieName];
|
|
459
|
+
if (!signedId) {
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
// Verify signature and extract session ID
|
|
463
|
+
const sessionId = verifySessionId(signedId, config.secret);
|
|
464
|
+
if (!sessionId) {
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
// Validate session ID entropy
|
|
468
|
+
if (!validateSessionIdEntropy(sessionId)) {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
// Load from store
|
|
472
|
+
const stored = await store.get(sessionId);
|
|
473
|
+
if (!stored) {
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
// Check expiration
|
|
477
|
+
if (stored.expiresAt <= Date.now()) {
|
|
478
|
+
await store.delete(sessionId);
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
// We need reply for the session handle, but loadSession doesn't have it
|
|
482
|
+
// This is intentional - loadSession is for checking only
|
|
483
|
+
// Use getOrCreateSession for full session handling
|
|
484
|
+
return null;
|
|
485
|
+
},
|
|
486
|
+
async getOrCreateSession(request, reply) {
|
|
487
|
+
const signedId = request.cookies[cookieName];
|
|
488
|
+
if (signedId) {
|
|
489
|
+
// Verify signature
|
|
490
|
+
const sessionId = verifySessionId(signedId, config.secret);
|
|
491
|
+
if (sessionId && validateSessionIdEntropy(sessionId)) {
|
|
492
|
+
// Load from store
|
|
493
|
+
const stored = await store.get(sessionId);
|
|
494
|
+
if (stored && stored.expiresAt > Date.now()) {
|
|
495
|
+
// Check absolute timeout before returning
|
|
496
|
+
if (absoluteTimeout) {
|
|
497
|
+
const absoluteExpiresAt = stored.data._createdAt + absoluteTimeout * 1000;
|
|
498
|
+
if (Date.now() >= absoluteExpiresAt) {
|
|
499
|
+
await store.delete(sessionId);
|
|
500
|
+
clearCookie(reply);
|
|
501
|
+
// Create new session
|
|
502
|
+
return this.createSession(reply);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return createSessionHandle(sessionId, stored.data, stored.expiresAt, false, reply);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
// Create new session
|
|
510
|
+
return this.createSession(reply);
|
|
511
|
+
},
|
|
512
|
+
async destroySession(sessionId) {
|
|
513
|
+
await store.delete(sessionId);
|
|
514
|
+
},
|
|
515
|
+
async destroyUserSessions(userId) {
|
|
516
|
+
if (store.deleteSessionsByUser) {
|
|
517
|
+
await store.deleteSessionsByUser(userId);
|
|
518
|
+
}
|
|
519
|
+
else if (store.getSessionsByUser) {
|
|
520
|
+
const sessions = await store.getSessionsByUser(userId);
|
|
521
|
+
for (const sessionId of sessions) {
|
|
522
|
+
await store.delete(sessionId);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
// If store doesn't support user session tracking, silently skip
|
|
526
|
+
},
|
|
527
|
+
clearCookie,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
// ============================================================================
|
|
531
|
+
// Session Middleware Factory
|
|
532
|
+
// ============================================================================
|
|
533
|
+
/**
|
|
534
|
+
* Creates session middleware for procedures
|
|
535
|
+
*
|
|
536
|
+
* @example
|
|
537
|
+
* ```typescript
|
|
538
|
+
* const session = createSessionMiddleware({
|
|
539
|
+
* secret: process.env.SESSION_SECRET!,
|
|
540
|
+
* cookie: { secure: true },
|
|
541
|
+
* });
|
|
542
|
+
*
|
|
543
|
+
* // Use in procedures
|
|
544
|
+
* const getCart = procedure()
|
|
545
|
+
* .use(session.middleware())
|
|
546
|
+
* .query(async ({ ctx }) => {
|
|
547
|
+
* return ctx.session.get('cart') ?? [];
|
|
548
|
+
* });
|
|
549
|
+
*
|
|
550
|
+
* // Require authentication
|
|
551
|
+
* const getProfile = procedure()
|
|
552
|
+
* .use(session.requireAuth())
|
|
553
|
+
* .query(async ({ ctx }) => {
|
|
554
|
+
* return ctx.user;
|
|
555
|
+
* });
|
|
556
|
+
* ```
|
|
557
|
+
*/
|
|
558
|
+
export function createSessionMiddleware(config) {
|
|
559
|
+
const manager = createSessionManager(config);
|
|
560
|
+
/**
|
|
561
|
+
* Base session middleware
|
|
562
|
+
*/
|
|
563
|
+
function middleware(options = {}) {
|
|
564
|
+
return async ({ ctx, next }) => {
|
|
565
|
+
const request = ctx.request;
|
|
566
|
+
const reply = ctx.reply;
|
|
567
|
+
let session;
|
|
568
|
+
if (options.lazy) {
|
|
569
|
+
// Lazy mode: only create session if accessed
|
|
570
|
+
// This requires a proxy to detect access
|
|
571
|
+
let realSession = null;
|
|
572
|
+
const lazySession = new Proxy({}, {
|
|
573
|
+
get(_target, prop) {
|
|
574
|
+
if (!realSession) {
|
|
575
|
+
// Create session on first access
|
|
576
|
+
// Note: This is synchronous, so we can't use getOrCreateSession
|
|
577
|
+
// For lazy mode, we create a new session
|
|
578
|
+
realSession = manager.createSession(reply);
|
|
579
|
+
}
|
|
580
|
+
const value = realSession[prop];
|
|
581
|
+
if (typeof value === 'function') {
|
|
582
|
+
return value.bind(realSession);
|
|
583
|
+
}
|
|
584
|
+
return value;
|
|
585
|
+
},
|
|
586
|
+
});
|
|
587
|
+
session = lazySession;
|
|
588
|
+
}
|
|
589
|
+
else {
|
|
590
|
+
session = await manager.getOrCreateSession(request, reply);
|
|
591
|
+
}
|
|
592
|
+
// Attach to request for hooks
|
|
593
|
+
request.session = session;
|
|
594
|
+
try {
|
|
595
|
+
const result = await next({
|
|
596
|
+
ctx: {
|
|
597
|
+
...ctx,
|
|
598
|
+
session,
|
|
599
|
+
},
|
|
600
|
+
});
|
|
601
|
+
// Auto-save session if modified
|
|
602
|
+
if (session.isModified && !session.isDestroyed) {
|
|
603
|
+
await session.save();
|
|
604
|
+
}
|
|
605
|
+
return result;
|
|
606
|
+
}
|
|
607
|
+
catch (error) {
|
|
608
|
+
// Still try to save session on error
|
|
609
|
+
if (session.isModified && !session.isDestroyed) {
|
|
610
|
+
try {
|
|
611
|
+
await session.save();
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
// Ignore save errors during error handling
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
throw error;
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Middleware that requires authentication
|
|
623
|
+
*/
|
|
624
|
+
function requireAuth() {
|
|
625
|
+
return async ({ ctx, next }) => {
|
|
626
|
+
const request = ctx.request;
|
|
627
|
+
const reply = ctx.reply;
|
|
628
|
+
const session = await manager.getOrCreateSession(request, reply);
|
|
629
|
+
// Check if session has userId
|
|
630
|
+
const userId = session.get('userId');
|
|
631
|
+
if (!userId) {
|
|
632
|
+
throw new AuthError('Authentication required', 401, 'SESSION_UNAUTHORIZED');
|
|
633
|
+
}
|
|
634
|
+
// Load user if userLoader provided
|
|
635
|
+
let user;
|
|
636
|
+
if (config.userLoader) {
|
|
637
|
+
const loadedUser = await config.userLoader(userId);
|
|
638
|
+
if (!loadedUser) {
|
|
639
|
+
// User no longer exists - destroy session
|
|
640
|
+
await session.destroy();
|
|
641
|
+
throw new AuthError('User not found', 401, 'USER_NOT_FOUND');
|
|
642
|
+
}
|
|
643
|
+
user = loadedUser;
|
|
644
|
+
}
|
|
645
|
+
else {
|
|
646
|
+
// Create minimal user from session
|
|
647
|
+
user = {
|
|
648
|
+
id: userId,
|
|
649
|
+
email: session.get('userEmail') ?? '',
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
request.session = session;
|
|
653
|
+
try {
|
|
654
|
+
const result = await next({
|
|
655
|
+
ctx: {
|
|
656
|
+
...ctx,
|
|
657
|
+
session,
|
|
658
|
+
user,
|
|
659
|
+
isAuthenticated: true,
|
|
660
|
+
},
|
|
661
|
+
});
|
|
662
|
+
if (session.isModified && !session.isDestroyed) {
|
|
663
|
+
await session.save();
|
|
664
|
+
}
|
|
665
|
+
return result;
|
|
666
|
+
}
|
|
667
|
+
catch (error) {
|
|
668
|
+
if (session.isModified && !session.isDestroyed) {
|
|
669
|
+
try {
|
|
670
|
+
await session.save();
|
|
671
|
+
}
|
|
672
|
+
catch {
|
|
673
|
+
// Ignore
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
throw error;
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Middleware for optional authentication
|
|
682
|
+
*/
|
|
683
|
+
function optionalAuth() {
|
|
684
|
+
return async ({ ctx, next }) => {
|
|
685
|
+
const request = ctx.request;
|
|
686
|
+
const reply = ctx.reply;
|
|
687
|
+
const session = await manager.getOrCreateSession(request, reply);
|
|
688
|
+
const userId = session.get('userId');
|
|
689
|
+
let user;
|
|
690
|
+
let isAuthenticated = false;
|
|
691
|
+
if (userId) {
|
|
692
|
+
if (config.userLoader) {
|
|
693
|
+
const loadedUser = await config.userLoader(userId);
|
|
694
|
+
if (loadedUser) {
|
|
695
|
+
user = loadedUser;
|
|
696
|
+
isAuthenticated = true;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
else {
|
|
700
|
+
user = {
|
|
701
|
+
id: userId,
|
|
702
|
+
email: session.get('userEmail') ?? '',
|
|
703
|
+
};
|
|
704
|
+
isAuthenticated = true;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
request.session = session;
|
|
708
|
+
try {
|
|
709
|
+
const result = await next({
|
|
710
|
+
ctx: {
|
|
711
|
+
...ctx,
|
|
712
|
+
session,
|
|
713
|
+
user,
|
|
714
|
+
isAuthenticated,
|
|
715
|
+
},
|
|
716
|
+
});
|
|
717
|
+
if (session.isModified && !session.isDestroyed) {
|
|
718
|
+
await session.save();
|
|
719
|
+
}
|
|
720
|
+
return result;
|
|
721
|
+
}
|
|
722
|
+
catch (error) {
|
|
723
|
+
if (session.isModified && !session.isDestroyed) {
|
|
724
|
+
try {
|
|
725
|
+
await session.save();
|
|
726
|
+
}
|
|
727
|
+
catch {
|
|
728
|
+
// Ignore
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
throw error;
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
return {
|
|
736
|
+
/** Session manager instance */
|
|
737
|
+
manager,
|
|
738
|
+
/** Base session middleware */
|
|
739
|
+
middleware,
|
|
740
|
+
/** Authentication required middleware */
|
|
741
|
+
requireAuth,
|
|
742
|
+
/** Optional authentication middleware */
|
|
743
|
+
optionalAuth,
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
// ============================================================================
|
|
747
|
+
// Session Helper Functions
|
|
748
|
+
// ============================================================================
|
|
749
|
+
/**
|
|
750
|
+
* Login helper - sets user in session and regenerates ID
|
|
751
|
+
*
|
|
752
|
+
* @example
|
|
753
|
+
* ```typescript
|
|
754
|
+
* const login = procedure()
|
|
755
|
+
* .use(session.middleware())
|
|
756
|
+
* .input(LoginSchema)
|
|
757
|
+
* .mutation(async ({ input, ctx }) => {
|
|
758
|
+
* const user = await verifyCredentials(input.email, input.password);
|
|
759
|
+
* await loginSession(ctx.session, user);
|
|
760
|
+
* return { success: true };
|
|
761
|
+
* });
|
|
762
|
+
* ```
|
|
763
|
+
*/
|
|
764
|
+
export async function loginSession(session, user) {
|
|
765
|
+
// Regenerate session ID to prevent session fixation
|
|
766
|
+
await session.regenerate();
|
|
767
|
+
// Store user info in session
|
|
768
|
+
session.set('userId', user.id);
|
|
769
|
+
session.set('userEmail', user.email);
|
|
770
|
+
// Save immediately
|
|
771
|
+
await session.save();
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Logout helper - destroys session
|
|
775
|
+
*
|
|
776
|
+
* @example
|
|
777
|
+
* ```typescript
|
|
778
|
+
* const logout = procedure()
|
|
779
|
+
* .use(session.requireAuth())
|
|
780
|
+
* .mutation(async ({ ctx }) => {
|
|
781
|
+
* await logoutSession(ctx.session);
|
|
782
|
+
* return { success: true };
|
|
783
|
+
* });
|
|
784
|
+
* ```
|
|
785
|
+
*/
|
|
786
|
+
export async function logoutSession(session) {
|
|
787
|
+
await session.destroy();
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Check if session is authenticated
|
|
791
|
+
*/
|
|
792
|
+
export function isSessionAuthenticated(session) {
|
|
793
|
+
return !!session.get('userId');
|
|
794
|
+
}
|
|
795
|
+
//# sourceMappingURL=session.js.map
|