@payez/next-mvp 3.9.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/dist/api/auth-handler.d.ts +1 -2
  2. package/dist/api/auth-handler.js +9 -9
  3. package/dist/api-handlers/account/change-password.js +110 -112
  4. package/dist/api-handlers/admin/analytics.d.ts +19 -20
  5. package/dist/api-handlers/admin/analytics.js +378 -379
  6. package/dist/api-handlers/admin/audit.d.ts +19 -20
  7. package/dist/api-handlers/admin/audit.js +213 -214
  8. package/dist/api-handlers/admin/index.d.ts +21 -22
  9. package/dist/api-handlers/admin/index.js +42 -43
  10. package/dist/api-handlers/admin/redis-sessions.d.ts +35 -36
  11. package/dist/api-handlers/admin/redis-sessions.js +203 -204
  12. package/dist/api-handlers/admin/sessions.d.ts +20 -21
  13. package/dist/api-handlers/admin/sessions.js +283 -284
  14. package/dist/api-handlers/admin/site-logs.d.ts +45 -46
  15. package/dist/api-handlers/admin/site-logs.js +317 -318
  16. package/dist/api-handlers/admin/stats.d.ts +20 -21
  17. package/dist/api-handlers/admin/stats.js +239 -240
  18. package/dist/api-handlers/admin/users.d.ts +19 -20
  19. package/dist/api-handlers/admin/users.js +221 -222
  20. package/dist/api-handlers/admin/vibe-data.d.ts +79 -80
  21. package/dist/api-handlers/admin/vibe-data.js +267 -268
  22. package/dist/api-handlers/auth/refresh.js +633 -635
  23. package/dist/api-handlers/auth/signout.js +186 -187
  24. package/dist/api-handlers/auth/status.js +4 -7
  25. package/dist/api-handlers/auth/update-session.d.ts +1 -1
  26. package/dist/api-handlers/auth/update-session.js +12 -14
  27. package/dist/api-handlers/auth/verify-code.d.ts +43 -43
  28. package/dist/api-handlers/auth/verify-code.js +90 -94
  29. package/dist/api-handlers/session/viability.js +114 -146
  30. package/dist/api-handlers/test/force-expire.js +59 -65
  31. package/dist/auth/auth-decision.js +182 -182
  32. package/dist/auth/better-auth.d.ts +3 -6
  33. package/dist/auth/better-auth.js +3 -6
  34. package/dist/auth/route-config.js +2 -2
  35. package/dist/auth/utils/token-utils.d.ts +83 -84
  36. package/dist/auth/utils/token-utils.js +218 -219
  37. package/dist/client/AuthContext.js +115 -112
  38. package/dist/client/better-auth-client.d.ts +1020 -961
  39. package/dist/client/better-auth-client.js +54 -7
  40. package/dist/client/fetch-with-auth.js +2 -2
  41. package/dist/components/SessionSync.js +121 -119
  42. package/dist/components/account/MobileNavDrawer.js +64 -64
  43. package/dist/components/account/UserAvatarMenu.js +91 -88
  44. package/dist/components/admin/VibeAdminLayout.js +71 -69
  45. package/dist/hooks/useAuth.js +9 -7
  46. package/dist/hooks/useAuthSettings.js +93 -93
  47. package/dist/hooks/useAvailableProviders.d.ts +43 -45
  48. package/dist/hooks/useAvailableProviders.js +112 -108
  49. package/dist/hooks/useSessionExpiration.d.ts +2 -3
  50. package/dist/hooks/useSessionExpiration.js +2 -2
  51. package/dist/hooks/useViabilitySession.js +3 -2
  52. package/dist/index.js +4 -6
  53. package/dist/lib/app-slug.d.ts +95 -95
  54. package/dist/lib/app-slug.js +172 -172
  55. package/dist/lib/standardized-client-api.js +10 -5
  56. package/dist/lib/startup-init.js +21 -25
  57. package/dist/lib/test-aware-get-token.js +86 -81
  58. package/dist/lib/token-lifecycle.d.ts +78 -52
  59. package/dist/lib/token-lifecycle.js +360 -398
  60. package/dist/pages/admin-login/page.js +73 -83
  61. package/dist/pages/client-admin/ClientSiteAdminPage.js +179 -177
  62. package/dist/pages/login/page.js +202 -211
  63. package/dist/pages/showcase/ShowcasePage.js +142 -140
  64. package/dist/pages/test-env/EmergencyLogoutPage.js +99 -98
  65. package/dist/pages/test-env/JwtInspectPage.js +116 -114
  66. package/dist/pages/test-env/RefreshTokenPage.js +4 -2
  67. package/dist/pages/test-env/TestEnvPage.js +51 -49
  68. package/dist/pages/verify-code/page.js +412 -408
  69. package/dist/routes/auth/logout.d.ts +31 -31
  70. package/dist/routes/auth/logout.js +98 -113
  71. package/dist/routes/auth/nextauth.d.ts +14 -11
  72. package/dist/routes/auth/nextauth.js +25 -57
  73. package/dist/routes/auth/session.js +157 -179
  74. package/dist/routes/auth/viability.js +190 -201
  75. package/dist/server/auth.d.ts +50 -0
  76. package/dist/server/auth.js +62 -0
  77. package/dist/stores/authStore.js +19 -23
  78. package/dist/utils/logout.js +5 -5
  79. package/package.json +1 -3
  80. package/src/api/auth-handler.ts +550 -549
  81. package/src/api-handlers/account/change-password.ts +5 -8
  82. package/src/api-handlers/admin/analytics.ts +4 -6
  83. package/src/api-handlers/admin/audit.ts +5 -7
  84. package/src/api-handlers/admin/index.ts +1 -2
  85. package/src/api-handlers/admin/redis-sessions.ts +6 -8
  86. package/src/api-handlers/admin/sessions.ts +5 -7
  87. package/src/api-handlers/admin/site-logs.ts +8 -10
  88. package/src/api-handlers/admin/stats.ts +4 -6
  89. package/src/api-handlers/admin/users.ts +5 -7
  90. package/src/api-handlers/admin/vibe-data.ts +10 -12
  91. package/src/api-handlers/auth/refresh.ts +5 -7
  92. package/src/api-handlers/auth/signout.ts +5 -6
  93. package/src/api-handlers/auth/status.ts +4 -7
  94. package/src/api-handlers/auth/update-session.ts +123 -125
  95. package/src/api-handlers/auth/verify-code.ts +9 -13
  96. package/src/api-handlers/session/viability.ts +10 -47
  97. package/src/api-handlers/test/force-expire.ts +4 -11
  98. package/src/auth/auth-decision.ts +1 -1
  99. package/src/auth/better-auth.ts +138 -141
  100. package/src/auth/route-config.ts +219 -219
  101. package/src/auth/utils/token-utils.ts +0 -1
  102. package/src/client/AuthContext.tsx +6 -2
  103. package/src/client/better-auth-client.ts +54 -7
  104. package/src/client/fetch-with-auth.ts +47 -47
  105. package/src/components/SessionSync.tsx +6 -5
  106. package/src/components/account/MobileNavDrawer.tsx +3 -3
  107. package/src/components/account/UserAvatarMenu.tsx +6 -3
  108. package/src/components/admin/VibeAdminLayout.tsx +4 -2
  109. package/src/config/logger.ts +1 -1
  110. package/src/hooks/useAuth.ts +117 -115
  111. package/src/hooks/useAuthSettings.ts +2 -2
  112. package/src/hooks/useAvailableProviders.ts +9 -5
  113. package/src/hooks/useSessionExpiration.ts +101 -102
  114. package/src/hooks/useViabilitySession.ts +336 -335
  115. package/src/index.ts +60 -63
  116. package/src/lib/api-handler.ts +0 -1
  117. package/src/lib/app-slug.ts +6 -6
  118. package/src/lib/standardized-client-api.ts +901 -895
  119. package/src/lib/startup-init.ts +243 -247
  120. package/src/lib/test-aware-get-token.ts +22 -12
  121. package/src/lib/token-lifecycle.ts +12 -53
  122. package/src/pages/admin-login/page.tsx +9 -17
  123. package/src/pages/client-admin/ClientSiteAdminPage.tsx +4 -2
  124. package/src/pages/login/page.tsx +21 -28
  125. package/src/pages/showcase/ShowcasePage.tsx +4 -2
  126. package/src/pages/test-env/EmergencyLogoutPage.tsx +7 -6
  127. package/src/pages/test-env/JwtInspectPage.tsx +5 -3
  128. package/src/pages/test-env/RefreshTokenPage.tsx +157 -155
  129. package/src/pages/test-env/TestEnvPage.tsx +4 -2
  130. package/src/pages/verify-code/page.tsx +10 -6
  131. package/src/routes/auth/logout.ts +7 -25
  132. package/src/routes/auth/nextauth.ts +45 -71
  133. package/src/routes/auth/session.ts +25 -50
  134. package/src/routes/auth/viability.ts +7 -19
  135. package/src/server/auth.ts +60 -0
  136. package/src/stores/authStore.ts +1899 -1904
  137. package/src/utils/logout.ts +30 -30
  138. package/src/auth/auth-options.ts +0 -237
  139. package/src/auth/callbacks/index.ts +0 -7
  140. package/src/auth/callbacks/jwt.ts +0 -382
  141. package/src/auth/callbacks/session.ts +0 -243
  142. package/src/auth/callbacks/signin.ts +0 -56
  143. package/src/auth/events/index.ts +0 -5
  144. package/src/auth/events/signout.ts +0 -33
  145. package/src/auth/providers/credentials.ts +0 -256
  146. package/src/auth/providers/index.ts +0 -6
  147. package/src/auth/providers/oauth.ts +0 -114
  148. package/src/lib/nextauth-secret.ts +0 -121
  149. package/src/types/next-auth.d.ts +0 -15
@@ -1,398 +1,360 @@
1
- "use strict";
2
- /**
3
- * Token Lifecycle Management for @payez/next-mvp
4
- *
5
- * Ensures tokens are fresh before making API calls.
6
- * Checks expiration and triggers refresh if needed.
7
- *
8
- * Pattern: Check first, refresh if needed, fail gracefully if refresh fails.
9
- *
10
- * HANDLES CONCURRENT REFRESH: When multiple API calls arrive simultaneously
11
- * with expired tokens, only one will actually perform the refresh. Others
12
- * receive 409 (conflict) and wait for the refresh to complete, then use
13
- * the freshly refreshed tokens.
14
- *
15
- * REQUIRED: Your app must expose the refresh route:
16
- * ```typescript
17
- * // app/api/auth/refresh/route.ts
18
- * export { POST } from '@payez/next-mvp/routes/auth/refresh';
19
- * ```
20
- *
21
- * @version 2.0.0
22
- */
23
- Object.defineProperty(exports, "__esModule", { value: true });
24
- exports.ensureFreshToken = ensureFreshToken;
25
- exports.getFreshAuthHeader = getFreshAuthHeader;
26
- const jwt_1 = require("next-auth/jwt");
27
- const session_store_1 = require("./session-store");
28
- const app_slug_1 = require("./app-slug");
29
- const idp_client_config_1 = require("./idp-client-config");
30
- // 5 minute threshold for "needs refresh" - matches refresh handler pattern
31
- const REFRESH_THRESHOLD_MS = 5 * 60 * 1000;
32
- // Concurrent refresh handling configuration
33
- const CONCURRENT_REFRESH_POLL_INTERVAL_MS = 200; // How often to poll session during concurrent refresh
34
- const CONCURRENT_REFRESH_MAX_WAIT_MS = 8000; // Max time to wait for concurrent refresh to complete
35
- const REFRESH_RETRY_DELAY_MS = 500; // Delay before retrying after failed concurrent refresh
36
- const KEY_PROPAGATION_DELAY_MS = 150; // Delay after refresh to allow JWKS cache updates in downstream services
37
- /**
38
- * Check if token needs refresh based on expiration time
39
- */
40
- function needsRefresh(accessTokenExpires) {
41
- if (!accessTokenExpires)
42
- return true;
43
- const timeUntilExpiry = accessTokenExpires - Date.now();
44
- return timeUntilExpiry <= REFRESH_THRESHOLD_MS;
45
- }
46
- /**
47
- * Helper to delay execution
48
- */
49
- function delay(ms) {
50
- return new Promise(resolve => setTimeout(resolve, ms));
51
- }
52
- /**
53
- * Wait for a concurrent refresh to complete by polling the session.
54
- * Returns true if session becomes fresh, false if timeout reached.
55
- */
56
- async function waitForConcurrentRefresh(sessionToken, maxWaitMs = CONCURRENT_REFRESH_MAX_WAIT_MS) {
57
- const startTime = Date.now();
58
- while (Date.now() - startTime < maxWaitMs) {
59
- await delay(CONCURRENT_REFRESH_POLL_INTERVAL_MS);
60
- const sessionData = await (0, session_store_1.getSession)(sessionToken);
61
- if (!sessionData) {
62
- return { success: false };
63
- }
64
- // Check if token is now fresh
65
- if (!needsRefresh(sessionData.idpAccessTokenExpires)) {
66
- return { success: true, sessionData };
67
- }
68
- // Check if session has a new access token (even if still within threshold)
69
- if (sessionData.idpAccessToken && sessionData.idpAccessTokenExpires &&
70
- sessionData.idpAccessTokenExpires > Date.now()) {
71
- return { success: true, sessionData };
72
- }
73
- }
74
- return { success: false };
75
- }
76
- /**
77
- * Get the internal API URL for making internal service calls.
78
- * INTERNAL_API_URL is REQUIRED - no fallbacks.
79
- */
80
- function getInternalApiUrl(request) {
81
- const internalUrl = process.env.INTERNAL_API_URL;
82
- if (!internalUrl) {
83
- throw new Error('[INTERNAL_API_URL] FATAL: INTERNAL_API_URL environment variable is REQUIRED. ' +
84
- 'Set it to this app\'s internal K8s service URL (e.g., http://myapp.namespace.svc.cluster.local:80) ' +
85
- 'or http://localhost:3000 for local development.');
86
- }
87
- return internalUrl;
88
- }
89
- /**
90
- * Trigger a token refresh via the refresh API endpoint.
91
- *
92
- * HANDLES CONCURRENT REFRESH (409):
93
- * When another request is already refreshing the token, this function
94
- * waits for that refresh to complete instead of failing immediately.
95
- * This prevents race conditions where multiple parallel API calls
96
- * could cause unnecessary refresh failures.
97
- *
98
- * TERMINAL STATES:
99
- * Returns { success: false, terminal: true } when the session cannot be
100
- * recovered (e.g., no refresh token). Callers should redirect to login.
101
- */
102
- async function triggerRefresh(request, sessionToken, retryCount = 0) {
103
- const maxRetries = 2;
104
- try {
105
- const baseUrl = getInternalApiUrl(request);
106
- const requestId = `refresh_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
107
- const response = await fetch(`${baseUrl}/api/auth/refresh`, {
108
- method: 'POST',
109
- headers: {
110
- 'Content-Type': 'application/json',
111
- 'Cookie': request.headers.get('cookie') || '',
112
- 'X-Session-Token': sessionToken,
113
- 'X-Request-Id': requestId,
114
- },
115
- });
116
- // Handle 409 Conflict - another refresh is in progress
117
- if (response.status === 409) {
118
- // Wait for the concurrent refresh to complete
119
- const waitResult = await waitForConcurrentRefresh(sessionToken);
120
- if (waitResult.success) {
121
- return { success: true };
122
- }
123
- // Concurrent refresh didn't produce a fresh token - try again if we have retries left
124
- if (retryCount < maxRetries) {
125
- await delay(REFRESH_RETRY_DELAY_MS);
126
- return triggerRefresh(request, sessionToken, retryCount + 1);
127
- }
128
- return { success: false };
129
- }
130
- // Handle other non-OK responses
131
- if (!response.ok) {
132
- // Parse response body to check for terminal errors
133
- let responseData = {};
134
- try {
135
- responseData = await response.json();
136
- }
137
- catch {
138
- // Ignore parse errors
139
- }
140
- // Log the failure for debugging
141
- console.warn('[TOKEN_LIFECYCLE] Refresh request failed:', {
142
- status: response.status,
143
- statusText: response.statusText,
144
- baseUrl,
145
- retryCount,
146
- code: responseData.code,
147
- terminal: responseData.terminal
148
- });
149
- // CHECK FOR TERMINAL STATE: No refresh token = session is dead
150
- // Don't retry - user must re-authenticate
151
- if (responseData.code === 'NO_REFRESH_TOKEN' || responseData.terminal === true) {
152
- console.error('[TOKEN_LIFECYCLE] TERMINAL: Session has no refresh token - user must re-login');
153
- return { success: false, terminal: true, code: responseData.code };
154
- }
155
- // For other 401s, check if maybe session was refreshed by another request
156
- if (response.status === 401 && retryCount < maxRetries) {
157
- await delay(REFRESH_RETRY_DELAY_MS);
158
- const sessionData = await (0, session_store_1.getSession)(sessionToken);
159
- if (sessionData && !needsRefresh(sessionData.idpAccessTokenExpires)) {
160
- return { success: true };
161
- }
162
- }
163
- return { success: false, code: responseData.code };
164
- }
165
- const result = await response.json();
166
- const success = result.refreshed === true || result.reason === 'already_fresh';
167
- return { success };
168
- }
169
- catch (error) {
170
- // Log network errors for debugging
171
- console.error('[TOKEN_LIFECYCLE] Refresh network error:', {
172
- error: error instanceof Error ? error.message : String(error),
173
- retryCount
174
- });
175
- // On network error, check if maybe another request refreshed the token
176
- if (retryCount < maxRetries) {
177
- await delay(REFRESH_RETRY_DELAY_MS);
178
- const sessionData = await (0, session_store_1.getSession)(sessionToken);
179
- if (sessionData && !needsRefresh(sessionData.idpAccessTokenExpires)) {
180
- return { success: true };
181
- }
182
- }
183
- return { success: false };
184
- }
185
- }
186
- /**
187
- * Ensures we have a fresh access token before making API calls.
188
- *
189
- * This utility checks token expiration and triggers a refresh if needed,
190
- * preventing 401 errors from expired tokens being sent to downstream APIs.
191
- *
192
- * @param request - The incoming NextRequest
193
- * @returns TokenResult with accessToken and sessionData, or TokenError
194
- *
195
- * @example
196
- * ```typescript
197
- * import { ensureFreshToken } from '@payez/next-mvp/lib/token-lifecycle';
198
- *
199
- * export async function GET(request: NextRequest) {
200
- * const tokenResult = await ensureFreshToken(request);
201
- * if (!tokenResult.success) {
202
- * return NextResponse.json({ error: tokenResult.error }, { status: 401 });
203
- * }
204
- *
205
- * // Use tokenResult.accessToken for downstream API calls
206
- * const response = await fetch('https://api.example.com/data', {
207
- * headers: { 'Authorization': `Bearer ${tokenResult.accessToken}` }
208
- * });
209
- * }
210
- * ```
211
- */
212
- /**
213
- * Get NextAuth secret from IDP config (cached).
214
- * NEVER use process.env.NEXTAUTH_SECRET directly - it may not be set.
215
- */
216
- async function getNextAuthSecret() {
217
- const config = await (0, idp_client_config_1.getIDPClientConfig)();
218
- return config.nextAuthSecret || '';
219
- }
220
- async function ensureFreshToken(request) {
221
- try {
222
- // 1. Get NextAuth JWT to extract sessionToken
223
- // Use IDP config to get the secret (same as viability.ts)
224
- const secret = await getNextAuthSecret();
225
- if (!secret) {
226
- console.error('[TOKEN_LIFECYCLE] NEXTAUTH_SECRET not available from IDP config');
227
- return { success: false, error: 'NO_SESSION', message: 'Auth not configured' };
228
- }
229
- const cookieName = (0, app_slug_1.getJwtCookieName)();
230
- const token = await (0, jwt_1.getToken)({
231
- req: request,
232
- secret,
233
- cookieName,
234
- });
235
- // Support both field names: sessionToken (auth.ts JWT) and redisSessionId (legacy)
236
- const sessionTokenFromJwt = (token?.sessionToken || token?.redisSessionId);
237
- if (!sessionTokenFromJwt) {
238
- // Debug: log what we got including cookie presence and value info
239
- const cookieHeader = request.headers.get('cookie') || '';
240
- const hasCookie = cookieHeader.includes(cookieName);
241
- // Extract the actual cookie value to check if it's empty
242
- const cookieMatch = cookieHeader.match(new RegExp(`${cookieName}=([^;]*)`));
243
- const cookieValue = cookieMatch ? cookieMatch[1] : null;
244
- const cookieValueLength = cookieValue?.length || 0;
245
- console.warn('[TOKEN_LIFECYCLE] NO_SESSION -', {
246
- token: token ? 'exists but no sessionToken/redisSessionId' : 'null',
247
- cookieName,
248
- hasCookie,
249
- cookieValueLength,
250
- cookieValuePreview: cookieValue ? cookieValue.substring(0, 20) + '...' : 'EMPTY',
251
- cookieHeaderLength: cookieHeader.length,
252
- secretLength: secret.length,
253
- });
254
- return {
255
- success: false,
256
- error: 'NO_SESSION',
257
- message: 'No session available',
258
- };
259
- }
260
- const sessionToken = sessionTokenFromJwt;
261
- // 2. Get session data from Redis
262
- let sessionData = await (0, session_store_1.getSession)(sessionToken);
263
- if (!sessionData) {
264
- return {
265
- success: false,
266
- error: 'NO_SESSION',
267
- message: 'Session expired or not found',
268
- };
269
- }
270
- // DEBUG: Log session data before refresh check
271
- const tokenExpiresStr = sessionData.idpAccessTokenExpires
272
- ? new Date(sessionData.idpAccessTokenExpires).toISOString()
273
- : 'undefined';
274
- let needsRefreshNow = needsRefresh(sessionData.idpAccessTokenExpires);
275
- // VALIDATION: Check if the actual JWT token's exp matches what Redis claims
276
- // This catches cases where accessTokenExpires was updated but accessToken wasn't
277
- let tokenMismatch = false;
278
- if (sessionData.idpAccessToken && !needsRefreshNow) {
279
- try {
280
- const tokenParts = sessionData.idpAccessToken.split('.');
281
- if (tokenParts.length === 3) {
282
- const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64url').toString());
283
- const jwtExpMs = (payload.exp || 0) * 1000;
284
- const now = Date.now();
285
- // If the JWT is actually expired, force a refresh regardless of what Redis says
286
- if (jwtExpMs < now) {
287
- console.warn('[TOKEN_LIFECYCLE] Token mismatch detected! JWT expired but Redis claims valid', {
288
- jwtExp: new Date(jwtExpMs).toISOString(),
289
- redisAccessTokenExpires: tokenExpiresStr,
290
- now: new Date(now).toISOString(),
291
- mismatchMs: sessionData.idpAccessTokenExpires ? sessionData.idpAccessTokenExpires - jwtExpMs : 'N/A'
292
- });
293
- needsRefreshNow = true;
294
- tokenMismatch = true;
295
- }
296
- }
297
- }
298
- catch (e) {
299
- // If we can't decode, proceed with normal logic
300
- console.warn('[TOKEN_LIFECYCLE] Could not validate JWT exp claim:', e);
301
- }
302
- }
303
- console.log('[TOKEN_LIFECYCLE] ensureFreshToken check:', {
304
- sessionToken: sessionToken.substring(0, 8) + '...',
305
- accessTokenExpires: tokenExpiresStr,
306
- now: new Date().toISOString(),
307
- needsRefresh: needsRefreshNow,
308
- tokenMismatch,
309
- hasRefreshToken: !!sessionData.idpRefreshToken
310
- });
311
- // 3. Check if token needs refresh
312
- if (needsRefreshNow) {
313
- // 4. Trigger refresh
314
- console.log('[TOKEN_LIFECYCLE] Triggering refresh...');
315
- const refreshResult = await triggerRefresh(request, sessionToken);
316
- if (!refreshResult.success) {
317
- // Check for terminal state - session cannot be recovered
318
- if (refreshResult.terminal) {
319
- console.error('[TOKEN_LIFECYCLE] TERMINAL: Session expired with no refresh token - redirect to login');
320
- return {
321
- success: false,
322
- error: 'SESSION_EXPIRED_NO_REFRESH',
323
- message: 'Session expired. Please sign in again.',
324
- terminal: true,
325
- };
326
- }
327
- console.warn('[TOKEN_LIFECYCLE] Refresh failed');
328
- return {
329
- success: false,
330
- error: 'REFRESH_FAILED',
331
- message: 'Token refresh failed',
332
- };
333
- }
334
- // 5. Re-fetch session data after refresh
335
- sessionData = await (0, session_store_1.getSession)(sessionToken);
336
- console.log('[TOKEN_LIFECYCLE] After refresh:', {
337
- hasAccessToken: !!sessionData?.idpAccessToken,
338
- newAccessTokenExpires: sessionData?.idpAccessTokenExpires
339
- ? new Date(sessionData.idpAccessTokenExpires).toISOString()
340
- : 'undefined'
341
- });
342
- if (!sessionData?.idpAccessToken) {
343
- return {
344
- success: false,
345
- error: 'REFRESH_FAILED',
346
- message: 'No access token after refresh',
347
- };
348
- }
349
- // 5.5. Key propagation delay - allow downstream services (like Vibe) to cache new JWKS
350
- // This is critical when IDP rotates signing keys - the new token's kid may not be
351
- // immediately available in Vibe's JWKS cache
352
- await delay(KEY_PROPAGATION_DELAY_MS);
353
- }
354
- // 6. Validate we have a token
355
- if (!sessionData.idpAccessToken) {
356
- return {
357
- success: false,
358
- error: 'NO_TOKEN',
359
- message: 'No access token available',
360
- };
361
- }
362
- return {
363
- success: true,
364
- accessToken: sessionData.idpAccessToken,
365
- sessionData,
366
- };
367
- }
368
- catch (error) {
369
- console.error('[TOKEN_LIFECYCLE] Error:', error);
370
- return {
371
- success: false,
372
- error: 'NO_SESSION',
373
- message: error instanceof Error ? error.message : 'Unknown error',
374
- };
375
- }
376
- }
377
- /**
378
- * Get authorization header from fresh token.
379
- * Convenience wrapper for API routes.
380
- *
381
- * @param request - The incoming NextRequest
382
- * @returns Authorization header string or null if token unavailable
383
- *
384
- * @example
385
- * ```typescript
386
- * const authHeader = await getFreshAuthHeader(request);
387
- * if (!authHeader) {
388
- * return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
389
- * }
390
- * ```
391
- */
392
- async function getFreshAuthHeader(request) {
393
- const result = await ensureFreshToken(request);
394
- if (!result.success) {
395
- return null;
396
- }
397
- return `Bearer ${result.accessToken}`;
398
- }
1
+ "use strict";
2
+ /**
3
+ * Token Lifecycle Management for @payez/next-mvp
4
+ *
5
+ * Ensures tokens are fresh before making API calls.
6
+ * Checks expiration and triggers refresh if needed.
7
+ *
8
+ * Pattern: Check first, refresh if needed, fail gracefully if refresh fails.
9
+ *
10
+ * HANDLES CONCURRENT REFRESH: When multiple API calls arrive simultaneously
11
+ * with expired tokens, only one will actually perform the refresh. Others
12
+ * receive 409 (conflict) and wait for the refresh to complete, then use
13
+ * the freshly refreshed tokens.
14
+ *
15
+ * REQUIRED: Your app must expose the refresh route:
16
+ * ```typescript
17
+ * // app/api/auth/refresh/route.ts
18
+ * export { POST } from '@payez/next-mvp/routes/auth/refresh';
19
+ * ```
20
+ *
21
+ * @version 2.0.0
22
+ */
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.ensureFreshToken = ensureFreshToken;
25
+ exports.getFreshAuthHeader = getFreshAuthHeader;
26
+ const session_store_1 = require("./session-store");
27
+ const auth_1 = require("../server/auth");
28
+ // 5 minute threshold for "needs refresh" - matches refresh handler pattern
29
+ const REFRESH_THRESHOLD_MS = 5 * 60 * 1000;
30
+ // Concurrent refresh handling configuration
31
+ const CONCURRENT_REFRESH_POLL_INTERVAL_MS = 200; // How often to poll session during concurrent refresh
32
+ const CONCURRENT_REFRESH_MAX_WAIT_MS = 8000; // Max time to wait for concurrent refresh to complete
33
+ const REFRESH_RETRY_DELAY_MS = 500; // Delay before retrying after failed concurrent refresh
34
+ const KEY_PROPAGATION_DELAY_MS = 150; // Delay after refresh to allow JWKS cache updates in downstream services
35
+ /**
36
+ * Check if token needs refresh based on expiration time
37
+ */
38
+ function needsRefresh(accessTokenExpires) {
39
+ if (!accessTokenExpires)
40
+ return true;
41
+ const timeUntilExpiry = accessTokenExpires - Date.now();
42
+ return timeUntilExpiry <= REFRESH_THRESHOLD_MS;
43
+ }
44
+ /**
45
+ * Helper to delay execution
46
+ */
47
+ function delay(ms) {
48
+ return new Promise(resolve => setTimeout(resolve, ms));
49
+ }
50
+ /**
51
+ * Wait for a concurrent refresh to complete by polling the session.
52
+ * Returns true if session becomes fresh, false if timeout reached.
53
+ */
54
+ async function waitForConcurrentRefresh(sessionToken, maxWaitMs = CONCURRENT_REFRESH_MAX_WAIT_MS) {
55
+ const startTime = Date.now();
56
+ while (Date.now() - startTime < maxWaitMs) {
57
+ await delay(CONCURRENT_REFRESH_POLL_INTERVAL_MS);
58
+ const sessionData = await (0, session_store_1.getSession)(sessionToken);
59
+ if (!sessionData) {
60
+ return { success: false };
61
+ }
62
+ // Check if token is now fresh
63
+ if (!needsRefresh(sessionData.idpAccessTokenExpires)) {
64
+ return { success: true, sessionData };
65
+ }
66
+ // Check if session has a new access token (even if still within threshold)
67
+ if (sessionData.idpAccessToken && sessionData.idpAccessTokenExpires &&
68
+ sessionData.idpAccessTokenExpires > Date.now()) {
69
+ return { success: true, sessionData };
70
+ }
71
+ }
72
+ return { success: false };
73
+ }
74
+ /**
75
+ * Get the internal API URL for making internal service calls.
76
+ * INTERNAL_API_URL is REQUIRED - no fallbacks.
77
+ */
78
+ function getInternalApiUrl(request) {
79
+ const internalUrl = process.env.INTERNAL_API_URL;
80
+ if (!internalUrl) {
81
+ throw new Error('[INTERNAL_API_URL] FATAL: INTERNAL_API_URL environment variable is REQUIRED. ' +
82
+ 'Set it to this app\'s internal K8s service URL (e.g., http://myapp.namespace.svc.cluster.local:80) ' +
83
+ 'or http://localhost:3000 for local development.');
84
+ }
85
+ return internalUrl;
86
+ }
87
+ /**
88
+ * Trigger a token refresh via the refresh API endpoint.
89
+ *
90
+ * HANDLES CONCURRENT REFRESH (409):
91
+ * When another request is already refreshing the token, this function
92
+ * waits for that refresh to complete instead of failing immediately.
93
+ * This prevents race conditions where multiple parallel API calls
94
+ * could cause unnecessary refresh failures.
95
+ *
96
+ * TERMINAL STATES:
97
+ * Returns { success: false, terminal: true } when the session cannot be
98
+ * recovered (e.g., no refresh token). Callers should redirect to login.
99
+ */
100
+ async function triggerRefresh(request, sessionToken, retryCount = 0) {
101
+ const maxRetries = 2;
102
+ try {
103
+ const baseUrl = getInternalApiUrl(request);
104
+ const requestId = `refresh_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
105
+ const response = await fetch(`${baseUrl}/api/auth/refresh`, {
106
+ method: 'POST',
107
+ headers: {
108
+ 'Content-Type': 'application/json',
109
+ 'Cookie': request.headers.get('cookie') || '',
110
+ 'X-Session-Token': sessionToken,
111
+ 'X-Request-Id': requestId,
112
+ },
113
+ });
114
+ // Handle 409 Conflict - another refresh is in progress
115
+ if (response.status === 409) {
116
+ // Wait for the concurrent refresh to complete
117
+ const waitResult = await waitForConcurrentRefresh(sessionToken);
118
+ if (waitResult.success) {
119
+ return { success: true };
120
+ }
121
+ // Concurrent refresh didn't produce a fresh token - try again if we have retries left
122
+ if (retryCount < maxRetries) {
123
+ await delay(REFRESH_RETRY_DELAY_MS);
124
+ return triggerRefresh(request, sessionToken, retryCount + 1);
125
+ }
126
+ return { success: false };
127
+ }
128
+ // Handle other non-OK responses
129
+ if (!response.ok) {
130
+ // Parse response body to check for terminal errors
131
+ let responseData = {};
132
+ try {
133
+ responseData = await response.json();
134
+ }
135
+ catch {
136
+ // Ignore parse errors
137
+ }
138
+ // Log the failure for debugging
139
+ console.warn('[TOKEN_LIFECYCLE] Refresh request failed:', {
140
+ status: response.status,
141
+ statusText: response.statusText,
142
+ baseUrl,
143
+ retryCount,
144
+ code: responseData.code,
145
+ terminal: responseData.terminal
146
+ });
147
+ // CHECK FOR TERMINAL STATE: No refresh token = session is dead
148
+ // Don't retry - user must re-authenticate
149
+ if (responseData.code === 'NO_REFRESH_TOKEN' || responseData.terminal === true) {
150
+ console.error('[TOKEN_LIFECYCLE] TERMINAL: Session has no refresh token - user must re-login');
151
+ return { success: false, terminal: true, code: responseData.code };
152
+ }
153
+ // For other 401s, check if maybe session was refreshed by another request
154
+ if (response.status === 401 && retryCount < maxRetries) {
155
+ await delay(REFRESH_RETRY_DELAY_MS);
156
+ const sessionData = await (0, session_store_1.getSession)(sessionToken);
157
+ if (sessionData && !needsRefresh(sessionData.idpAccessTokenExpires)) {
158
+ return { success: true };
159
+ }
160
+ }
161
+ return { success: false, code: responseData.code };
162
+ }
163
+ const result = await response.json();
164
+ const success = result.refreshed === true || result.reason === 'already_fresh';
165
+ return { success };
166
+ }
167
+ catch (error) {
168
+ // Log network errors for debugging
169
+ console.error('[TOKEN_LIFECYCLE] Refresh network error:', {
170
+ error: error instanceof Error ? error.message : String(error),
171
+ retryCount
172
+ });
173
+ // On network error, check if maybe another request refreshed the token
174
+ if (retryCount < maxRetries) {
175
+ await delay(REFRESH_RETRY_DELAY_MS);
176
+ const sessionData = await (0, session_store_1.getSession)(sessionToken);
177
+ if (sessionData && !needsRefresh(sessionData.idpAccessTokenExpires)) {
178
+ return { success: true };
179
+ }
180
+ }
181
+ return { success: false };
182
+ }
183
+ }
184
+ /**
185
+ * Ensures we have a fresh access token before making API calls.
186
+ *
187
+ * This utility checks token expiration and triggers a refresh if needed,
188
+ * preventing 401 errors from expired tokens being sent to downstream APIs.
189
+ *
190
+ * @param request - The incoming NextRequest
191
+ * @returns TokenResult with accessToken and sessionData, or TokenError
192
+ *
193
+ * @example
194
+ * ```typescript
195
+ * import { ensureFreshToken } from '@payez/next-mvp/lib/token-lifecycle';
196
+ *
197
+ * export async function GET(request: NextRequest) {
198
+ * const tokenResult = await ensureFreshToken(request);
199
+ * if (!tokenResult.success) {
200
+ * return NextResponse.json({ error: tokenResult.error }, { status: 401 });
201
+ * }
202
+ *
203
+ * // Use tokenResult.accessToken for downstream API calls
204
+ * const response = await fetch('https://api.example.com/data', {
205
+ * headers: { 'Authorization': `Bearer ${tokenResult.accessToken}` }
206
+ * });
207
+ * }
208
+ * ```
209
+ */
210
+ async function ensureFreshToken(request) {
211
+ try {
212
+ // 1. Get Better Auth session to extract sessionToken
213
+ const betterAuthSession = await (0, auth_1.getSession)(request);
214
+ if (!betterAuthSession?.session?.token) {
215
+ console.warn('[TOKEN_LIFECYCLE] NO_SESSION - Better Auth session not found');
216
+ return {
217
+ success: false,
218
+ error: 'NO_SESSION',
219
+ message: 'No session available',
220
+ };
221
+ }
222
+ const sessionToken = betterAuthSession.session.token;
223
+ // 2. Get session data from Redis
224
+ let sessionData = await (0, session_store_1.getSession)(sessionToken);
225
+ if (!sessionData) {
226
+ return {
227
+ success: false,
228
+ error: 'NO_SESSION',
229
+ message: 'Session expired or not found',
230
+ };
231
+ }
232
+ // DEBUG: Log session data before refresh check
233
+ const tokenExpiresStr = sessionData.idpAccessTokenExpires
234
+ ? new Date(sessionData.idpAccessTokenExpires).toISOString()
235
+ : 'undefined';
236
+ let needsRefreshNow = needsRefresh(sessionData.idpAccessTokenExpires);
237
+ // VALIDATION: Check if the actual JWT token's exp matches what Redis claims
238
+ // This catches cases where accessTokenExpires was updated but accessToken wasn't
239
+ let tokenMismatch = false;
240
+ if (sessionData.idpAccessToken && !needsRefreshNow) {
241
+ try {
242
+ const tokenParts = sessionData.idpAccessToken.split('.');
243
+ if (tokenParts.length === 3) {
244
+ const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64url').toString());
245
+ const jwtExpMs = (payload.exp || 0) * 1000;
246
+ const now = Date.now();
247
+ // If the JWT is actually expired, force a refresh regardless of what Redis says
248
+ if (jwtExpMs < now) {
249
+ console.warn('[TOKEN_LIFECYCLE] Token mismatch detected! JWT expired but Redis claims valid', {
250
+ jwtExp: new Date(jwtExpMs).toISOString(),
251
+ redisAccessTokenExpires: tokenExpiresStr,
252
+ now: new Date(now).toISOString(),
253
+ mismatchMs: sessionData.idpAccessTokenExpires ? sessionData.idpAccessTokenExpires - jwtExpMs : 'N/A'
254
+ });
255
+ needsRefreshNow = true;
256
+ tokenMismatch = true;
257
+ }
258
+ }
259
+ }
260
+ catch (e) {
261
+ // If we can't decode, proceed with normal logic
262
+ console.warn('[TOKEN_LIFECYCLE] Could not validate JWT exp claim:', e);
263
+ }
264
+ }
265
+ console.log('[TOKEN_LIFECYCLE] ensureFreshToken check:', {
266
+ sessionToken: sessionToken.substring(0, 8) + '...',
267
+ accessTokenExpires: tokenExpiresStr,
268
+ now: new Date().toISOString(),
269
+ needsRefresh: needsRefreshNow,
270
+ tokenMismatch,
271
+ hasRefreshToken: !!sessionData.idpRefreshToken
272
+ });
273
+ // 3. Check if token needs refresh
274
+ if (needsRefreshNow) {
275
+ // 4. Trigger refresh
276
+ console.log('[TOKEN_LIFECYCLE] Triggering refresh...');
277
+ const refreshResult = await triggerRefresh(request, sessionToken);
278
+ if (!refreshResult.success) {
279
+ // Check for terminal state - session cannot be recovered
280
+ if (refreshResult.terminal) {
281
+ console.error('[TOKEN_LIFECYCLE] TERMINAL: Session expired with no refresh token - redirect to login');
282
+ return {
283
+ success: false,
284
+ error: 'SESSION_EXPIRED_NO_REFRESH',
285
+ message: 'Session expired. Please sign in again.',
286
+ terminal: true,
287
+ };
288
+ }
289
+ console.warn('[TOKEN_LIFECYCLE] Refresh failed');
290
+ return {
291
+ success: false,
292
+ error: 'REFRESH_FAILED',
293
+ message: 'Token refresh failed',
294
+ };
295
+ }
296
+ // 5. Re-fetch session data after refresh
297
+ sessionData = await (0, session_store_1.getSession)(sessionToken);
298
+ console.log('[TOKEN_LIFECYCLE] After refresh:', {
299
+ hasAccessToken: !!sessionData?.idpAccessToken,
300
+ newAccessTokenExpires: sessionData?.idpAccessTokenExpires
301
+ ? new Date(sessionData.idpAccessTokenExpires).toISOString()
302
+ : 'undefined'
303
+ });
304
+ if (!sessionData?.idpAccessToken) {
305
+ return {
306
+ success: false,
307
+ error: 'REFRESH_FAILED',
308
+ message: 'No access token after refresh',
309
+ };
310
+ }
311
+ // 5.5. Key propagation delay - allow downstream services (like Vibe) to cache new JWKS
312
+ // This is critical when IDP rotates signing keys - the new token's kid may not be
313
+ // immediately available in Vibe's JWKS cache
314
+ await delay(KEY_PROPAGATION_DELAY_MS);
315
+ }
316
+ // 6. Validate we have a token
317
+ if (!sessionData.idpAccessToken) {
318
+ return {
319
+ success: false,
320
+ error: 'NO_TOKEN',
321
+ message: 'No access token available',
322
+ };
323
+ }
324
+ return {
325
+ success: true,
326
+ accessToken: sessionData.idpAccessToken,
327
+ sessionData,
328
+ };
329
+ }
330
+ catch (error) {
331
+ console.error('[TOKEN_LIFECYCLE] Error:', error);
332
+ return {
333
+ success: false,
334
+ error: 'NO_SESSION',
335
+ message: error instanceof Error ? error.message : 'Unknown error',
336
+ };
337
+ }
338
+ }
339
+ /**
340
+ * Get authorization header from fresh token.
341
+ * Convenience wrapper for API routes.
342
+ *
343
+ * @param request - The incoming NextRequest
344
+ * @returns Authorization header string or null if token unavailable
345
+ *
346
+ * @example
347
+ * ```typescript
348
+ * const authHeader = await getFreshAuthHeader(request);
349
+ * if (!authHeader) {
350
+ * return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
351
+ * }
352
+ * ```
353
+ */
354
+ async function getFreshAuthHeader(request) {
355
+ const result = await ensureFreshToken(request);
356
+ if (!result.success) {
357
+ return null;
358
+ }
359
+ return `Bearer ${result.accessToken}`;
360
+ }