@mcp-ts/sdk 2.3.3 → 2.4.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.
Files changed (82) hide show
  1. package/README.md +113 -30
  2. package/dist/adapters/agui-adapter.d.mts +3 -3
  3. package/dist/adapters/agui-adapter.d.ts +3 -3
  4. package/dist/adapters/agui-middleware.d.mts +3 -3
  5. package/dist/adapters/agui-middleware.d.ts +3 -3
  6. package/dist/adapters/ai-adapter.d.mts +3 -3
  7. package/dist/adapters/ai-adapter.d.ts +3 -3
  8. package/dist/adapters/langchain-adapter.d.mts +3 -3
  9. package/dist/adapters/langchain-adapter.d.ts +3 -3
  10. package/dist/adapters/mastra-adapter.d.mts +1 -1
  11. package/dist/adapters/mastra-adapter.d.ts +1 -1
  12. package/dist/client/index.d.mts +2 -2
  13. package/dist/client/index.d.ts +2 -2
  14. package/dist/client/index.js +2 -2
  15. package/dist/client/index.js.map +1 -1
  16. package/dist/client/index.mjs +2 -2
  17. package/dist/client/index.mjs.map +1 -1
  18. package/dist/client/react.d.mts +9 -8
  19. package/dist/client/react.d.ts +9 -8
  20. package/dist/client/react.js +66 -26
  21. package/dist/client/react.js.map +1 -1
  22. package/dist/client/react.mjs +67 -27
  23. package/dist/client/react.mjs.map +1 -1
  24. package/dist/client/vue.d.mts +6 -5
  25. package/dist/client/vue.d.ts +6 -5
  26. package/dist/client/vue.js +27 -17
  27. package/dist/client/vue.js.map +1 -1
  28. package/dist/client/vue.mjs +27 -17
  29. package/dist/client/vue.mjs.map +1 -1
  30. package/dist/{index-Cfjsme-a.d.mts → index-ByIjEReo.d.mts} +2 -2
  31. package/dist/{index-CmjMd2ac.d.ts → index-CtXvKl8N.d.ts} +2 -2
  32. package/dist/index.d.mts +5 -5
  33. package/dist/index.d.ts +5 -5
  34. package/dist/index.js +729 -397
  35. package/dist/index.js.map +1 -1
  36. package/dist/index.mjs +724 -396
  37. package/dist/index.mjs.map +1 -1
  38. package/dist/{multi-session-client-DYNe6az3.d.mts → multi-session-client-CIMUGF8S.d.mts} +15 -41
  39. package/dist/{multi-session-client-BYtguGJm.d.ts → multi-session-client-CnvZEGPY.d.ts} +15 -41
  40. package/dist/server/index.d.mts +45 -17
  41. package/dist/server/index.d.ts +45 -17
  42. package/dist/server/index.js +721 -391
  43. package/dist/server/index.js.map +1 -1
  44. package/dist/server/index.mjs +718 -391
  45. package/dist/server/index.mjs.map +1 -1
  46. package/dist/shared/index.d.mts +9 -8
  47. package/dist/shared/index.d.ts +9 -8
  48. package/dist/shared/index.js +7 -5
  49. package/dist/shared/index.js.map +1 -1
  50. package/dist/shared/index.mjs +5 -4
  51. package/dist/shared/index.mjs.map +1 -1
  52. package/dist/{tool-router-BMzhoNYt.d.ts → tool-router-CZMrOG8J.d.ts} +1 -1
  53. package/dist/{tool-router-BhHsvBfP.d.mts → tool-router-CuApsDiV.d.mts} +1 -1
  54. package/dist/{types-CxFaaZrM.d.mts → types-DCk_IF4L.d.mts} +5 -3
  55. package/dist/{types-CxFaaZrM.d.ts → types-DCk_IF4L.d.ts} +5 -3
  56. package/migrations/neon/20260513010000_install_mcp_sessions.sql +40 -3
  57. package/migrations/neon/20260513020000_add_session_cleanup_cron.sql +4 -4
  58. package/migrations/supabase/20260330195700_install_mcp_sessions.sql +67 -3
  59. package/migrations/supabase/20260421010000_add_session_cleanup_cron.sql +6 -6
  60. package/package.json +13 -3
  61. package/src/client/core/sse-client.ts +2 -2
  62. package/src/client/react/index.ts +1 -1
  63. package/src/client/react/oauth-popup.tsx +34 -13
  64. package/src/client/react/use-mcp.ts +19 -45
  65. package/src/client/utils/session-state.ts +43 -0
  66. package/src/client/vue/use-mcp.ts +18 -47
  67. package/src/server/handlers/sse-handler.ts +9 -4
  68. package/src/server/mcp/multi-session-client.ts +2 -6
  69. package/src/server/mcp/oauth-client.ts +73 -220
  70. package/src/server/mcp/storage-oauth-provider.ts +79 -50
  71. package/src/server/storage/file-backend.ts +74 -18
  72. package/src/server/storage/index.ts +2 -4
  73. package/src/server/storage/memory-backend.ts +49 -13
  74. package/src/server/storage/neon-backend.ts +201 -68
  75. package/src/server/storage/redis-backend.ts +81 -23
  76. package/src/server/storage/session-lifecycle.ts +78 -0
  77. package/src/server/storage/sqlite-backend.ts +89 -15
  78. package/src/server/storage/supabase-backend.ts +188 -63
  79. package/src/server/storage/types.ts +49 -16
  80. package/src/shared/constants.ts +5 -6
  81. package/src/shared/types.ts +5 -3
  82. package/src/shared/utils.ts +26 -0
@@ -1,11 +1,14 @@
1
- import type { SessionStore, Session } from './types.js';
2
- import { SESSION_TTL_SECONDS } from '../../shared/constants.js';
1
+ import type { SessionStore, Session, SessionCredentials } from './types.js';
2
+ import type { SessionStatus } from './types.js';
3
+ import { DORMANT_SESSION_EXPIRATION_MS } from '../../shared/constants.js';
3
4
  import { generateSessionId } from '../../shared/utils.js';
4
5
  import { encryptObject, decryptObject } from './crypto.js';
6
+ import { resolveSessionExpiresAt } from './session-lifecycle.js';
5
7
 
6
8
  export interface NeonStorageOptions {
7
9
  schema?: string;
8
10
  table?: string;
11
+ credentialsTable?: string;
9
12
  }
10
13
 
11
14
  type NeonSql = {
@@ -13,6 +16,7 @@ type NeonSql = {
13
16
  };
14
17
 
15
18
  type NeonSessionRow = {
19
+ id?: string;
16
20
  session_id: string;
17
21
  server_id?: string | null;
18
22
  server_name?: string | null;
@@ -20,18 +24,27 @@ type NeonSessionRow = {
20
24
  transport_type: 'sse' | 'streamable-http';
21
25
  callback_url: string;
22
26
  created_at: string | Date;
27
+ updated_at?: string | Date | null;
28
+ expires_at?: string | Date | null;
23
29
  user_id: string;
24
30
  headers?: unknown;
25
- active?: boolean | null;
31
+ auth_url?: string | null;
32
+ status?: SessionStatus | null;
33
+ };
34
+
35
+ type NeonCredentialsRow = {
36
+ session_id: string;
37
+ user_id: string;
26
38
  client_information?: unknown;
27
39
  tokens?: unknown;
28
- code_verifier?: string | null;
40
+ code_verifier?: unknown;
29
41
  client_id?: string | null;
42
+ oauth_state?: unknown;
30
43
  };
31
44
 
32
45
  export class NeonStorageBackend implements SessionStore {
33
- private readonly DEFAULT_TTL = SESSION_TTL_SECONDS;
34
46
  private readonly tableName: string;
47
+ private readonly credentialsTableName: string;
35
48
 
36
49
  constructor(
37
50
  private readonly sql: NeonSql,
@@ -39,23 +52,29 @@ export class NeonStorageBackend implements SessionStore {
39
52
  ) {
40
53
  const schema = options.schema || 'public';
41
54
  const table = options.table || 'mcp_sessions';
55
+ const credentialsTable = options.credentialsTable || 'mcp_credentials';
42
56
  this.tableName = `${this.quoteIdentifier(schema)}.${this.quoteIdentifier(table)}`;
57
+ this.credentialsTableName = `${this.quoteIdentifier(schema)}.${this.quoteIdentifier(credentialsTable)}`;
43
58
  }
44
59
 
45
60
  async init(): Promise<void> {
61
+ await this.assertTable(this.tableName, 'mcp_sessions');
62
+ await this.assertTable(this.credentialsTableName, 'mcp_credentials');
63
+ console.log('[mcp-ts][Storage] Neon: storage tables verified.');
64
+ }
65
+
66
+ private async assertTable(qualifiedName: string, displayName: string): Promise<void> {
46
67
  const [{ exists } = { exists: null }] = await this.sql.query(
47
68
  'SELECT to_regclass($1) AS exists',
48
- [this.tableName.replace(/"/g, '')]
69
+ [qualifiedName.replace(/"/g, '')]
49
70
  ) as Array<{ exists: string | null }>;
50
71
 
51
72
  if (!exists) {
52
73
  throw new Error(
53
- '[NeonStorage] Table "mcp_sessions" not found in your database. ' +
74
+ `[NeonStorage] Table "${displayName}" not found in your database. ` +
54
75
  'Please create it using the Neon storage guide in docs/storage-backends/neon.md.'
55
76
  );
56
77
  }
57
-
58
- console.log('[mcp-ts][Storage] Neon: "mcp_sessions" table verified.');
59
78
  }
60
79
 
61
80
  generateSessionId(): string {
@@ -78,22 +97,45 @@ export class NeonStorageBackend implements SessionStore {
78
97
  transportType: row.transport_type,
79
98
  callbackUrl: row.callback_url,
80
99
  createdAt: new Date(row.created_at).getTime(),
100
+ updatedAt: new Date(row.updated_at ?? row.created_at).getTime(),
101
+ expiresAt: row.expires_at ? new Date(row.expires_at).getTime() : null,
81
102
  userId: row.user_id,
82
103
  headers: decryptObject(row.headers),
83
- active: row.active ?? false,
84
- clientInformation: row.client_information as Session['clientInformation'],
104
+ authUrl: row.auth_url ?? undefined,
105
+ status: row.status ?? 'pending',
106
+ };
107
+ }
108
+
109
+ private mapRowToCredentials(row: NeonCredentialsRow, userId: string, sessionId: string): SessionCredentials {
110
+ return {
111
+ sessionId,
112
+ userId,
113
+ clientInformation: decryptObject(row.client_information),
85
114
  tokens: decryptObject(row.tokens),
86
- codeVerifier: row.code_verifier ?? undefined,
115
+ codeVerifier: decryptObject(row.code_verifier),
87
116
  clientId: row.client_id ?? undefined,
117
+ oauthState: row.oauth_state as SessionCredentials['oauthState'],
88
118
  };
89
119
  }
90
120
 
91
- async create(session: Session, ttl?: number): Promise<void> {
121
+ private hasCredentialData(data: Partial<SessionCredentials>): boolean {
122
+ return (
123
+ 'clientInformation' in data ||
124
+ 'tokens' in data ||
125
+ 'codeVerifier' in data ||
126
+ 'clientId' in data ||
127
+ 'oauthState' in data
128
+ );
129
+ }
130
+
131
+ async create(session: Session): Promise<void> {
92
132
  const { sessionId, userId } = session;
93
133
  if (!sessionId || !userId) throw new Error('userId and sessionId required');
94
134
 
95
- const effectiveTtl = ttl ?? this.DEFAULT_TTL;
96
- const expiresAt = new Date(Date.now() + effectiveTtl * 1000).toISOString();
135
+ const status = session.status ?? 'pending';
136
+ const createdAt = new Date(session.createdAt || Date.now()).toISOString();
137
+ const updatedAt = new Date(session.updatedAt ?? session.createdAt ?? Date.now()).toISOString();
138
+ const expiresAt = resolveSessionExpiresAt(status, new Date(createdAt).getTime());
97
139
 
98
140
  try {
99
141
  await this.sql.query(
@@ -106,16 +148,14 @@ export class NeonStorageBackend implements SessionStore {
106
148
  transport_type,
107
149
  callback_url,
108
150
  created_at,
151
+ updated_at,
109
152
  headers,
110
- active,
111
- client_information,
112
- tokens,
113
- code_verifier,
114
- client_id,
153
+ auth_url,
154
+ status,
115
155
  expires_at
116
156
  ) VALUES (
117
157
  $1, $2, $3, $4, $5, $6, $7, $8,
118
- $9, $10, $11, $12, $13, $14, $15
158
+ $9, $10, $11, $12, $13
119
159
  )`,
120
160
  [
121
161
  sessionId,
@@ -125,14 +165,12 @@ export class NeonStorageBackend implements SessionStore {
125
165
  session.serverUrl,
126
166
  session.transportType,
127
167
  session.callbackUrl,
128
- new Date(session.createdAt || Date.now()).toISOString(),
168
+ createdAt,
169
+ updatedAt,
129
170
  encryptObject(session.headers),
130
- session.active ?? false,
131
- session.clientInformation,
132
- encryptObject(session.tokens),
133
- session.codeVerifier,
134
- session.clientId,
135
- expiresAt,
171
+ session.authUrl ?? null,
172
+ status,
173
+ expiresAt === null ? null : new Date(expiresAt).toISOString(),
136
174
  ]
137
175
  );
138
176
  } catch (error: any) {
@@ -141,57 +179,106 @@ export class NeonStorageBackend implements SessionStore {
141
179
  }
142
180
  throw new Error(`Failed to create session in Neon: ${error.message}`);
143
181
  }
182
+
144
183
  }
145
184
 
146
- async update(userId: string, sessionId: string, data: Partial<Session>, ttl?: number): Promise<void> {
185
+ async update(userId: string, sessionId: string, data: Partial<Session>): Promise<void> {
147
186
  const currentSession = await this.get(userId, sessionId);
148
187
  if (!currentSession) {
149
188
  throw new Error(`Session ${sessionId} not found for userId ${userId}`);
150
189
  }
151
190
 
152
191
  const updatedSession = { ...currentSession, ...data };
153
- const effectiveTtl = ttl ?? this.DEFAULT_TTL;
154
- const expiresAt = new Date(Date.now() + effectiveTtl * 1000).toISOString();
155
-
156
- const updatedRows = await this.sql.query(
157
- `UPDATE ${this.tableName}
158
- SET
159
- server_id = $1,
160
- server_name = $2,
161
- server_url = $3,
162
- transport_type = $4,
163
- callback_url = $5,
164
- active = $6,
165
- headers = $7,
166
- client_information = $8,
167
- tokens = $9,
168
- code_verifier = $10,
169
- client_id = $11,
170
- expires_at = $12,
171
- updated_at = now()
172
- WHERE user_id = $13 AND session_id = $14
173
- RETURNING id`,
192
+ const status = updatedSession.status ?? 'pending';
193
+ const expiresAt = resolveSessionExpiresAt(status);
194
+
195
+ const shouldUpdateSession = (
196
+ 'serverId' in data ||
197
+ 'serverName' in data ||
198
+ 'serverUrl' in data ||
199
+ 'transportType' in data ||
200
+ 'callbackUrl' in data ||
201
+ 'status' in data ||
202
+ 'headers' in data ||
203
+ 'authUrl' in data
204
+ );
205
+
206
+ if (shouldUpdateSession) {
207
+ const updatedRows = await this.sql.query(
208
+ `UPDATE ${this.tableName}
209
+ SET
210
+ server_id = $1,
211
+ server_name = $2,
212
+ server_url = $3,
213
+ transport_type = $4,
214
+ callback_url = $5,
215
+ status = $6,
216
+ headers = $7,
217
+ auth_url = $8,
218
+ expires_at = $9,
219
+ updated_at = now()
220
+ WHERE user_id = $10 AND session_id = $11
221
+ RETURNING id`,
222
+ [
223
+ updatedSession.serverId,
224
+ updatedSession.serverName,
225
+ updatedSession.serverUrl,
226
+ updatedSession.transportType,
227
+ updatedSession.callbackUrl,
228
+ status,
229
+ encryptObject(updatedSession.headers),
230
+ updatedSession.authUrl ?? null,
231
+ expiresAt === null ? null : new Date(expiresAt).toISOString(),
232
+ userId,
233
+ sessionId,
234
+ ]
235
+ ) as Array<{ id: string }>;
236
+
237
+ if (updatedRows.length === 0) {
238
+ throw new Error(`Session ${sessionId} not found for userId ${userId}`);
239
+ }
240
+ }
241
+
242
+ }
243
+
244
+ async patchCredentials(userId: string, sessionId: string, data: Partial<SessionCredentials>): Promise<void> {
245
+ if (!this.hasCredentialData(data)) return;
246
+
247
+ await this.sql.query(
248
+ `INSERT INTO ${this.credentialsTableName} (
249
+ user_id,
250
+ session_id,
251
+ client_information,
252
+ tokens,
253
+ code_verifier,
254
+ client_id,
255
+ oauth_state,
256
+ updated_at
257
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, now())
258
+ ON CONFLICT (user_id, session_id)
259
+ DO UPDATE SET
260
+ client_information = CASE WHEN $8 THEN EXCLUDED.client_information ELSE ${this.credentialsTableName}.client_information END,
261
+ tokens = CASE WHEN $9 THEN EXCLUDED.tokens ELSE ${this.credentialsTableName}.tokens END,
262
+ code_verifier = CASE WHEN $10 THEN EXCLUDED.code_verifier ELSE ${this.credentialsTableName}.code_verifier END,
263
+ client_id = CASE WHEN $11 THEN EXCLUDED.client_id ELSE ${this.credentialsTableName}.client_id END,
264
+ oauth_state = CASE WHEN $12 THEN EXCLUDED.oauth_state ELSE ${this.credentialsTableName}.oauth_state END,
265
+ updated_at = now()`,
174
266
  [
175
- updatedSession.serverId,
176
- updatedSession.serverName,
177
- updatedSession.serverUrl,
178
- updatedSession.transportType,
179
- updatedSession.callbackUrl,
180
- updatedSession.active ?? false,
181
- encryptObject(updatedSession.headers),
182
- updatedSession.clientInformation,
183
- encryptObject(updatedSession.tokens),
184
- updatedSession.codeVerifier,
185
- updatedSession.clientId,
186
- expiresAt,
187
267
  userId,
188
268
  sessionId,
269
+ 'clientInformation' in data ? (data.clientInformation == null ? null : encryptObject(data.clientInformation)) : null,
270
+ 'tokens' in data ? (data.tokens == null ? null : encryptObject(data.tokens)) : null,
271
+ 'codeVerifier' in data ? (data.codeVerifier == null ? null : encryptObject(data.codeVerifier)) : null,
272
+ 'clientId' in data ? (data.clientId ?? null) : null,
273
+ 'oauthState' in data ? (data.oauthState ?? null) : null,
274
+ 'clientInformation' in data,
275
+ 'tokens' in data,
276
+ 'codeVerifier' in data,
277
+ 'clientId' in data,
278
+ 'oauthState' in data,
189
279
  ]
190
- ) as Array<{ id: string }>;
280
+ );
191
281
 
192
- if (updatedRows.length === 0) {
193
- throw new Error(`Session ${sessionId} not found for userId ${userId}`);
194
- }
195
282
  }
196
283
 
197
284
  async get(userId: string, sessionId: string): Promise<Session | null> {
@@ -200,13 +287,39 @@ export class NeonStorageBackend implements SessionStore {
200
287
  `SELECT * FROM ${this.tableName} WHERE user_id = $1 AND session_id = $2`,
201
288
  [userId, sessionId]
202
289
  ) as NeonSessionRow[];
203
- return rows[0] ? this.mapRowToSessionData(rows[0]) : null;
290
+
291
+ if (!rows[0]) return null;
292
+
293
+ return this.mapRowToSessionData(rows[0]);
204
294
  } catch (error) {
205
295
  console.error('[NeonStorage] Failed to get session:', error);
206
296
  return null;
207
297
  }
208
298
  }
209
299
 
300
+ async getCredentials(userId: string, sessionId: string): Promise<SessionCredentials | null> {
301
+ try {
302
+ const credentialRows = await this.sql.query(
303
+ `SELECT * FROM ${this.credentialsTableName} WHERE user_id = $1 AND session_id = $2`,
304
+ [userId, sessionId]
305
+ ) as NeonCredentialsRow[];
306
+
307
+ if (credentialRows[0]) {
308
+ return this.mapRowToCredentials(credentialRows[0], userId, sessionId);
309
+ }
310
+
311
+ const sessionRows = await this.sql.query(
312
+ `SELECT id FROM ${this.tableName} WHERE user_id = $1 AND session_id = $2`,
313
+ [userId, sessionId]
314
+ ) as Array<{ id: string }>;
315
+
316
+ return sessionRows[0] ? { sessionId, userId } : null;
317
+ } catch (error) {
318
+ console.error('[NeonStorage] Failed to get credentials:', error);
319
+ return null;
320
+ }
321
+ }
322
+
210
323
  async list(userId: string): Promise<Session[]> {
211
324
  try {
212
325
  const rows = await this.sql.query(
@@ -220,6 +333,17 @@ export class NeonStorageBackend implements SessionStore {
220
333
  }
221
334
  }
222
335
 
336
+ async clearCredentials(userId: string, sessionId: string): Promise<void> {
337
+ try {
338
+ await this.sql.query(
339
+ `DELETE FROM ${this.credentialsTableName} WHERE user_id = $1 AND session_id = $2`,
340
+ [userId, sessionId]
341
+ );
342
+ } catch (error) {
343
+ console.error('[NeonStorage] Failed to clear credentials:', error);
344
+ }
345
+ }
346
+
223
347
  async delete(userId: string, sessionId: string): Promise<void> {
224
348
  try {
225
349
  await this.sql.query(
@@ -258,6 +382,7 @@ export class NeonStorageBackend implements SessionStore {
258
382
 
259
383
  async clearAll(): Promise<void> {
260
384
  try {
385
+ await this.sql.query(`DELETE FROM ${this.credentialsTableName}`);
261
386
  await this.sql.query(`DELETE FROM ${this.tableName}`);
262
387
  } catch (error) {
263
388
  console.error('[NeonStorage] Failed to clear sessions:', error);
@@ -267,9 +392,17 @@ export class NeonStorageBackend implements SessionStore {
267
392
  async cleanupExpired(): Promise<void> {
268
393
  try {
269
394
  await this.sql.query(
270
- `DELETE FROM ${this.tableName} WHERE expires_at < $1`,
395
+ `DELETE FROM ${this.tableName}
396
+ WHERE expires_at IS NOT NULL
397
+ AND expires_at < $1
398
+ AND status <> 'active'`,
271
399
  [new Date().toISOString()]
272
400
  );
401
+ await this.sql.query(
402
+ `DELETE FROM ${this.tableName}
403
+ WHERE status = 'active' AND updated_at < $1`,
404
+ [new Date(Date.now() - DORMANT_SESSION_EXPIRATION_MS).toISOString()]
405
+ );
273
406
  } catch (error) {
274
407
  console.error('[NeonStorage] Failed to cleanup expired sessions:', error);
275
408
  }
@@ -1,14 +1,19 @@
1
1
  import type { Redis } from 'ioredis';
2
- import type { SessionStore, Session } from './types.js';
3
- import { SESSION_TTL_SECONDS } from '../../shared/constants.js';
2
+ import type { SessionStore, Session, SessionCredentials } from './types.js';
4
3
  import { generateSessionId } from '../../shared/utils.js';
4
+ import {
5
+ mergeSessionUpdate,
6
+ normalizeNewSession,
7
+ normalizeStoredSession,
8
+ resolveSessionRedisTtlSeconds,
9
+ } from './session-lifecycle.js';
5
10
 
6
11
  /**
7
12
  * Redis implementation of SessionStore
8
13
  */
9
14
  export class RedisStorageBackend implements SessionStore {
10
- private readonly DEFAULT_TTL = SESSION_TTL_SECONDS;
11
15
  private readonly KEY_PREFIX = 'mcp:session:';
16
+ private readonly CREDENTIALS_KEY_PREFIX = 'mcp:credentials:';
12
17
  private readonly USER_ID_KEY_PREFIX = 'mcp:userId:';
13
18
  private readonly USER_ID_KEY_SUFFIX = ':sessions';
14
19
 
@@ -31,6 +36,10 @@ export class RedisStorageBackend implements SessionStore {
31
36
  return `${this.KEY_PREFIX}${userId}:${sessionId}`;
32
37
  }
33
38
 
39
+ private getCredentialsKey(userId: string, sessionId: string): string {
40
+ return `${this.CREDENTIALS_KEY_PREFIX}${userId}:${sessionId}`;
41
+ }
42
+
34
43
  /**
35
44
  * Generates Redis key for tracking all sessions for a user
36
45
  * @private
@@ -78,18 +87,18 @@ export class RedisStorageBackend implements SessionStore {
78
87
  return generateSessionId();
79
88
  }
80
89
 
81
- async create(session: Session, ttl?: number): Promise<void> {
90
+ async create(session: Session): Promise<void> {
82
91
  const { sessionId, userId } = session;
83
92
  if (!sessionId || !userId) throw new Error('userId and sessionId required');
84
93
 
85
94
  const sessionKey = this.getSessionKey(userId, sessionId);
86
95
  const userIdKey = this.getUserIdKey(userId);
87
- const effectiveTtl = ttl ?? this.DEFAULT_TTL;
96
+ const sessionWithLifecycle = normalizeNewSession(session);
97
+ const effectiveTtl = resolveSessionRedisTtlSeconds(sessionWithLifecycle);
88
98
 
89
- /** ioredis syntax: set(key, val, 'EX', ttl, 'NX') */
90
99
  const result = await this.redis.set(
91
100
  sessionKey,
92
- JSON.stringify(session),
101
+ JSON.stringify(sessionWithLifecycle),
93
102
  'EX',
94
103
  effectiveTtl,
95
104
  'NX'
@@ -101,11 +110,10 @@ export class RedisStorageBackend implements SessionStore {
101
110
 
102
111
  await this.redis.sadd(userIdKey, sessionId);
103
112
  }
104
- async update(userId: string, sessionId: string, data: Partial<Session>, ttl?: number): Promise<void> {
113
+ async update(userId: string, sessionId: string, data: Partial<Session>): Promise<void> {
105
114
  const sessionKey = this.getSessionKey(userId, sessionId);
106
- const effectiveTtl = ttl ?? this.DEFAULT_TTL;
107
115
 
108
- /** Lua script for atomic parsing, merging, and saving */
116
+ /** Lua script for atomic get-and-set with expiration refresh. */
109
117
  const script = `
110
118
  local currentStr = redis.call("GET", KEYS[1])
111
119
  if not currentStr then
@@ -113,21 +121,25 @@ export class RedisStorageBackend implements SessionStore {
113
121
  end
114
122
 
115
123
  local current = cjson.decode(currentStr)
116
- local updates = cjson.decode(ARGV[1])
124
+ local updated = cjson.decode(ARGV[1])
117
125
 
118
- for k,v in pairs(updates) do
119
- current[k] = v
120
- end
121
-
122
- redis.call("SET", KEYS[1], cjson.encode(current), "EX", ARGV[2])
126
+ redis.call("SET", KEYS[1], cjson.encode(updated), "EX", ARGV[2])
123
127
  return 1
124
128
  `;
125
129
 
130
+ const current = await this.get(userId, sessionId);
131
+ if (!current) {
132
+ throw new Error(`Session ${sessionId} not found for userId ${userId}`);
133
+ }
134
+
135
+ const updated = mergeSessionUpdate(current, data);
136
+ const effectiveTtl = resolveSessionRedisTtlSeconds(updated);
137
+
126
138
  const result = await this.redis.eval(
127
139
  script,
128
140
  1,
129
141
  sessionKey,
130
- JSON.stringify(data),
142
+ JSON.stringify(updated),
131
143
  effectiveTtl
132
144
  );
133
145
 
@@ -136,6 +148,29 @@ export class RedisStorageBackend implements SessionStore {
136
148
  }
137
149
  }
138
150
 
151
+ async patchCredentials(userId: string, sessionId: string, data: Partial<SessionCredentials>): Promise<void> {
152
+ const sessionKey = this.getSessionKey(userId, sessionId);
153
+ const credentialsKey = this.getCredentialsKey(userId, sessionId);
154
+ const sessionExists = await this.redis.exists(sessionKey);
155
+ if (!sessionExists) {
156
+ throw new Error(`Session ${sessionId} not found for userId ${userId}`);
157
+ }
158
+
159
+ const session = await this.get(userId, sessionId);
160
+ const currentTtl = await this.redis.ttl(sessionKey);
161
+ const effectiveTtl = currentTtl > 0 && session
162
+ ? currentTtl
163
+ : resolveSessionRedisTtlSeconds(session ?? { status: 'pending' });
164
+ const currentStr = await this.redis.get(credentialsKey);
165
+ const current = currentStr ? JSON.parse(currentStr) as SessionCredentials : { sessionId, userId };
166
+ const credentials = { ...current, ...data, sessionId, userId };
167
+ if (effectiveTtl > 0) {
168
+ await this.redis.set(credentialsKey, JSON.stringify(credentials), 'EX', effectiveTtl);
169
+ } else {
170
+ await this.redis.set(credentialsKey, JSON.stringify(credentials));
171
+ }
172
+ }
173
+
139
174
  async get(userId: string, sessionId: string): Promise<Session | null> {
140
175
  try {
141
176
  const sessionKey = this.getSessionKey(userId, sessionId);
@@ -145,14 +180,32 @@ export class RedisStorageBackend implements SessionStore {
145
180
  return null;
146
181
  }
147
182
 
148
- const Session: Session = JSON.parse(sessionDataStr);
149
- return Session;
183
+ const session: Session = JSON.parse(sessionDataStr);
184
+ return normalizeStoredSession(session);
150
185
  } catch (error) {
151
186
  console.error('[RedisStorageBackend] Failed to get session:', error);
152
187
  return null;
153
188
  }
154
189
  }
155
190
 
191
+ async getCredentials(userId: string, sessionId: string): Promise<SessionCredentials | null> {
192
+ const session = await this.get(userId, sessionId);
193
+ if (!session) return null;
194
+
195
+ const credentialsStr = await this.redis.get(this.getCredentialsKey(userId, sessionId));
196
+ return credentialsStr ? JSON.parse(credentialsStr) as SessionCredentials : { sessionId, userId };
197
+ }
198
+
199
+ async clearCredentials(userId: string, sessionId: string): Promise<void> {
200
+ await this.patchCredentials(userId, sessionId, {
201
+ clientInformation: null,
202
+ tokens: null,
203
+ codeVerifier: null,
204
+ clientId: null,
205
+ oauthState: null,
206
+ });
207
+ }
208
+
156
209
  async listIds(userId: string): Promise<string[]> {
157
210
  const sessions = await this.list(userId);
158
211
  return sessions.map((session) => session.sessionId);
@@ -176,7 +229,9 @@ export class RedisStorageBackend implements SessionStore {
176
229
  await this.redis.srem(userIdKey, ...staleSessionIds);
177
230
  }
178
231
 
179
- return results.filter((session): session is Session => session !== null);
232
+ return results
233
+ .filter((session): session is Session => session !== null)
234
+ .map((session) => normalizeStoredSession(session));
180
235
  } catch (error) {
181
236
  console.error(`[RedisStorageBackend] Failed to get session data for ${userId}:`, error);
182
237
  return [];
@@ -186,10 +241,11 @@ export class RedisStorageBackend implements SessionStore {
186
241
  async delete(userId: string, sessionId: string): Promise<void> {
187
242
  try {
188
243
  const sessionKey = this.getSessionKey(userId, sessionId);
244
+ const credentialsKey = this.getCredentialsKey(userId, sessionId);
189
245
  const userIdKey = this.getUserIdKey(userId);
190
246
 
191
247
  await this.redis.srem(userIdKey, sessionId);
192
- await this.redis.del(sessionKey);
248
+ await this.redis.del(sessionKey, credentialsKey);
193
249
  } catch (error) {
194
250
  console.error('[RedisStorageBackend] Failed to remove session:', error);
195
251
  }
@@ -206,7 +262,7 @@ export class RedisStorageBackend implements SessionStore {
206
262
  }
207
263
 
208
264
  try {
209
- return (JSON.parse(data) as Session).sessionId;
265
+ return normalizeStoredSession(JSON.parse(data) as Session).sessionId;
210
266
  } catch (error) {
211
267
  console.error('[RedisStorageBackend] Failed to parse session while listing all session IDs:', error);
212
268
  return null;
@@ -224,8 +280,9 @@ export class RedisStorageBackend implements SessionStore {
224
280
  async clearAll(): Promise<void> {
225
281
  try {
226
282
  const keys = await this.scanKeys(`${this.KEY_PREFIX}*`);
283
+ const credentialKeys = await this.scanKeys(`${this.CREDENTIALS_KEY_PREFIX}*`);
227
284
  const userIdKeys = await this.scanKeys(`${this.USER_ID_KEY_PREFIX}*${this.USER_ID_KEY_SUFFIX}`);
228
- const allKeys = [...keys, ...userIdKeys];
285
+ const allKeys = [...keys, ...credentialKeys, ...userIdKeys];
229
286
  if (allKeys.length > 0) {
230
287
  await this.redis.del(...allKeys);
231
288
  }
@@ -254,6 +311,7 @@ export class RedisStorageBackend implements SessionStore {
254
311
  const staleSessionIds = sessionIds.filter((_, index) => existenceChecks[index] === 0);
255
312
  if (staleSessionIds.length > 0) {
256
313
  await this.redis.srem(userIdKey, ...staleSessionIds);
314
+ await this.redis.del(...staleSessionIds.map((sessionId) => this.getCredentialsKey(userId, sessionId)));
257
315
  }
258
316
 
259
317
  const remainingCount = await this.redis.scard(userIdKey);