@payez/next-mvp 4.1.11 → 4.1.20

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 (178) hide show
  1. package/dist/api-handlers/account/profile.d.ts +38 -0
  2. package/dist/api-handlers/account/profile.js +73 -45
  3. package/dist/api-handlers/account/recovery/send-code.d.ts +33 -0
  4. package/dist/api-handlers/account/recovery/send-code.js +52 -18
  5. package/dist/api-handlers/account/recovery/verify-code.d.ts +33 -0
  6. package/dist/api-handlers/account/recovery/verify-code.js +52 -18
  7. package/dist/api-handlers/account/verify-email.js +15 -1
  8. package/dist/api-handlers/account/verify-sms.js +15 -1
  9. package/dist/api-handlers/auth/passkey-signin.d.ts +29 -0
  10. package/dist/api-handlers/auth/passkey-signin.js +97 -0
  11. package/dist/api-handlers/mfa/backup-code-verify.d.ts +73 -0
  12. package/dist/api-handlers/mfa/backup-code-verify.js +153 -0
  13. package/dist/api-handlers/mfa/backup-codes.d.ts +7 -0
  14. package/dist/api-handlers/mfa/backup-codes.js +40 -0
  15. package/dist/api-handlers/mfa/methods.d.ts +3 -0
  16. package/dist/api-handlers/mfa/methods.js +41 -0
  17. package/dist/api-handlers/mfa/totp-confirm.d.ts +8 -0
  18. package/dist/api-handlers/mfa/totp-confirm.js +41 -0
  19. package/dist/api-handlers/mfa/totp-enroll.d.ts +8 -0
  20. package/dist/api-handlers/mfa/totp-enroll.js +41 -0
  21. package/dist/api-handlers/mfa/totp-verify.d.ts +47 -0
  22. package/dist/api-handlers/mfa/totp-verify.js +134 -0
  23. package/dist/api-handlers/passkeys/list.d.ts +7 -0
  24. package/dist/api-handlers/passkeys/list.js +29 -0
  25. package/dist/api-handlers/passkeys/manage.d.ts +3 -0
  26. package/dist/api-handlers/passkeys/manage.js +54 -0
  27. package/dist/api-handlers/passkeys/register-options.d.ts +8 -0
  28. package/dist/api-handlers/passkeys/register-options.js +41 -0
  29. package/dist/api-handlers/passkeys/register-verify.d.ts +7 -0
  30. package/dist/api-handlers/passkeys/register-verify.js +40 -0
  31. package/dist/api-handlers/session/viability.js +19 -0
  32. package/dist/auth/better-auth.d.ts +68 -5
  33. package/dist/auth/better-auth.js +256 -42
  34. package/dist/auth/utils/idp-client.d.ts +13 -22
  35. package/dist/auth/utils/idp-client.js +12 -82
  36. package/dist/client/better-auth-client.d.ts +14 -2
  37. package/dist/lib/session-store.js +28 -21
  38. package/dist/lib/startup-init.js +19 -19
  39. package/dist/lib/token-lifecycle.js +40 -4
  40. package/dist/models/SessionModel.d.ts +17 -0
  41. package/dist/pages/login/page.d.ts +10 -1
  42. package/dist/pages/login/page.js +69 -80
  43. package/dist/pages/verify-code/page.d.ts +10 -1
  44. package/dist/pages/verify-code/page.js +72 -4
  45. package/dist/routes/auth/passkey-signin.d.ts +13 -0
  46. package/dist/routes/auth/passkey-signin.js +18 -0
  47. package/dist/routes/auth/viability.d.ts +2 -0
  48. package/dist/routes/auth/viability.js +88 -0
  49. package/dist/routes/mfa/backup-code-verify.d.ts +1 -0
  50. package/dist/routes/mfa/backup-code-verify.js +5 -0
  51. package/dist/routes/mfa/backup-codes.d.ts +10 -0
  52. package/dist/routes/mfa/backup-codes.js +14 -0
  53. package/dist/routes/mfa/methods.d.ts +21 -0
  54. package/dist/routes/mfa/methods.js +10 -0
  55. package/dist/routes/mfa/totp-confirm.d.ts +10 -0
  56. package/dist/routes/mfa/totp-confirm.js +14 -0
  57. package/dist/routes/mfa/totp-enroll.d.ts +10 -0
  58. package/dist/routes/mfa/totp-enroll.js +14 -0
  59. package/dist/routes/mfa/totp-verify.d.ts +14 -0
  60. package/dist/routes/mfa/totp-verify.js +18 -0
  61. package/dist/routes/passkeys/list.d.ts +10 -0
  62. package/dist/routes/passkeys/list.js +14 -0
  63. package/dist/routes/passkeys/manage.d.ts +18 -0
  64. package/dist/routes/passkeys/manage.js +13 -0
  65. package/dist/routes/passkeys/register-options.d.ts +10 -0
  66. package/dist/routes/passkeys/register-options.js +14 -0
  67. package/dist/routes/passkeys/register-verify.d.ts +10 -0
  68. package/dist/routes/passkeys/register-verify.js +14 -0
  69. package/dist/server/auth.d.ts +2 -2
  70. package/dist/server/decode-session.js +37 -3
  71. package/package.json +958 -893
  72. package/scripts/check-internal-url-usage.sh +73 -73
  73. package/src/api/index.ts +17 -17
  74. package/src/api-handlers/account/masked-info.ts +45 -45
  75. package/src/api-handlers/account/profile.ts +99 -80
  76. package/src/api-handlers/account/recovery/initiate.ts +23 -23
  77. package/src/api-handlers/account/recovery/send-code.ts +73 -25
  78. package/src/api-handlers/account/recovery/verify-code.ts +73 -25
  79. package/src/api-handlers/account/reset-password.ts +23 -23
  80. package/src/api-handlers/account/send-code.ts +76 -76
  81. package/src/api-handlers/account/validate-password.ts +118 -118
  82. package/src/api-handlers/account/verify-email.ts +15 -1
  83. package/src/api-handlers/account/verify-sms.ts +15 -1
  84. package/src/api-handlers/admin/stats.ts +249 -249
  85. package/src/api-handlers/anon/preferences.ts +123 -123
  86. package/src/api-handlers/auth/jwks.ts +20 -20
  87. package/src/api-handlers/auth/login.ts +240 -240
  88. package/src/api-handlers/auth/passkey-signin.ts +98 -0
  89. package/src/api-handlers/auth/validate.ts +43 -43
  90. package/src/api-handlers/mfa/backup-code-verify.ts +175 -0
  91. package/src/api-handlers/mfa/backup-codes.ts +41 -0
  92. package/src/api-handlers/mfa/methods.ts +46 -0
  93. package/src/api-handlers/mfa/totp-confirm.ts +42 -0
  94. package/src/api-handlers/mfa/totp-enroll.ts +42 -0
  95. package/src/api-handlers/mfa/totp-verify.ts +156 -0
  96. package/src/api-handlers/passkeys/list.ts +31 -0
  97. package/src/api-handlers/passkeys/manage.ts +54 -0
  98. package/src/api-handlers/passkeys/register-options.ts +42 -0
  99. package/src/api-handlers/passkeys/register-verify.ts +41 -0
  100. package/src/api-handlers/session/viability.ts +161 -142
  101. package/src/auth/better-auth.ts +247 -6
  102. package/src/auth/unauthenticated-routes.ts +2 -2
  103. package/src/auth/utils/idp-client.ts +13 -95
  104. package/src/client/better-auth-client.ts +70 -70
  105. package/src/client/fetchWithSession.ts +21 -21
  106. package/src/client/index.ts +13 -13
  107. package/src/client/useAnonSession.ts +131 -131
  108. package/src/components/admin/VibeAdminContext.tsx +87 -87
  109. package/src/config/logging-config.ts +82 -82
  110. package/src/config/unauthenticated-routes.ts +19 -19
  111. package/src/edge/internal-api-url.ts +65 -65
  112. package/src/edge/middleware.ts +42 -42
  113. package/src/hooks/useProfile.ts +75 -75
  114. package/src/lib/anon-session.ts +213 -213
  115. package/src/lib/auth-secret.ts +137 -137
  116. package/src/lib/demo-mode.ts +17 -17
  117. package/src/lib/idp-client-config.ts +559 -559
  118. package/src/lib/idp-fetch.ts +126 -126
  119. package/src/lib/internal-api.ts +171 -171
  120. package/src/lib/jwt-decode-client.ts +45 -45
  121. package/src/lib/rate-limit-service.ts +9 -9
  122. package/src/lib/redis.ts +26 -26
  123. package/src/lib/refresh-token-validator.ts +64 -64
  124. package/src/lib/secret-validation.ts +8 -8
  125. package/src/lib/session-store.ts +702 -695
  126. package/src/lib/session.ts +34 -34
  127. package/src/lib/startup-init.ts +251 -251
  128. package/src/lib/token-expiry.ts +40 -40
  129. package/src/lib/token-lifecycle.ts +533 -497
  130. package/src/lib/types/api-responses.ts +336 -336
  131. package/src/middleware/auth-decision.ts +43 -43
  132. package/src/models/DecodedAccessToken.ts +16 -16
  133. package/src/models/SessionModel.ts +282 -264
  134. package/src/pages/admin-roles/index.ts +9 -9
  135. package/src/pages/login/page.tsx +123 -228
  136. package/src/pages/security/EnhancedSecurityPage.tsx +9 -9
  137. package/src/pages/verify-code/page.tsx +71 -5
  138. package/src/routes/account/index.ts +31 -31
  139. package/src/routes/account/masked-info.ts +36 -36
  140. package/src/routes/account/send-code.ts +39 -39
  141. package/src/routes/account/verify-email.ts +40 -40
  142. package/src/routes/account/verify-sms.ts +40 -40
  143. package/src/routes/auth/index.ts +22 -22
  144. package/src/routes/auth/passkey-signin.ts +13 -0
  145. package/src/routes/auth/refresh.ts +48 -48
  146. package/src/routes/auth/session.ts +166 -166
  147. package/src/routes/auth/viability.ts +89 -0
  148. package/src/routes/index.ts +17 -17
  149. package/src/routes/mfa/backup-code-verify.ts +1 -0
  150. package/src/routes/mfa/backup-codes.ts +10 -0
  151. package/src/routes/mfa/methods.ts +22 -0
  152. package/src/routes/mfa/totp-confirm.ts +10 -0
  153. package/src/routes/mfa/totp-enroll.ts +10 -0
  154. package/src/routes/mfa/totp-verify.ts +14 -0
  155. package/src/routes/passkeys/list.ts +10 -0
  156. package/src/routes/passkeys/manage.ts +23 -0
  157. package/src/routes/passkeys/register-options.ts +10 -0
  158. package/src/routes/passkeys/register-verify.ts +10 -0
  159. package/src/routes/session/index.ts +7 -7
  160. package/src/routes/session/refresh-viability.ts +17 -17
  161. package/src/server/auth.ts +373 -373
  162. package/src/server/decode-session.ts +235 -202
  163. package/src/services/signalrActivityService.ts +258 -258
  164. package/src/theme/ThemeProvider.tsx +39 -39
  165. package/src/theme/default.ts +33 -33
  166. package/src/theme/index.ts +31 -31
  167. package/src/theme/types.ts +69 -69
  168. package/src/theme/useTheme.ts +57 -57
  169. package/src/theme/utils.ts +40 -40
  170. package/src/types/api.ts +12 -12
  171. package/src/types/auth.d.ts +23 -23
  172. package/src/types/auth.ts +22 -22
  173. package/src/types/logging.ts +11 -11
  174. package/src/types/recovery.ts +54 -54
  175. package/src/utils/api.ts +353 -353
  176. package/src/utils/circuitBreaker.ts +40 -40
  177. package/src/utils/error-message.ts +108 -108
  178. package/src/utils/layout/reservedSpace.ts +124 -124
@@ -1,695 +1,702 @@
1
- /**
2
- * Session Store for `@payez/next-mvp` using ioredis
3
- *
4
- * This module provides a Redis-backed session store that is compatible with the
5
- * `ioredis` client. It handles the creation, retrieval, and deletion of
6
- * session data, which is the single source of truth for authentication.
7
- *
8
- * Includes advanced distributed refresh coordination with version control.
9
- */
10
-
11
- import redis from './redis';
12
- import { randomBytes } from 'crypto';
13
- import { SessionData } from '../models/SessionModel';
14
- import { getSessionPrefix, getRefreshLockPrefix } from './app-slug';
15
- import { extractKidFromToken } from '../auth/utils/token-utils';
16
-
17
- // Re-export SessionData for consumers
18
- export type { SessionData } from '../models/SessionModel';
19
-
20
- // Use app-slug prefixes for multi-app isolation
21
- const getSessionKey = (token: string) => `${getSessionPrefix()}${token}`;
22
- const getRefreshLockKey = (token: string) => `${getRefreshLockPrefix()}${token}`;
23
- const getSessionVersionKey = (token: string) => `${getSessionPrefix()}ver:${token}`;
24
-
25
- // Better Auth uses a different key format: ba:{appSlug}:{token}
26
- const getBetterAuthSessionKey = (token: string, appSlug?: string) => `ba:${appSlug || 'app'}:${token}`;
27
-
28
- const REFRESH_LOCK_TTL = 60; // 60 seconds
29
- // 3 days in seconds (matches refresh token lifetime). EXPORTED as the single source
30
- // of session TTL — better-auth.ts derives its BA-session Redis TTL from this so the
31
- // two can never drift apart again (the 7d-vs-3d drift = the zombie-session window, QA 6952).
32
- export const SESSION_TTL = 3 * 24 * 60 * 60;
33
-
34
- export interface RefreshLockInfo {
35
- sessionToken: string;
36
- acquiredAt: number;
37
- acquiredBy: string; // request ID or process identifier
38
- lockVersion: number;
39
- }
40
-
41
- /**
42
- * Generates a new session token.
43
- * @returns A new session token string.
44
- */
45
- export function generateSessionToken(): string {
46
- return randomBytes(32).toString('hex');
47
- }
48
-
49
- /**
50
- * Creates a new session in Redis.
51
- *
52
- * @param data The session data to store.
53
- * @returns The generated session token (redisSessionId).
54
- */
55
- export async function createSession(data: SessionData): Promise<string> {
56
- const sessionToken = randomBytes(32).toString('hex');
57
- const key = getSessionKey(sessionToken);
58
- const versionKey = getSessionVersionKey(sessionToken);
59
-
60
- try {
61
- await redis.multi()
62
- .setex(key, SESSION_TTL, JSON.stringify(data))
63
- .setex(versionKey, SESSION_TTL, '1')
64
- .exec();
65
- } catch (error) {
66
- console.error('[SESSION-STORE] Failed to create session:', error);
67
- throw error;
68
- }
69
-
70
- return sessionToken;
71
- }
72
-
73
- /**
74
- * Retrieves a session from Redis.
75
- *
76
- * @param sessionToken The session token (redisSessionId) to look up.
77
- * @returns The session data, or null if not found.
78
- */
79
- export async function getSession(sessionToken: string): Promise<SessionData | null> {
80
- if (!sessionToken) {
81
- return null;
82
- }
83
- const key = getSessionKey(sessionToken);
84
- const json = await redis.get(key);
85
- if (!json) {
86
- return null;
87
- }
88
- try {
89
- return JSON.parse(json) as SessionData;
90
- } catch {
91
- console.error('[SESSION-STORE] Failed to parse session data');
92
- return null;
93
- }
94
- }
95
-
96
- /**
97
- * Retrieves a Better Auth session from Redis.
98
- * Better Auth uses key format: ba:{appSlug}:{token}
99
- *
100
- * @param sessionToken The session token to look up.
101
- * @param appSlug The app slug (defaults to 'idealvibe_online' or extracted from env).
102
- * @returns The session data, or null if not found.
103
- */
104
- export async function getBetterAuthSession(sessionToken: string, appSlug?: string): Promise<SessionData | null> {
105
- if (!sessionToken) {
106
- return null;
107
- }
108
- // Try to get appSlug from env if not provided
109
- const slug = appSlug || process.env.CLIENT_ID || 'idealvibe_online';
110
- const key = getBetterAuthSessionKey(sessionToken, slug);
111
- const json = await redis.get(key);
112
- if (!json) {
113
- return null;
114
- }
115
- try {
116
- const data = JSON.parse(json);
117
- // Better Auth stores the session differently - extract user data
118
- if (data.user) {
119
- return {
120
- userId: data.user.id || data.user.email,
121
- email: data.user.email,
122
- name: data.user.name,
123
- image: data.user.image,
124
- idpAccessToken: data.idpTokens?.idpAccessToken,
125
- idpRefreshToken: data.idpTokens?.idpRefreshToken,
126
- idpAccessTokenExpires: data.idpTokens?.idpAccessTokenExpires,
127
- mfaVerified: data.idpTokens?.mfaVerified ?? false,
128
- roles: data.idpTokens?.roles || [],
129
- idpClientId: data.idpTokens?.idpClientId ?? data.idpTokens?.clientId ?? data.idpClientId,
130
- merchantId: data.idpTokens?.merchantId ?? data.merchantId,
131
- } as SessionData;
132
- }
133
- return data as SessionData;
134
- } catch {
135
- console.error('[SESSION-STORE] Failed to parse Better Auth session data');
136
- return null;
137
- }
138
- }
139
-
140
- /**
141
- * Refresh session TTL without reading/writing data (sliding window expiry).
142
- */
143
- export async function touchSession(token: string): Promise<void> {
144
- const key = getSessionKey(token);
145
- await redis.expire(key, SESSION_TTL);
146
- }
147
-
148
- /**
149
- * Retrieves a session along with a version identifier for optimistic locking.
150
- * @param sessionToken The session token to look up.
151
- * @returns An object with session and version, or null if not found.
152
- */
153
- export async function getSessionWithVersion(sessionToken: string): Promise<{ session: SessionData; version: string } | null> {
154
- const session = await getSession(sessionToken);
155
- if (!session) {
156
- return null;
157
- }
158
-
159
- const versionKey = getSessionVersionKey(sessionToken);
160
- const version = await redis.get(versionKey);
161
-
162
- if (!version) {
163
- // Session exists but version key missing - use idpAccessTokenExpires as fallback
164
- const fallbackVersion = String(session.idpAccessTokenExpires || Date.now());
165
- return { session, version: fallbackVersion };
166
- }
167
-
168
- return { session, version };
169
- }
170
-
171
- /**
172
- * Checks if the access token in a session is still fresh (not expired).
173
- * @param sessionToken The session token to check.
174
- * @param currentAccessToken The current access token to compare.
175
- * @param currentVersion Optional version to check for changes.
176
- * @returns Object with freshness status and latest token info.
177
- */
178
- export async function isAccessTokenFresh(
179
- sessionToken: string,
180
- currentAccessToken: string,
181
- currentVersion?: string
182
- ): Promise<{
183
- isFresh: boolean;
184
- latestAccessToken?: string;
185
- latestVersion?: string;
186
- versionChanged: boolean;
187
- }> {
188
- const sessionWithVersion = await getSessionWithVersion(sessionToken);
189
-
190
- if (!sessionWithVersion) {
191
- return { isFresh: false, versionChanged: true };
192
- }
193
-
194
- const { session, version } = sessionWithVersion;
195
- const versionChanged = currentVersion ? version !== currentVersion : false;
196
-
197
- // If version changed, the token might be stale
198
- // Use normalized field name (idpAccessToken)
199
- const isFresh = !versionChanged && session.idpAccessToken === currentAccessToken;
200
-
201
- return {
202
- isFresh,
203
- latestAccessToken: session.idpAccessToken || undefined,
204
- latestVersion: version,
205
- versionChanged
206
- };
207
- }
208
-
209
- /**
210
- * Deletes a session from Redis.
211
- * @param sessionToken The session token to delete.
212
- */
213
- export async function deleteSession(sessionToken: string): Promise<void> {
214
- if (!sessionToken) {
215
- return;
216
- }
217
- const key = getSessionKey(sessionToken);
218
- const versionKey = getSessionVersionKey(sessionToken);
219
- await redis.del(key, versionKey);
220
- }
221
-
222
- /**
223
- * Sets a session directly (for testing or migrations).
224
- * @param sessionToken The session token.
225
- * @param data The session data.
226
- */
227
- export async function setSession(sessionToken: string, data: SessionData): Promise<void> {
228
- const key = getSessionKey(sessionToken);
229
- const versionKey = getSessionVersionKey(sessionToken);
230
- await redis.multi()
231
- .setex(key, SESSION_TTL, JSON.stringify(data))
232
- .incr(versionKey)
233
- .expire(versionKey, SESSION_TTL)
234
- .exec();
235
- }
236
-
237
- /**
238
- * Updates tokens within an existing session.
239
- * @param sessionToken The session token to update.
240
- * @param updates Partial session data to update.
241
- * @returns The updated session data, or null if the session was not found.
242
- */
243
- export async function updateSession(
244
- sessionToken: string,
245
- updates: Partial<SessionData>
246
- ): Promise<SessionData | null> {
247
- const key = getSessionKey(sessionToken);
248
- const versionKey = getSessionVersionKey(sessionToken);
249
-
250
- // Get current session to merge with updates
251
- const currentSession = await getSession(sessionToken);
252
- if (!currentSession) {
253
- return null;
254
- }
255
-
256
- // CRITICAL: Track any refresh token changes (check both old and new field names)
257
- const hadRefreshToken = !!(currentSession.idpRefreshToken || (currentSession as any).refreshToken);
258
- const willHaveRefreshToken = (updates as any).idpRefreshToken !== undefined
259
- ? !!(updates as any).idpRefreshToken
260
- : (updates as any).refreshToken !== undefined
261
- ? !!(updates as any).refreshToken
262
- : hadRefreshToken;
263
-
264
- if (hadRefreshToken && !willHaveRefreshToken) {
265
- console.error('[SESSION-STORE] ⚠️ REFRESH_TOKEN_BEING_CLEARED', {
266
- sessionToken: sessionToken.substring(0, 8) + '...',
267
- userId: currentSession.userId,
268
- updateKeys: Object.keys(updates),
269
- refreshTokenInUpdate: (updates as any).idpRefreshToken || (updates as any).refreshToken,
270
- refreshTokenClearedReason: (updates as any).refreshTokenClearedReason || 'UNKNOWN',
271
- stack: new Error().stack
272
- });
273
- }
274
-
275
- // Merge current session with updates
276
- const updatedSession = { ...currentSession, ...updates };
277
-
278
- // Write the entire updated session back to Redis with version increment
279
- await redis.multi()
280
- .setex(key, SESSION_TTL, JSON.stringify(updatedSession))
281
- .incr(versionKey)
282
- .expire(versionKey, SESSION_TTL)
283
- .exec();
284
-
285
- return updatedSession;
286
- }
287
-
288
- /**
289
- * Transitions a session to a MFA-completed state.
290
- * @param sessionToken The session token to update.
291
- * @param tokens The new tokens received after MFA completion.
292
- * @param mfaMethod The MFA method used (email, sms, totp) - required for token refresh.
293
- * @returns The updated session data.
294
- */
295
- export async function transitionTo2FASession(
296
- sessionToken: string,
297
- tokens: {
298
- accessToken?: string;
299
- refreshToken?: string;
300
- accessTokenExpires?: number;
301
- refreshTokenExpires?: number;
302
- // Support new field names
303
- idpAccessToken?: string;
304
- idpRefreshToken?: string;
305
- idpAccessTokenExpires?: number;
306
- idpRefreshTokenExpires?: number;
307
- },
308
- mfaMethod?: 'email' | 'sms' | 'totp'
309
- ): Promise<SessionData | null> {
310
- const newAccessToken = tokens.idpAccessToken || tokens.accessToken;
311
-
312
- console.log('[transitionTo2FASession] Called with:', {
313
- sessionToken: sessionToken?.substring(0, 8) + '...',
314
- mfaMethod,
315
- hasAccessToken: !!newAccessToken,
316
- hasRefreshToken: !!(tokens.idpRefreshToken || tokens.refreshToken),
317
- });
318
-
319
- // Extract bearerKeyId from the new access token (IDP may use different key after 2FA)
320
- let bearerKeyId: string | undefined;
321
- if (newAccessToken) {
322
- bearerKeyId = extractKidFromToken(newAccessToken);
323
- if (bearerKeyId) {
324
- console.log('[transitionTo2FASession] Extracted bearerKeyId (kid) from new JWT header:', bearerKeyId);
325
- }
326
- }
327
-
328
- // Support both old and new field names in input
329
- // CRITICAL: Set BOTH mfaVerified (new) AND twoFactorComplete (legacy) for compatibility
330
- // auth.ts session callback reads twoFactorComplete, so we must set it here
331
- const updates: Partial<SessionData> = {
332
- idpAccessToken: newAccessToken,
333
- idpRefreshToken: tokens.idpRefreshToken || tokens.refreshToken,
334
- idpAccessTokenExpires: tokens.idpAccessTokenExpires || tokens.accessTokenExpires,
335
- mfaVerified: true,
336
- twoFactorComplete: true, // Legacy field - required by auth.ts session callback
337
- mfaMethod: mfaMethod,
338
- // Update bearerKeyId if extracted from new token
339
- ...(bearerKeyId && { bearerKeyId }),
340
- };
341
-
342
- const refreshExpires = tokens.idpRefreshTokenExpires || tokens.refreshTokenExpires;
343
- if (refreshExpires !== undefined) {
344
- updates.idpRefreshTokenExpires = refreshExpires;
345
- }
346
-
347
- console.log('[transitionTo2FASession] Updates to apply:', {
348
- mfaVerified: updates.mfaVerified,
349
- twoFactorComplete: (updates as any).twoFactorComplete,
350
- mfaMethod: updates.mfaMethod,
351
- hasIdpAccessToken: !!updates.idpAccessToken,
352
- });
353
-
354
- const result = await updateSession(sessionToken, updates);
355
-
356
- console.log('[transitionTo2FASession] Result:', {
357
- success: !!result,
358
- resultMfaVerified: result?.mfaVerified,
359
- });
360
-
361
- return result;
362
- }
363
-
364
- /**
365
- * Updates IDP tokens and their expiries in an existing session.
366
- * @param sessionToken The session token to update.
367
- * @param idpAccessToken The new IDP access token.
368
- * @param idpRefreshToken The new IDP refresh token.
369
- * @param idpAccessTokenExpires The access token expiry timestamp.
370
- * @param idpRefreshTokenExpires The refresh token expiry timestamp (optional).
371
- * @returns The updated session data.
372
- */
373
- export async function updateTokens(
374
- sessionToken: string,
375
- idpAccessToken: string,
376
- idpRefreshToken: string,
377
- idpAccessTokenExpires: number,
378
- idpRefreshTokenExpires?: number
379
- ): Promise<SessionData | null> {
380
- return updateSession(sessionToken, {
381
- idpAccessToken,
382
- idpRefreshToken,
383
- idpAccessTokenExpires,
384
- idpRefreshTokenExpires,
385
- } as Partial<SessionData>);
386
- }
387
-
388
- /**
389
- * Marks a session as having completed MFA.
390
- * @param sessionToken The session token to update.
391
- * @returns The updated session data.
392
- */
393
- export async function mark2FAComplete(sessionToken: string): Promise<SessionData | null> {
394
- return updateSession(sessionToken, {
395
- mfaVerified: true,
396
- });
397
- }
398
-
399
- /**
400
- * Checks if MFA is complete for a session.
401
- * @param sessionToken The session token.
402
- * @returns True if MFA is complete.
403
- */
404
- export async function is2FAComplete(sessionToken: string): Promise<boolean> {
405
- const session = await getSession(sessionToken);
406
- return session?.mfaVerified === true;
407
- }
408
-
409
- /**
410
- * Gets IDP tokens from a session.
411
- * @param sessionToken The session token.
412
- * @returns The tokens or null if session not found.
413
- */
414
- export async function getTokens(sessionToken: string): Promise<{
415
- accessToken: string;
416
- refreshToken: string;
417
- // Also expose new names for clarity
418
- idpAccessToken: string;
419
- idpRefreshToken: string;
420
- } | null> {
421
- const session = await getSession(sessionToken);
422
- if (!session || !session.idpAccessToken || !session.idpRefreshToken) {
423
- return null;
424
- }
425
- return {
426
- // Legacy names for backward compatibility
427
- accessToken: session.idpAccessToken,
428
- refreshToken: session.idpRefreshToken,
429
- // New normalized names
430
- idpAccessToken: session.idpAccessToken,
431
- idpRefreshToken: session.idpRefreshToken,
432
- };
433
- }
434
-
435
- /**
436
- * Refreshes a JWT session (placeholder for compatibility).
437
- * @param sessionToken The session token.
438
- * @returns The session data or null.
439
- */
440
- export async function refreshJWTSession(sessionToken: string): Promise<SessionData | null> {
441
- return getSession(sessionToken);
442
- }
443
-
444
- /**
445
- * Clears all sessions (for testing only).
446
- */
447
- export async function clearAllSessions(): Promise<void> {
448
- console.warn('[SESSION-STORE] clearAllSessions called - this should only be used in testing');
449
- }
450
-
451
- /**
452
- * Lists all sessions (for testing/debugging only).
453
- * @returns An empty array (placeholder).
454
- */
455
- export async function listAllSessions(): Promise<string[]> {
456
- console.warn('[SESSION-STORE] listAllSessions called - this should only be used in testing');
457
- return [];
458
- }
459
-
460
- // ===============================
461
- // DISTRIBUTED REFRESH COORDINATION
462
- // ===============================
463
-
464
- /**
465
- * Attempt to acquire a refresh lock for a session
466
- * Uses Redis SET with NX (Not eXists) for atomic lock acquisition
467
- */
468
- export async function acquireRefreshLock(
469
- sessionToken: string,
470
- requestId: string,
471
- maxWaitMs: number = 5000
472
- ): Promise<{ acquired: boolean; lockInfo?: RefreshLockInfo }> {
473
- const lockKey = getRefreshLockKey(sessionToken);
474
- const acquiredAt = Date.now();
475
- const lockVersion = Math.floor(Math.random() * 1000000);
476
-
477
- const lockInfo: RefreshLockInfo = {
478
- sessionToken,
479
- acquiredAt,
480
- acquiredBy: requestId,
481
- lockVersion
482
- };
483
-
484
- try {
485
- // Try to acquire the lock atomically
486
- const result = await redis.set(lockKey, JSON.stringify(lockInfo), 'PX', REFRESH_LOCK_TTL * 1000, 'NX');
487
-
488
- if (result === 'OK') {
489
- console.debug('[SESSION-STORE] Refresh lock acquired', {
490
- sessionToken: sessionToken.substring(0, 8) + '...',
491
- requestId,
492
- lockVersion
493
- });
494
-
495
- return { acquired: true, lockInfo };
496
- } else {
497
- // Lock already exists, check if we should wait
498
- if (maxWaitMs > 0) {
499
- console.debug('[SESSION-STORE] Refresh lock already exists, waiting for release', {
500
- sessionToken: sessionToken.substring(0, 8) + '...',
501
- requestId,
502
- maxWaitMs
503
- });
504
-
505
- return await waitForRefreshLockRelease(sessionToken, requestId, maxWaitMs);
506
- }
507
-
508
- console.warn('[SESSION-STORE] Refresh lock already exists, not waiting', {
509
- sessionToken: sessionToken.substring(0, 8) + '...',
510
- requestId
511
- });
512
-
513
- return { acquired: false };
514
- }
515
- } catch (error) {
516
- console.error('[SESSION-STORE] Failed to acquire refresh lock', {
517
- sessionToken: sessionToken.substring(0, 8) + '...',
518
- requestId,
519
- error: error instanceof Error ? error.message : String(error)
520
- });
521
-
522
- return { acquired: false };
523
- }
524
- }
525
-
526
- /**
527
- * Wait for a refresh lock to be released
528
- */
529
- async function waitForRefreshLockRelease(
530
- sessionToken: string,
531
- requestId: string,
532
- maxWaitMs: number
533
- ): Promise<{ acquired: boolean; lockInfo?: RefreshLockInfo }> {
534
- const lockKey = getRefreshLockKey(sessionToken);
535
- const startTime = Date.now();
536
- const pollInterval = 100;
537
-
538
- while (Date.now() - startTime < maxWaitMs) {
539
- try {
540
- const lockExists = await redis.exists(lockKey);
541
-
542
- if (!lockExists) {
543
- // Lock released - do not reacquire here to avoid double refresh
544
- return { acquired: false };
545
- }
546
-
547
- // Wait before next check
548
- await new Promise(resolve => setTimeout(resolve, pollInterval));
549
- } catch (error) {
550
- console.error('[SESSION-STORE] Error while waiting for refresh lock release', {
551
- sessionToken: sessionToken.substring(0, 8) + '...',
552
- requestId,
553
- error: error instanceof Error ? error.message : String(error)
554
- });
555
- break;
556
- }
557
- }
558
-
559
- console.warn('[SESSION-STORE] Timeout waiting for refresh lock release', {
560
- sessionToken: sessionToken.substring(0, 8) + '...',
561
- requestId,
562
- waitedMs: Date.now() - startTime
563
- });
564
-
565
- return { acquired: false };
566
- }
567
-
568
- /**
569
- * Release a refresh lock
570
- * Uses Lua script to ensure atomic validation and release
571
- */
572
- export async function releaseRefreshLock(
573
- sessionToken: string,
574
- requestId: string,
575
- lockVersion?: number
576
- ): Promise<boolean> {
577
- const lockKey = getRefreshLockKey(sessionToken);
578
-
579
- try {
580
- // Lua script for atomic lock validation and release
581
- const luaScript = `
582
- local lockKey = KEYS[1]
583
- local expectedRequestId = ARGV[1]
584
- local expectedVersion = ARGV[2]
585
-
586
- local lockData = redis.call('GET', lockKey)
587
- if not lockData then
588
- return 0 -- Lock doesn't exist
589
- end
590
-
591
- local lockInfo = cjson.decode(lockData)
592
- if lockInfo.acquiredBy == expectedRequestId then
593
- if not expectedVersion or expectedVersion == '' or tostring(lockInfo.lockVersion) == expectedVersion then
594
- redis.call('DEL', lockKey)
595
- return 1 -- Successfully released
596
- else
597
- return -2 -- Version mismatch
598
- end
599
- else
600
- return -1 -- Wrong owner
601
- end
602
- `;
603
-
604
- const result = await redis.eval(
605
- luaScript,
606
- 1,
607
- lockKey,
608
- requestId,
609
- lockVersion ? lockVersion.toString() : ''
610
- ) as number;
611
-
612
- if (result === 1) {
613
- console.debug('[SESSION-STORE] Refresh lock released successfully', {
614
- sessionToken: sessionToken.substring(0, 8) + '...',
615
- requestId,
616
- lockVersion
617
- });
618
- return true;
619
- } else if (result === 0) {
620
- console.warn('[SESSION-STORE] Attempted to release non-existent refresh lock', {
621
- sessionToken: sessionToken.substring(0, 8) + '...',
622
- requestId
623
- });
624
- return false;
625
- } else if (result === -1) {
626
- console.error('[SESSION-STORE] Attempted to release refresh lock owned by another request', {
627
- sessionToken: sessionToken.substring(0, 8) + '...',
628
- requestId
629
- });
630
- return false;
631
- } else if (result === -2) {
632
- console.error('[SESSION-STORE] Lock version mismatch during release', {
633
- sessionToken: sessionToken.substring(0, 8) + '...',
634
- requestId,
635
- lockVersion
636
- });
637
- return false;
638
- } else {
639
- console.error('[SESSION-STORE] Unexpected result from lock release script', {
640
- sessionToken: sessionToken.substring(0, 8) + '...',
641
- requestId,
642
- result
643
- });
644
- return false;
645
- }
646
- } catch (error) {
647
- console.error('[SESSION-STORE] Failed to release refresh lock', {
648
- sessionToken: sessionToken.substring(0, 8) + '...',
649
- requestId,
650
- error: error instanceof Error ? error.message : String(error)
651
- });
652
-
653
- return false;
654
- }
655
- }
656
-
657
- /**
658
- * Check if a refresh lock exists for a session
659
- */
660
- export async function checkRefreshLock(sessionToken: string): Promise<RefreshLockInfo | null> {
661
- const lockKey = getRefreshLockKey(sessionToken);
662
-
663
- try {
664
- const lockData = await redis.get(lockKey);
665
-
666
- if (!lockData) {
667
- return null;
668
- }
669
-
670
- return JSON.parse(lockData) as RefreshLockInfo;
671
- } catch (error) {
672
- console.error('[SESSION-STORE] Failed to check refresh lock', {
673
- sessionToken: sessionToken.substring(0, 8) + '...',
674
- error: error instanceof Error ? error.message : String(error)
675
- });
676
-
677
- return null;
678
- }
679
- }
680
-
681
- /**
682
- * Simple check if a refresh is currently in progress for a session
683
- */
684
- export async function isRefreshInProgress(sessionToken: string): Promise<boolean> {
685
- const lock = await checkRefreshLock(sessionToken);
686
- return lock !== null;
687
- }
688
-
689
- /**
690
- * Force cleanup of expired or orphaned refresh locks
691
- */
692
- export async function cleanupRefreshLocks(): Promise<number> {
693
- console.warn('[SESSION-STORE] cleanupRefreshLocks called - scanning for expired locks');
694
- return 0;
695
- }
1
+ /**
2
+ * Session Store for `@payez/next-mvp` using ioredis
3
+ *
4
+ * This module provides a Redis-backed session store that is compatible with the
5
+ * `ioredis` client. It handles the creation, retrieval, and deletion of
6
+ * session data, which is the single source of truth for authentication.
7
+ *
8
+ * Includes advanced distributed refresh coordination with version control.
9
+ */
10
+
11
+ import redis from './redis';
12
+ import { randomBytes } from 'crypto';
13
+ import { SessionData } from '../models/SessionModel';
14
+ import { getSessionPrefix, getRefreshLockPrefix } from './app-slug';
15
+ import { extractKidFromToken } from '../auth/utils/token-utils';
16
+
17
+ // Re-export SessionData for consumers
18
+ export type { SessionData } from '../models/SessionModel';
19
+
20
+ // Use app-slug prefixes for multi-app isolation
21
+ const getSessionKey = (token: string) => `${getSessionPrefix()}${token}`;
22
+ const getRefreshLockKey = (token: string) => `${getRefreshLockPrefix()}${token}`;
23
+ const getSessionVersionKey = (token: string) => `${getSessionPrefix()}ver:${token}`;
24
+
25
+ // Better Auth uses a different key format: ba:{appSlug}:{token}
26
+ const getBetterAuthSessionKey = (token: string, appSlug?: string) => `ba:${appSlug || 'app'}:${token}`;
27
+
28
+ const REFRESH_LOCK_TTL = 60; // 60 seconds
29
+ // 3 days in seconds (matches refresh token lifetime). EXPORTED as the single source
30
+ // of session TTL — better-auth.ts derives its BA-session Redis TTL from this so the
31
+ // two can never drift apart again (the 7d-vs-3d drift = the zombie-session window, QA 6952).
32
+ export const SESSION_TTL = 3 * 24 * 60 * 60;
33
+
34
+ export interface RefreshLockInfo {
35
+ sessionToken: string;
36
+ acquiredAt: number;
37
+ acquiredBy: string; // request ID or process identifier
38
+ lockVersion: number;
39
+ }
40
+
41
+ /**
42
+ * Generates a new session token.
43
+ * @returns A new session token string.
44
+ */
45
+ export function generateSessionToken(): string {
46
+ return randomBytes(32).toString('hex');
47
+ }
48
+
49
+ /**
50
+ * Creates a new session in Redis.
51
+ *
52
+ * @param data The session data to store.
53
+ * @returns The generated session token (redisSessionId).
54
+ */
55
+ export async function createSession(data: SessionData): Promise<string> {
56
+ const sessionToken = randomBytes(32).toString('hex');
57
+ const key = getSessionKey(sessionToken);
58
+ const versionKey = getSessionVersionKey(sessionToken);
59
+
60
+ try {
61
+ await redis.multi()
62
+ .setex(key, SESSION_TTL, JSON.stringify(data))
63
+ .setex(versionKey, SESSION_TTL, '1')
64
+ .exec();
65
+ } catch (error) {
66
+ console.error('[SESSION-STORE] Failed to create session:', error);
67
+ throw error;
68
+ }
69
+
70
+ return sessionToken;
71
+ }
72
+
73
+ /**
74
+ * Retrieves a session from Redis.
75
+ *
76
+ * @param sessionToken The session token (redisSessionId) to look up.
77
+ * @returns The session data, or null if not found.
78
+ */
79
+ export async function getSession(sessionToken: string): Promise<SessionData | null> {
80
+ if (!sessionToken) {
81
+ return null;
82
+ }
83
+ const key = getSessionKey(sessionToken);
84
+ const json = await redis.get(key);
85
+ if (!json) {
86
+ return null;
87
+ }
88
+ try {
89
+ return JSON.parse(json) as SessionData;
90
+ } catch {
91
+ console.error('[SESSION-STORE] Failed to parse session data');
92
+ return null;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Retrieves a Better Auth session from Redis.
98
+ * Better Auth uses key format: ba:{appSlug}:{token}
99
+ *
100
+ * @param sessionToken The session token to look up.
101
+ * @param appSlug The app slug (defaults to 'idealvibe_online' or extracted from env).
102
+ * @returns The session data, or null if not found.
103
+ */
104
+ export async function getBetterAuthSession(sessionToken: string, appSlug?: string): Promise<SessionData | null> {
105
+ if (!sessionToken) {
106
+ return null;
107
+ }
108
+ // Try to get appSlug from env if not provided
109
+ const slug = appSlug || process.env.CLIENT_ID || 'idealvibe_online';
110
+ const key = getBetterAuthSessionKey(sessionToken, slug);
111
+ const json = await redis.get(key);
112
+ if (!json) {
113
+ return null;
114
+ }
115
+ try {
116
+ const data = JSON.parse(json);
117
+ // Better Auth stores the session differently - extract user data
118
+ if (data.user) {
119
+ return {
120
+ userId: data.user.id || data.user.email,
121
+ email: data.user.email,
122
+ name: data.user.name,
123
+ image: data.user.image,
124
+ idpAccessToken: data.idpTokens?.idpAccessToken,
125
+ idpRefreshToken: data.idpTokens?.idpRefreshToken,
126
+ idpAccessTokenExpires: data.idpTokens?.idpAccessTokenExpires,
127
+ mfaVerified: data.idpTokens?.mfaVerified ?? false,
128
+ // Cards 141688/141741 — the IDP's routing decision, carried not derived.
129
+ // `null` is a real value here (IDP said nothing is outstanding), so it
130
+ // must stay distinguishable from `undefined` (we never captured it —
131
+ // true of every session minted before this change). No `?? null`:
132
+ // defaulting would turn "unknown" into "fully authenticated" and
133
+ // fail open on the existing session population.
134
+ nextStep: data.idpTokens?.nextStep,
135
+ roles: data.idpTokens?.roles || [],
136
+ idpClientId: data.idpTokens?.idpClientId ?? data.idpTokens?.clientId ?? data.idpClientId,
137
+ merchantId: data.idpTokens?.merchantId ?? data.merchantId,
138
+ } as SessionData;
139
+ }
140
+ return data as SessionData;
141
+ } catch {
142
+ console.error('[SESSION-STORE] Failed to parse Better Auth session data');
143
+ return null;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Refresh session TTL without reading/writing data (sliding window expiry).
149
+ */
150
+ export async function touchSession(token: string): Promise<void> {
151
+ const key = getSessionKey(token);
152
+ await redis.expire(key, SESSION_TTL);
153
+ }
154
+
155
+ /**
156
+ * Retrieves a session along with a version identifier for optimistic locking.
157
+ * @param sessionToken The session token to look up.
158
+ * @returns An object with session and version, or null if not found.
159
+ */
160
+ export async function getSessionWithVersion(sessionToken: string): Promise<{ session: SessionData; version: string } | null> {
161
+ const session = await getSession(sessionToken);
162
+ if (!session) {
163
+ return null;
164
+ }
165
+
166
+ const versionKey = getSessionVersionKey(sessionToken);
167
+ const version = await redis.get(versionKey);
168
+
169
+ if (!version) {
170
+ // Session exists but version key missing - use idpAccessTokenExpires as fallback
171
+ const fallbackVersion = String(session.idpAccessTokenExpires || Date.now());
172
+ return { session, version: fallbackVersion };
173
+ }
174
+
175
+ return { session, version };
176
+ }
177
+
178
+ /**
179
+ * Checks if the access token in a session is still fresh (not expired).
180
+ * @param sessionToken The session token to check.
181
+ * @param currentAccessToken The current access token to compare.
182
+ * @param currentVersion Optional version to check for changes.
183
+ * @returns Object with freshness status and latest token info.
184
+ */
185
+ export async function isAccessTokenFresh(
186
+ sessionToken: string,
187
+ currentAccessToken: string,
188
+ currentVersion?: string
189
+ ): Promise<{
190
+ isFresh: boolean;
191
+ latestAccessToken?: string;
192
+ latestVersion?: string;
193
+ versionChanged: boolean;
194
+ }> {
195
+ const sessionWithVersion = await getSessionWithVersion(sessionToken);
196
+
197
+ if (!sessionWithVersion) {
198
+ return { isFresh: false, versionChanged: true };
199
+ }
200
+
201
+ const { session, version } = sessionWithVersion;
202
+ const versionChanged = currentVersion ? version !== currentVersion : false;
203
+
204
+ // If version changed, the token might be stale
205
+ // Use normalized field name (idpAccessToken)
206
+ const isFresh = !versionChanged && session.idpAccessToken === currentAccessToken;
207
+
208
+ return {
209
+ isFresh,
210
+ latestAccessToken: session.idpAccessToken || undefined,
211
+ latestVersion: version,
212
+ versionChanged
213
+ };
214
+ }
215
+
216
+ /**
217
+ * Deletes a session from Redis.
218
+ * @param sessionToken The session token to delete.
219
+ */
220
+ export async function deleteSession(sessionToken: string): Promise<void> {
221
+ if (!sessionToken) {
222
+ return;
223
+ }
224
+ const key = getSessionKey(sessionToken);
225
+ const versionKey = getSessionVersionKey(sessionToken);
226
+ await redis.del(key, versionKey);
227
+ }
228
+
229
+ /**
230
+ * Sets a session directly (for testing or migrations).
231
+ * @param sessionToken The session token.
232
+ * @param data The session data.
233
+ */
234
+ export async function setSession(sessionToken: string, data: SessionData): Promise<void> {
235
+ const key = getSessionKey(sessionToken);
236
+ const versionKey = getSessionVersionKey(sessionToken);
237
+ await redis.multi()
238
+ .setex(key, SESSION_TTL, JSON.stringify(data))
239
+ .incr(versionKey)
240
+ .expire(versionKey, SESSION_TTL)
241
+ .exec();
242
+ }
243
+
244
+ /**
245
+ * Updates tokens within an existing session.
246
+ * @param sessionToken The session token to update.
247
+ * @param updates Partial session data to update.
248
+ * @returns The updated session data, or null if the session was not found.
249
+ */
250
+ export async function updateSession(
251
+ sessionToken: string,
252
+ updates: Partial<SessionData>
253
+ ): Promise<SessionData | null> {
254
+ const key = getSessionKey(sessionToken);
255
+ const versionKey = getSessionVersionKey(sessionToken);
256
+
257
+ // Get current session to merge with updates
258
+ const currentSession = await getSession(sessionToken);
259
+ if (!currentSession) {
260
+ return null;
261
+ }
262
+
263
+ // CRITICAL: Track any refresh token changes (check both old and new field names)
264
+ const hadRefreshToken = !!(currentSession.idpRefreshToken || (currentSession as any).refreshToken);
265
+ const willHaveRefreshToken = (updates as any).idpRefreshToken !== undefined
266
+ ? !!(updates as any).idpRefreshToken
267
+ : (updates as any).refreshToken !== undefined
268
+ ? !!(updates as any).refreshToken
269
+ : hadRefreshToken;
270
+
271
+ if (hadRefreshToken && !willHaveRefreshToken) {
272
+ console.error('[SESSION-STORE] ⚠️ REFRESH_TOKEN_BEING_CLEARED', {
273
+ sessionToken: sessionToken.substring(0, 8) + '...',
274
+ userId: currentSession.userId,
275
+ updateKeys: Object.keys(updates),
276
+ refreshTokenInUpdate: (updates as any).idpRefreshToken || (updates as any).refreshToken,
277
+ refreshTokenClearedReason: (updates as any).refreshTokenClearedReason || 'UNKNOWN',
278
+ stack: new Error().stack
279
+ });
280
+ }
281
+
282
+ // Merge current session with updates
283
+ const updatedSession = { ...currentSession, ...updates };
284
+
285
+ // Write the entire updated session back to Redis with version increment
286
+ await redis.multi()
287
+ .setex(key, SESSION_TTL, JSON.stringify(updatedSession))
288
+ .incr(versionKey)
289
+ .expire(versionKey, SESSION_TTL)
290
+ .exec();
291
+
292
+ return updatedSession;
293
+ }
294
+
295
+ /**
296
+ * Transitions a session to a MFA-completed state.
297
+ * @param sessionToken The session token to update.
298
+ * @param tokens The new tokens received after MFA completion.
299
+ * @param mfaMethod The MFA method used (email, sms, totp) - required for token refresh.
300
+ * @returns The updated session data.
301
+ */
302
+ export async function transitionTo2FASession(
303
+ sessionToken: string,
304
+ tokens: {
305
+ accessToken?: string;
306
+ refreshToken?: string;
307
+ accessTokenExpires?: number;
308
+ refreshTokenExpires?: number;
309
+ // Support new field names
310
+ idpAccessToken?: string;
311
+ idpRefreshToken?: string;
312
+ idpAccessTokenExpires?: number;
313
+ idpRefreshTokenExpires?: number;
314
+ },
315
+ mfaMethod?: 'email' | 'sms' | 'totp'
316
+ ): Promise<SessionData | null> {
317
+ const newAccessToken = tokens.idpAccessToken || tokens.accessToken;
318
+
319
+ console.log('[transitionTo2FASession] Called with:', {
320
+ sessionToken: sessionToken?.substring(0, 8) + '...',
321
+ mfaMethod,
322
+ hasAccessToken: !!newAccessToken,
323
+ hasRefreshToken: !!(tokens.idpRefreshToken || tokens.refreshToken),
324
+ });
325
+
326
+ // Extract bearerKeyId from the new access token (IDP may use different key after 2FA)
327
+ let bearerKeyId: string | undefined;
328
+ if (newAccessToken) {
329
+ bearerKeyId = extractKidFromToken(newAccessToken);
330
+ if (bearerKeyId) {
331
+ console.log('[transitionTo2FASession] Extracted bearerKeyId (kid) from new JWT header:', bearerKeyId);
332
+ }
333
+ }
334
+
335
+ // Support both old and new field names in input
336
+ // CRITICAL: Set BOTH mfaVerified (new) AND twoFactorComplete (legacy) for compatibility
337
+ // auth.ts session callback reads twoFactorComplete, so we must set it here
338
+ const updates: Partial<SessionData> = {
339
+ idpAccessToken: newAccessToken,
340
+ idpRefreshToken: tokens.idpRefreshToken || tokens.refreshToken,
341
+ idpAccessTokenExpires: tokens.idpAccessTokenExpires || tokens.accessTokenExpires,
342
+ mfaVerified: true,
343
+ twoFactorComplete: true, // Legacy field - required by auth.ts session callback
344
+ mfaMethod: mfaMethod,
345
+ // Update bearerKeyId if extracted from new token
346
+ ...(bearerKeyId && { bearerKeyId }),
347
+ };
348
+
349
+ const refreshExpires = tokens.idpRefreshTokenExpires || tokens.refreshTokenExpires;
350
+ if (refreshExpires !== undefined) {
351
+ updates.idpRefreshTokenExpires = refreshExpires;
352
+ }
353
+
354
+ console.log('[transitionTo2FASession] Updates to apply:', {
355
+ mfaVerified: updates.mfaVerified,
356
+ twoFactorComplete: (updates as any).twoFactorComplete,
357
+ mfaMethod: updates.mfaMethod,
358
+ hasIdpAccessToken: !!updates.idpAccessToken,
359
+ });
360
+
361
+ const result = await updateSession(sessionToken, updates);
362
+
363
+ console.log('[transitionTo2FASession] Result:', {
364
+ success: !!result,
365
+ resultMfaVerified: result?.mfaVerified,
366
+ });
367
+
368
+ return result;
369
+ }
370
+
371
+ /**
372
+ * Updates IDP tokens and their expiries in an existing session.
373
+ * @param sessionToken The session token to update.
374
+ * @param idpAccessToken The new IDP access token.
375
+ * @param idpRefreshToken The new IDP refresh token.
376
+ * @param idpAccessTokenExpires The access token expiry timestamp.
377
+ * @param idpRefreshTokenExpires The refresh token expiry timestamp (optional).
378
+ * @returns The updated session data.
379
+ */
380
+ export async function updateTokens(
381
+ sessionToken: string,
382
+ idpAccessToken: string,
383
+ idpRefreshToken: string,
384
+ idpAccessTokenExpires: number,
385
+ idpRefreshTokenExpires?: number
386
+ ): Promise<SessionData | null> {
387
+ return updateSession(sessionToken, {
388
+ idpAccessToken,
389
+ idpRefreshToken,
390
+ idpAccessTokenExpires,
391
+ idpRefreshTokenExpires,
392
+ } as Partial<SessionData>);
393
+ }
394
+
395
+ /**
396
+ * Marks a session as having completed MFA.
397
+ * @param sessionToken The session token to update.
398
+ * @returns The updated session data.
399
+ */
400
+ export async function mark2FAComplete(sessionToken: string): Promise<SessionData | null> {
401
+ return updateSession(sessionToken, {
402
+ mfaVerified: true,
403
+ });
404
+ }
405
+
406
+ /**
407
+ * Checks if MFA is complete for a session.
408
+ * @param sessionToken The session token.
409
+ * @returns True if MFA is complete.
410
+ */
411
+ export async function is2FAComplete(sessionToken: string): Promise<boolean> {
412
+ const session = await getSession(sessionToken);
413
+ return session?.mfaVerified === true;
414
+ }
415
+
416
+ /**
417
+ * Gets IDP tokens from a session.
418
+ * @param sessionToken The session token.
419
+ * @returns The tokens or null if session not found.
420
+ */
421
+ export async function getTokens(sessionToken: string): Promise<{
422
+ accessToken: string;
423
+ refreshToken: string;
424
+ // Also expose new names for clarity
425
+ idpAccessToken: string;
426
+ idpRefreshToken: string;
427
+ } | null> {
428
+ const session = await getSession(sessionToken);
429
+ if (!session || !session.idpAccessToken || !session.idpRefreshToken) {
430
+ return null;
431
+ }
432
+ return {
433
+ // Legacy names for backward compatibility
434
+ accessToken: session.idpAccessToken,
435
+ refreshToken: session.idpRefreshToken,
436
+ // New normalized names
437
+ idpAccessToken: session.idpAccessToken,
438
+ idpRefreshToken: session.idpRefreshToken,
439
+ };
440
+ }
441
+
442
+ /**
443
+ * Refreshes a JWT session (placeholder for compatibility).
444
+ * @param sessionToken The session token.
445
+ * @returns The session data or null.
446
+ */
447
+ export async function refreshJWTSession(sessionToken: string): Promise<SessionData | null> {
448
+ return getSession(sessionToken);
449
+ }
450
+
451
+ /**
452
+ * Clears all sessions (for testing only).
453
+ */
454
+ export async function clearAllSessions(): Promise<void> {
455
+ console.warn('[SESSION-STORE] clearAllSessions called - this should only be used in testing');
456
+ }
457
+
458
+ /**
459
+ * Lists all sessions (for testing/debugging only).
460
+ * @returns An empty array (placeholder).
461
+ */
462
+ export async function listAllSessions(): Promise<string[]> {
463
+ console.warn('[SESSION-STORE] listAllSessions called - this should only be used in testing');
464
+ return [];
465
+ }
466
+
467
+ // ===============================
468
+ // DISTRIBUTED REFRESH COORDINATION
469
+ // ===============================
470
+
471
+ /**
472
+ * Attempt to acquire a refresh lock for a session
473
+ * Uses Redis SET with NX (Not eXists) for atomic lock acquisition
474
+ */
475
+ export async function acquireRefreshLock(
476
+ sessionToken: string,
477
+ requestId: string,
478
+ maxWaitMs: number = 5000
479
+ ): Promise<{ acquired: boolean; lockInfo?: RefreshLockInfo }> {
480
+ const lockKey = getRefreshLockKey(sessionToken);
481
+ const acquiredAt = Date.now();
482
+ const lockVersion = Math.floor(Math.random() * 1000000);
483
+
484
+ const lockInfo: RefreshLockInfo = {
485
+ sessionToken,
486
+ acquiredAt,
487
+ acquiredBy: requestId,
488
+ lockVersion
489
+ };
490
+
491
+ try {
492
+ // Try to acquire the lock atomically
493
+ const result = await redis.set(lockKey, JSON.stringify(lockInfo), 'PX', REFRESH_LOCK_TTL * 1000, 'NX');
494
+
495
+ if (result === 'OK') {
496
+ console.debug('[SESSION-STORE] Refresh lock acquired', {
497
+ sessionToken: sessionToken.substring(0, 8) + '...',
498
+ requestId,
499
+ lockVersion
500
+ });
501
+
502
+ return { acquired: true, lockInfo };
503
+ } else {
504
+ // Lock already exists, check if we should wait
505
+ if (maxWaitMs > 0) {
506
+ console.debug('[SESSION-STORE] Refresh lock already exists, waiting for release', {
507
+ sessionToken: sessionToken.substring(0, 8) + '...',
508
+ requestId,
509
+ maxWaitMs
510
+ });
511
+
512
+ return await waitForRefreshLockRelease(sessionToken, requestId, maxWaitMs);
513
+ }
514
+
515
+ console.warn('[SESSION-STORE] Refresh lock already exists, not waiting', {
516
+ sessionToken: sessionToken.substring(0, 8) + '...',
517
+ requestId
518
+ });
519
+
520
+ return { acquired: false };
521
+ }
522
+ } catch (error) {
523
+ console.error('[SESSION-STORE] Failed to acquire refresh lock', {
524
+ sessionToken: sessionToken.substring(0, 8) + '...',
525
+ requestId,
526
+ error: error instanceof Error ? error.message : String(error)
527
+ });
528
+
529
+ return { acquired: false };
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Wait for a refresh lock to be released
535
+ */
536
+ async function waitForRefreshLockRelease(
537
+ sessionToken: string,
538
+ requestId: string,
539
+ maxWaitMs: number
540
+ ): Promise<{ acquired: boolean; lockInfo?: RefreshLockInfo }> {
541
+ const lockKey = getRefreshLockKey(sessionToken);
542
+ const startTime = Date.now();
543
+ const pollInterval = 100;
544
+
545
+ while (Date.now() - startTime < maxWaitMs) {
546
+ try {
547
+ const lockExists = await redis.exists(lockKey);
548
+
549
+ if (!lockExists) {
550
+ // Lock released - do not reacquire here to avoid double refresh
551
+ return { acquired: false };
552
+ }
553
+
554
+ // Wait before next check
555
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
556
+ } catch (error) {
557
+ console.error('[SESSION-STORE] Error while waiting for refresh lock release', {
558
+ sessionToken: sessionToken.substring(0, 8) + '...',
559
+ requestId,
560
+ error: error instanceof Error ? error.message : String(error)
561
+ });
562
+ break;
563
+ }
564
+ }
565
+
566
+ console.warn('[SESSION-STORE] Timeout waiting for refresh lock release', {
567
+ sessionToken: sessionToken.substring(0, 8) + '...',
568
+ requestId,
569
+ waitedMs: Date.now() - startTime
570
+ });
571
+
572
+ return { acquired: false };
573
+ }
574
+
575
+ /**
576
+ * Release a refresh lock
577
+ * Uses Lua script to ensure atomic validation and release
578
+ */
579
+ export async function releaseRefreshLock(
580
+ sessionToken: string,
581
+ requestId: string,
582
+ lockVersion?: number
583
+ ): Promise<boolean> {
584
+ const lockKey = getRefreshLockKey(sessionToken);
585
+
586
+ try {
587
+ // Lua script for atomic lock validation and release
588
+ const luaScript = `
589
+ local lockKey = KEYS[1]
590
+ local expectedRequestId = ARGV[1]
591
+ local expectedVersion = ARGV[2]
592
+
593
+ local lockData = redis.call('GET', lockKey)
594
+ if not lockData then
595
+ return 0 -- Lock doesn't exist
596
+ end
597
+
598
+ local lockInfo = cjson.decode(lockData)
599
+ if lockInfo.acquiredBy == expectedRequestId then
600
+ if not expectedVersion or expectedVersion == '' or tostring(lockInfo.lockVersion) == expectedVersion then
601
+ redis.call('DEL', lockKey)
602
+ return 1 -- Successfully released
603
+ else
604
+ return -2 -- Version mismatch
605
+ end
606
+ else
607
+ return -1 -- Wrong owner
608
+ end
609
+ `;
610
+
611
+ const result = await redis.eval(
612
+ luaScript,
613
+ 1,
614
+ lockKey,
615
+ requestId,
616
+ lockVersion ? lockVersion.toString() : ''
617
+ ) as number;
618
+
619
+ if (result === 1) {
620
+ console.debug('[SESSION-STORE] Refresh lock released successfully', {
621
+ sessionToken: sessionToken.substring(0, 8) + '...',
622
+ requestId,
623
+ lockVersion
624
+ });
625
+ return true;
626
+ } else if (result === 0) {
627
+ console.warn('[SESSION-STORE] Attempted to release non-existent refresh lock', {
628
+ sessionToken: sessionToken.substring(0, 8) + '...',
629
+ requestId
630
+ });
631
+ return false;
632
+ } else if (result === -1) {
633
+ console.error('[SESSION-STORE] Attempted to release refresh lock owned by another request', {
634
+ sessionToken: sessionToken.substring(0, 8) + '...',
635
+ requestId
636
+ });
637
+ return false;
638
+ } else if (result === -2) {
639
+ console.error('[SESSION-STORE] Lock version mismatch during release', {
640
+ sessionToken: sessionToken.substring(0, 8) + '...',
641
+ requestId,
642
+ lockVersion
643
+ });
644
+ return false;
645
+ } else {
646
+ console.error('[SESSION-STORE] Unexpected result from lock release script', {
647
+ sessionToken: sessionToken.substring(0, 8) + '...',
648
+ requestId,
649
+ result
650
+ });
651
+ return false;
652
+ }
653
+ } catch (error) {
654
+ console.error('[SESSION-STORE] Failed to release refresh lock', {
655
+ sessionToken: sessionToken.substring(0, 8) + '...',
656
+ requestId,
657
+ error: error instanceof Error ? error.message : String(error)
658
+ });
659
+
660
+ return false;
661
+ }
662
+ }
663
+
664
+ /**
665
+ * Check if a refresh lock exists for a session
666
+ */
667
+ export async function checkRefreshLock(sessionToken: string): Promise<RefreshLockInfo | null> {
668
+ const lockKey = getRefreshLockKey(sessionToken);
669
+
670
+ try {
671
+ const lockData = await redis.get(lockKey);
672
+
673
+ if (!lockData) {
674
+ return null;
675
+ }
676
+
677
+ return JSON.parse(lockData) as RefreshLockInfo;
678
+ } catch (error) {
679
+ console.error('[SESSION-STORE] Failed to check refresh lock', {
680
+ sessionToken: sessionToken.substring(0, 8) + '...',
681
+ error: error instanceof Error ? error.message : String(error)
682
+ });
683
+
684
+ return null;
685
+ }
686
+ }
687
+
688
+ /**
689
+ * Simple check if a refresh is currently in progress for a session
690
+ */
691
+ export async function isRefreshInProgress(sessionToken: string): Promise<boolean> {
692
+ const lock = await checkRefreshLock(sessionToken);
693
+ return lock !== null;
694
+ }
695
+
696
+ /**
697
+ * Force cleanup of expired or orphaned refresh locks
698
+ */
699
+ export async function cleanupRefreshLocks(): Promise<number> {
700
+ console.warn('[SESSION-STORE] cleanupRefreshLocks called - scanning for expired locks');
701
+ return 0;
702
+ }