@payez/next-mvp 4.1.9 → 4.1.11

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 (164) hide show
  1. package/README.md +792 -792
  2. package/dist/api-handlers/auth/refresh.js +38 -15
  3. package/dist/auth/better-auth.js +4 -3
  4. package/dist/components/account/MobileNavDrawer.js +18 -18
  5. package/dist/components/account/UserAvatarMenu.js +1 -1
  6. package/dist/components/admin/AlertSettingsTab.js +40 -40
  7. package/dist/components/admin/LoggingSettingsTab.js +28 -28
  8. package/dist/lib/ensure-fresh-access-token.js +42 -8
  9. package/dist/lib/idp-fetch.js +25 -9
  10. package/dist/lib/session-store.d.ts +1 -0
  11. package/dist/lib/session-store.js +12 -8
  12. package/dist/lib/token-lifecycle.d.ts +2 -0
  13. package/dist/lib/token-lifecycle.js +21 -3
  14. package/dist/pages/login/page.js +28 -28
  15. package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
  16. package/package.json +893 -893
  17. package/scripts/dev-broker.ps1 +35 -35
  18. package/scripts/dev-local.ps1 +45 -45
  19. package/src/api/auth-handler.ts +548 -548
  20. package/src/api-handlers/account/change-password.ts +142 -142
  21. package/src/api-handlers/account/update-phone.ts +79 -79
  22. package/src/api-handlers/account/verify-email.ts +125 -125
  23. package/src/api-handlers/account/verify-sms.ts +125 -125
  24. package/src/api-handlers/admin/analytics.ts +443 -443
  25. package/src/api-handlers/admin/audit.ts +223 -223
  26. package/src/api-handlers/admin/index.ts +63 -63
  27. package/src/api-handlers/admin/redis-sessions.ts +251 -251
  28. package/src/api-handlers/admin/sessions.ts +318 -318
  29. package/src/api-handlers/admin/site-logs.ts +365 -365
  30. package/src/api-handlers/admin/users.ts +242 -242
  31. package/src/api-handlers/admin/vibe-data.ts +324 -324
  32. package/src/api-handlers/auth/refresh.ts +705 -682
  33. package/src/api-handlers/auth/signout.ts +199 -199
  34. package/src/api-handlers/auth/status.ts +20 -20
  35. package/src/api-handlers/auth/update-session.ts +110 -110
  36. package/src/api-handlers/auth/verify-code.ts +112 -112
  37. package/src/api-handlers/session/refresh-viability.ts +36 -36
  38. package/src/api-handlers/test/force-expire.ts +60 -60
  39. package/src/auth/auth-decision.ts +229 -229
  40. package/src/auth/better-auth.ts +467 -466
  41. package/src/auth/route-config.ts +219 -219
  42. package/src/auth/types/auth-types.ts +555 -555
  43. package/src/auth/types/index.ts +7 -7
  44. package/src/auth/utils/idp-client.ts +445 -445
  45. package/src/auth/utils/index.ts +6 -6
  46. package/src/auth/utils/token-utils.ts +243 -243
  47. package/src/client/AuthContext.tsx +143 -143
  48. package/src/client/fetch-with-auth.ts +47 -47
  49. package/src/components/SessionSync.tsx +138 -138
  50. package/src/components/SignalRHealthCheck.tsx +131 -131
  51. package/src/components/account/MobileNavDrawer.tsx +305 -305
  52. package/src/components/account/UserAvatarMenu.tsx +266 -266
  53. package/src/components/account/index.ts +13 -13
  54. package/src/components/admin/AlertSettingsTab.tsx +728 -728
  55. package/src/components/admin/AnalyticsTab.tsx +703 -703
  56. package/src/components/admin/DataBrowserTab.tsx +505 -505
  57. package/src/components/admin/LoggingSettingsTab.tsx +665 -665
  58. package/src/components/admin/SessionsTab.tsx +414 -414
  59. package/src/components/admin/StatsTab.tsx +379 -379
  60. package/src/components/admin/VibeAdminLayout.tsx +187 -187
  61. package/src/components/admin/index.ts +59 -59
  62. package/src/components/auth/FederatedAuthSection.tsx +95 -95
  63. package/src/components/auth/ModeAwareLoginPage.tsx +135 -135
  64. package/src/components/auth/ModeAwareSignupPage.tsx +267 -267
  65. package/src/components/auth/TraditionalAuthSection.tsx +99 -99
  66. package/src/components/recovery/CompleteStep.tsx +36 -36
  67. package/src/components/recovery/InitiateRecoveryStep.tsx +68 -68
  68. package/src/components/recovery/SelectMethodStep.tsx +73 -73
  69. package/src/components/recovery/SetPasswordStep.tsx +97 -97
  70. package/src/components/recovery/VerifyCodeStep.tsx +90 -90
  71. package/src/components/reserved/ReservedRecoveryWarning.tsx +160 -160
  72. package/src/components/reserved/ReservedStatusBox.tsx +118 -118
  73. package/src/components/ui/BetaBadge.tsx +58 -58
  74. package/src/components/ui/Footer.tsx +93 -93
  75. package/src/config/env.ts +57 -57
  76. package/src/config/logger.ts +62 -62
  77. package/src/config/vibe-log-transport.ts +264 -264
  78. package/src/hooks/useAuth.ts +117 -117
  79. package/src/hooks/useAuthSettings.ts +97 -97
  80. package/src/hooks/useAvailableProviders.ts +120 -120
  81. package/src/hooks/usePasswordValidation.ts +127 -127
  82. package/src/hooks/usePublicAuthSettings.ts +149 -149
  83. package/src/hooks/useSessionExpiration.ts +101 -101
  84. package/src/hooks/useViabilitySession.ts +336 -336
  85. package/src/index.ts +60 -60
  86. package/src/lib/api-handler.ts +643 -643
  87. package/src/lib/app-slug.ts +178 -178
  88. package/src/lib/ensure-fresh-access-token.ts +369 -335
  89. package/src/lib/geolocation.ts +265 -265
  90. package/src/lib/idp-fetch.ts +26 -10
  91. package/src/lib/jwt-decode.ts +82 -82
  92. package/src/lib/roles.ts +177 -177
  93. package/src/lib/session-store.ts +4 -1
  94. package/src/lib/site-logger.ts +245 -245
  95. package/src/lib/standardized-client-api.ts +901 -901
  96. package/src/lib/test-aware-get-token.ts +41 -41
  97. package/src/lib/token-lifecycle.ts +30 -3
  98. package/src/lib/user-agent-parser.ts +252 -252
  99. package/src/logging/api/admin-analytics.ts +51 -51
  100. package/src/logging/api/audit-log.ts +53 -53
  101. package/src/logging/components/AdminAnalyticsLayout.tsx +49 -49
  102. package/src/logging/components/AuditLogViewer.tsx +125 -125
  103. package/src/logging/components/ErrorMetricsCard.tsx +98 -98
  104. package/src/logging/components/HealthMetricsCard.tsx +70 -70
  105. package/src/logging/hooks/useAdminAnalytics.ts +22 -22
  106. package/src/logging/hooks/useAuditLog.ts +24 -24
  107. package/src/logging/hooks/useErrorMetrics.ts +40 -40
  108. package/src/logging/hooks/useHealthMetrics.ts +44 -44
  109. package/src/logging/index.ts +18 -18
  110. package/src/logging/types/analytics.ts +81 -81
  111. package/src/logging/types/audit.ts +31 -31
  112. package/src/logging/types/index.ts +3 -3
  113. package/src/middleware/create-middleware.ts +663 -663
  114. package/src/middleware/rbac-check.ts +279 -279
  115. package/src/middleware/twofa-presets.ts +224 -224
  116. package/src/pages/admin-login/page.tsx +221 -221
  117. package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -527
  118. package/src/pages/admin-page-permissions/index.ts +7 -7
  119. package/src/pages/admin-roles/RolesAdminPage.tsx +533 -533
  120. package/src/pages/admin-roles/modals.tsx +469 -469
  121. package/src/pages/client-admin/ClientSiteAdminPage.tsx +382 -382
  122. package/src/pages/client-admin/index.ts +33 -33
  123. package/src/pages/coming-soon/page.tsx +83 -83
  124. package/src/pages/login/page.tsx +456 -456
  125. package/src/pages/profile/EnhancedProfilePage.tsx +485 -485
  126. package/src/pages/profile/index.ts +9 -9
  127. package/src/pages/profile/page.tsx +166 -166
  128. package/src/pages/recovery/page.tsx +234 -234
  129. package/src/pages/roles/MyRolesPage.tsx +211 -211
  130. package/src/pages/roles/components.tsx +294 -294
  131. package/src/pages/roles/index.ts +17 -17
  132. package/src/pages/security/EnhancedSecurityPage.tsx +565 -565
  133. package/src/pages/security/index.ts +9 -9
  134. package/src/pages/security/page.tsx +507 -507
  135. package/src/pages/settings/EnhancedSettingsPage.tsx +642 -642
  136. package/src/pages/settings/index.ts +9 -9
  137. package/src/pages/settings/page.tsx +47 -47
  138. package/src/pages/showcase/ShowcasePage.tsx +532 -532
  139. package/src/pages/showcase/index.ts +13 -13
  140. package/src/pages/test-env/EmergencyLogoutPage.tsx +180 -180
  141. package/src/pages/test-env/JwtInspectPage.tsx +456 -456
  142. package/src/pages/test-env/RefreshTokenPage.tsx +157 -157
  143. package/src/pages/test-env/TestEnvPage.tsx +118 -118
  144. package/src/pages/test-env/index.ts +25 -25
  145. package/src/pages/verify-code/page.tsx +652 -652
  146. package/src/routes/account/update-phone.ts +13 -13
  147. package/src/routes/auth/logout.ts +108 -108
  148. package/src/routes/auth/nextauth.ts +45 -45
  149. package/src/routes/auth/settings.ts +75 -75
  150. package/src/routes/auth/viability.ts +224 -224
  151. package/src/server/auth-guard.ts +170 -170
  152. package/src/server/slim-middleware.ts +117 -117
  153. package/src/server/with-auth.ts +93 -93
  154. package/src/stores/authStore.ts +1899 -1899
  155. package/src/templates/instrumentation.ts +41 -41
  156. package/src/utils/logout.ts +30 -30
  157. package/src/vibe/client.ts +590 -590
  158. package/src/vibe/enterprise-auth.ts +208 -208
  159. package/src/vibe/errors.ts +185 -185
  160. package/src/vibe/generic.ts +431 -431
  161. package/src/vibe/hooks/index.ts +367 -367
  162. package/src/vibe/index.ts +132 -132
  163. package/src/vibe/sessions.ts +551 -551
  164. package/src/vibe/types.ts +577 -577
@@ -1,682 +1,705 @@
1
- /**
2
- * CRITICAL REFRESH TOKEN API HANDLER
3
- *
4
- * ASK BEFORE EDITING - TESTED AND WORKING SYSTEM
5
- *
6
- * This handler manages the server-side refresh token cycle with:
7
- * - Better Auth session extraction
8
- * - Session token fallback for internal calls
9
- * - PayEz IDP refresh token exchange
10
- * - Session state updates with new tokens
11
- * - Proper error handling and logging
12
- * - Single-use semantics enforcement
13
- *
14
- * @version 2.0
15
- */
16
-
17
- import { NextRequest, NextResponse } from 'next/server';
18
- import { getSession as getBetterAuthSession } from '../../server/auth';
19
- import { getSession, updateSession, acquireRefreshLock, releaseRefreshLock, checkRefreshLock } from '../../lib/session-store';
20
- import { computeTokenExpiries } from '../../lib/token-expiry';
21
- import { extractKidFromToken } from '../../auth/utils/token-utils';
22
-
23
- interface RefreshConfig {
24
- idpBaseUrl: string;
25
- clientId: string;
26
- refreshEndpoint?: string;
27
- }
28
-
29
- /**
30
- * Creates a refresh token handler for Next.js API routes
31
- *
32
- * @param config IDP connection settings (Better Auth handles session crypto)
33
- * @returns Next.js POST handler function
34
- *
35
- * @example
36
- * ```typescript
37
- * // In your app's /app/api/auth/refresh/route.ts
38
- * import { createRefreshHandler } from '@payez/next-mvp/api-handlers/auth/refresh';
39
- *
40
- * export const POST = createRefreshHandler({
41
- * idpBaseUrl: process.env.IDP_URL!,
42
- * clientId: process.env.CLIENT_ID!,
43
- * refreshEndpoint: '/api/ExternalAuth/refresh'
44
- * });
45
- * ```
46
- */
47
- export function createRefreshHandler(config: RefreshConfig) {
48
- const { idpBaseUrl, clientId, refreshEndpoint = '/api/ExternalAuth/refresh' } = config;
49
-
50
- return async function POST(req: NextRequest) {
51
- try {
52
- // Extract session from Better Auth
53
- const betterAuthSession = await getBetterAuthSession(req);
54
-
55
- let sessionToken = (betterAuthSession?.session?.token) as string | undefined;
56
- let userId = betterAuthSession?.user?.id;
57
-
58
- if (!sessionToken) {
59
- // Fallback: check for session token in header (for internal server-to-server calls)
60
- const headerSessionToken = req.headers.get('x-session-token');
61
- if (headerSessionToken) {
62
- sessionToken = headerSessionToken;
63
- const currentSession = await getSession(sessionToken);
64
- userId = currentSession?.userId || currentSession?.email;
65
- }
66
- }
67
-
68
- if (!sessionToken || !userId) {
69
- console.warn('[AUTH_REFRESH] Missing sessionToken or user id on token');
70
- return NextResponse.json({
71
- error: 'No session available for refresh',
72
- code: 'UNAUTHORIZED'
73
- }, { status: 401 });
74
- }
75
-
76
- // Get current session
77
- const currentSession = await getSession(sessionToken);
78
- // NOTE: Field is idpRefreshToken (not refreshToken) per normalized naming convention
79
- if (!currentSession?.idpRefreshToken) {
80
- console.warn('[AUTH_REFRESH] No refresh token available', { userId });
81
- return NextResponse.json({
82
- error: 'No refresh token available',
83
- code: 'NO_REFRESH_TOKEN', // Terminal state - session cannot be refreshed
84
- terminal: true, // Signal to frontend: don't retry, redirect to login
85
- resolution: 'User must re-authenticate'
86
- }, { status: 401 });
87
- }
88
-
89
- // ============================================================================
90
- // HIGH VISIBILITY: PRE-FLIGHT REFRESH DIAGNOSTICS
91
- // ============================================================================
92
- const now = Date.now();
93
- const refreshTokenAge = currentSession.idpRefreshTokenIssuedAt
94
- ? Math.round((now - currentSession.idpRefreshTokenIssuedAt) / 1000)
95
- : 'unknown';
96
- const refreshTokenExpiresIn = currentSession.idpRefreshTokenExpires
97
- ? Math.round((currentSession.idpRefreshTokenExpires - now) / 1000)
98
- : 'unknown';
99
- const accessTokenExpiresIn = currentSession.idpAccessTokenExpires
100
- ? Math.round((currentSession.idpAccessTokenExpires - now) / 1000)
101
- : 'unknown';
102
-
103
- console.log('╔══════════════════════════════════════════════════════════════════════════════╗');
104
- console.log('║ REFRESH TOKEN ATTEMPT - PRE-FLIGHT ║');
105
- console.log('╠══════════════════════════════════════════════════════════════════════════════╣');
106
- console.log(`║ User: ${(userId || 'unknown').substring(0, 50).padEnd(50)} ║`);
107
- console.log(`║ Session: ${sessionToken.substring(0, 8)}... ║`);
108
- console.log('╠──────────────────────────────────────────────────────────────────────────────╣');
109
- console.log(`║ Access Token Expires In: ${String(accessTokenExpiresIn).padEnd(10)} seconds ║`);
110
- console.log(`║ Refresh Token Age: ${String(refreshTokenAge).padEnd(10)} seconds ║`);
111
- console.log(`║ Refresh Token Expires In: ${String(refreshTokenExpiresIn).padEnd(10)} seconds ║`);
112
- console.log(`║ Refresh Token Length: ${String(currentSession.idpRefreshToken?.length || 0).padEnd(10)} chars ║`);
113
- console.log(`║ 2FA Complete: ${String(!!currentSession.mfaVerified).padEnd(10)} ║`);
114
- console.log(`║ Auth Level (ACR): ${String(currentSession.authenticationLevel || '1').padEnd(10)} ║`);
115
- console.log('╚══════════════════════════════════════════════════════════════════════════════╝');
116
-
117
- // Try to acquire refresh lock to prevent concurrent refresh attempts
118
- const requestId = req.headers.get('x-request-id') ?? `refresh_${Date.now()}`;
119
- const lockAcquired = await acquireRefreshLock(sessionToken, requestId, 5000);
120
-
121
- let weAcquiredLock = false;
122
- let releaseLockVersion: number | undefined;
123
-
124
- if (!lockAcquired.acquired) {
125
- const existingLock = await checkRefreshLock(sessionToken);
126
- if (existingLock && existingLock.acquiredBy === requestId) {
127
- console.info('[AUTH_REFRESH] Proceeding under existing caller-held lock', { requestId });
128
- } else {
129
- // Wait for the lock to release, then check if token is now fresh
130
- console.info('[AUTH_REFRESH] Refresh in progress by another request, waiting for completion...', { requestId });
131
-
132
- const maxWaitMs = 5000;
133
- const checkIntervalMs = 200;
134
- const startWait = Date.now();
135
-
136
- while (Date.now() - startWait < maxWaitMs) {
137
- await new Promise(resolve => setTimeout(resolve, checkIntervalMs));
138
-
139
- // Check if lock was released
140
- const lockCheck = await checkRefreshLock(sessionToken);
141
- if (!lockCheck) {
142
- // Lock released - check if token is now fresh
143
- const refreshedSession = await getSession(sessionToken);
144
- if (refreshedSession?.idpAccessToken && refreshedSession?.idpAccessTokenExpires) {
145
- const timeUntilExpiry = refreshedSession.idpAccessTokenExpires - Date.now();
146
- const fiveMinutes = 5 * 60 * 1000;
147
-
148
- if (timeUntilExpiry > fiveMinutes) {
149
- console.info('[AUTH_REFRESH] Lock released, token is fresh - returning success', {
150
- requestId,
151
- accessTokenExpires: new Date(refreshedSession.idpAccessTokenExpires).toISOString(),
152
- waitedMs: Date.now() - startWait,
153
- });
154
- return NextResponse.json({
155
- refreshed: true,
156
- reason: 'completed_by_concurrent_request',
157
- accessTokenExpires: refreshedSession.idpAccessTokenExpires,
158
- hasRefreshToken: !!refreshedSession.idpRefreshToken,
159
- });
160
- }
161
- }
162
- // Lock released but token not fresh - try to acquire lock ourselves
163
- break;
164
- }
165
- }
166
-
167
- // Still locked after waiting - return 409
168
- console.warn('[AUTH_REFRESH] Refresh still in progress after waiting', { requestId, waitedMs: Date.now() - startWait });
169
- return NextResponse.json({
170
- error: 'Refresh already in progress',
171
- code: 'CONFLICT'
172
- }, { status: 409 });
173
- }
174
- } else {
175
- weAcquiredLock = true;
176
- releaseLockVersion = lockAcquired.lockInfo?.lockVersion;
177
- }
178
-
179
- // Before performing network refresh, re-check if tokens are already fresh
180
- const latestAfterLock = await getSession(sessionToken);
181
- const fiveMinutes = 5 * 60 * 1000;
182
- const timeUntilExpiry = latestAfterLock?.idpAccessTokenExpires
183
- ? latestAfterLock.idpAccessTokenExpires - Date.now()
184
- : -1;
185
-
186
- // CRITICAL: Also check the actual JWT's exp claim - Redis might have stale data
187
- let actualJwtExpMs = -1;
188
- let tokenMismatch = false;
189
- if (latestAfterLock?.idpAccessToken) {
190
- try {
191
- const tokenParts = latestAfterLock.idpAccessToken.split('.');
192
- if (tokenParts.length === 3) {
193
- const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64url').toString());
194
- actualJwtExpMs = (payload.exp || 0) * 1000;
195
- const now = Date.now();
196
- // If the actual JWT is expired, we MUST refresh regardless of what Redis says
197
- if (actualJwtExpMs < now) {
198
- tokenMismatch = true;
199
- }
200
- }
201
- } catch (e) {
202
- // If we can't decode, proceed with normal logic
203
- }
204
- }
205
-
206
- console.log('[AUTH_REFRESH] Pre-refresh check:', {
207
- userId,
208
- hasAccessToken: !!latestAfterLock?.idpAccessToken,
209
- accessTokenExpires: latestAfterLock?.idpAccessTokenExpires
210
- ? new Date(latestAfterLock.idpAccessTokenExpires).toISOString()
211
- : 'undefined',
212
- actualJwtExp: actualJwtExpMs > 0 ? new Date(actualJwtExpMs).toISOString() : 'unknown',
213
- now: new Date().toISOString(),
214
- timeUntilExpiryMs: timeUntilExpiry,
215
- tokenMismatch,
216
- willRefresh: timeUntilExpiry <= fiveMinutes || tokenMismatch
217
- });
218
-
219
- // Only skip refresh if BOTH Redis says fresh AND the actual JWT is not expired
220
- if (latestAfterLock?.idpAccessToken && latestAfterLock?.idpAccessTokenExpires &&
221
- timeUntilExpiry > fiveMinutes && !tokenMismatch) {
222
- console.info('[AUTH_REFRESH] Skipping refresh: tokens already fresh', { userId, timeUntilExpiryMs: timeUntilExpiry });
223
- if (weAcquiredLock) {
224
- await releaseRefreshLock(sessionToken, requestId, releaseLockVersion);
225
- weAcquiredLock = false;
226
- }
227
- return NextResponse.json({
228
- refreshed: false,
229
- reason: 'already_fresh',
230
- accessTokenExpires: latestAfterLock.idpAccessTokenExpires,
231
- hasRefreshToken: !!latestAfterLock.idpRefreshToken,
232
- });
233
- }
234
-
235
- // If we detected a token mismatch, log it prominently
236
- if (tokenMismatch) {
237
- console.warn('[AUTH_REFRESH] Token mismatch detected - forcing refresh despite Redis claiming fresh', {
238
- userId,
239
- redisExpires: latestAfterLock?.idpAccessTokenExpires ? new Date(latestAfterLock.idpAccessTokenExpires).toISOString() : 'N/A',
240
- actualJwtExp: new Date(actualJwtExpMs).toISOString()
241
- });
242
- }
243
-
244
- // Copy refresh token for use in IDP call
245
- // Note: Token is NOT cleared preemptively - it will be replaced on success.
246
- // The lock mechanism prevents concurrent refresh attempts within the same session.
247
- // If IDP call fails, user can retry with the same token.
248
- const originalRefreshToken = currentSession.idpRefreshToken as string;
249
-
250
- try {
251
- // Extract 2FA claims from current session
252
- let authMethods: string[] = [];
253
- if (currentSession.authenticationMethods) {
254
- if (typeof currentSession.authenticationMethods === 'string') {
255
- try {
256
- authMethods = JSON.parse(currentSession.authenticationMethods);
257
- } catch (e) {
258
- console.warn('[AUTH_REFRESH] Failed to parse authenticationMethods', { authenticationMethods: currentSession.authenticationMethods });
259
- }
260
- } else if (Array.isArray(currentSession.authenticationMethods)) {
261
- authMethods = currentSession.authenticationMethods;
262
- }
263
- }
264
-
265
- // For OAuth sessions with empty AMR claims, provide defaults
266
- // OAuth IS a form of multi-factor auth (you authenticated with another provider)
267
- let isOAuthSession = !!currentSession.oauthProvider;
268
- if (authMethods.length === 0 && isOAuthSession) {
269
- // OAuth sessions get default AMR claims
270
- authMethods = ['pwd', 'mfa'];
271
- console.log('[AUTH_REFRESH] OAuth session with empty AMR - using defaults:', {
272
- oauthProvider: currentSession.oauthProvider,
273
- defaultAmr: authMethods
274
- });
275
- }
276
-
277
- // Check authMethods first, then fallback to twoFactorMethod field (set by transitionTo2FASession)
278
- // For OAuth sessions, use 'oauth' as the 2FA method since OAuth itself is the authentication
279
- const twoFactorMethod = authMethods.find(m => ['sms', 'totp', 'email'].includes(m))
280
- || currentSession.mfaMethod
281
- || (isOAuthSession ? 'oauth' : null);
282
-
283
- // DEBUG: Log what we have for 2FA
284
- console.log('[AUTH_REFRESH] 2FA Debug:', {
285
- authMethods,
286
- authMethodsRaw: currentSession.authenticationMethods,
287
- twoFactorMethodFromSession: currentSession.mfaMethod,
288
- twoFactorMethodResolved: twoFactorMethod,
289
- twoFactorComplete: currentSession.mfaVerified,
290
- sessionKeys: Object.keys(currentSession)
291
- });
292
-
293
- // Build refresh request body per PayEz wire standard (snake_case)
294
- // See: 2FA-TOKEN-REFRESH-CONTEXT.md - "The Ninja Shit"
295
- // For OAuth sessions with 2FA complete, use ACR level 2
296
- let acrValue = String(currentSession.authenticationLevel ?? '1');
297
- if (currentSession.oauthProvider && currentSession.mfaVerified && acrValue === '1') {
298
- acrValue = '2'; // OAuth with 2FA complete = full authentication
299
- }
300
- const refreshRequestBody: any = {
301
- refresh_token: originalRefreshToken,
302
- amr: authMethods,
303
- acr: acrValue
304
- };
305
-
306
- // DEBUG: Log exact request body
307
- console.log('[AUTH_REFRESH] Request body:', JSON.stringify({
308
- refresh_token: '***REDACTED***',
309
- amr: authMethods,
310
- acr: acrValue,
311
- acrType: typeof acrValue
312
- }));
313
-
314
- const twoFactorVerified = !!currentSession.mfaVerified;
315
- if (twoFactorVerified) {
316
- refreshRequestBody.two_factor_verified = true;
317
- }
318
- if (twoFactorMethod) {
319
- refreshRequestBody.two_factor_method = twoFactorMethod;
320
- }
321
- if (currentSession.mfaCompletedAt) {
322
- refreshRequestBody.two_factor_completed_at = new Date(currentSession.mfaCompletedAt).toISOString();
323
- }
324
-
325
- // Log the full request details for debugging
326
- console.log('[AUTH_REFRESH] Sending refresh request:', {
327
- url: `${idpBaseUrl}${refreshEndpoint}`,
328
- clientId,
329
- hasRefreshToken: !!refreshRequestBody.refresh_token,
330
- refreshTokenLength: refreshRequestBody.refresh_token?.length,
331
- amr: refreshRequestBody.amr,
332
- acr: refreshRequestBody.acr
333
- });
334
-
335
- let refreshResponse: Response;
336
- try {
337
- refreshResponse = await fetch(`${idpBaseUrl}${refreshEndpoint}`, {
338
- method: 'POST',
339
- headers: {
340
- 'Content-Type': 'application/json',
341
- 'X-Client-Id': clientId,
342
- },
343
- body: JSON.stringify(refreshRequestBody),
344
- });
345
- } catch (fetchError) {
346
- // Network error - IDP unreachable
347
- // Note: Lock will be released by finally block
348
- console.error('[AUTH_REFRESH] IDP unreachable:', {
349
- error: fetchError instanceof Error ? fetchError.message : String(fetchError),
350
- idpUrl: idpBaseUrl,
351
- userId
352
- });
353
- return NextResponse.json({
354
- error: 'IDP service unavailable',
355
- code: 'UPSTREAM_SERVICE_UNAVAILABLE',
356
- retryable: true,
357
- discardToken: false
358
- }, { status: 503 });
359
- }
360
-
361
- // Parse response body - handle empty or invalid JSON
362
- let responseData: any;
363
- try {
364
- const responseText = await refreshResponse.text();
365
- if (!responseText || responseText.trim() === '') {
366
- // Note: Lock will be released by finally block
367
- console.error('[AUTH_REFRESH] Empty response from IDP:', {
368
- status: refreshResponse.status,
369
- statusText: refreshResponse.statusText,
370
- userId
371
- });
372
- return NextResponse.json({
373
- error: 'Empty response from IDP',
374
- code: 'UPSTREAM_SERVICE_ERROR',
375
- retryable: true,
376
- discardToken: false
377
- }, { status: 502 });
378
- }
379
- responseData = JSON.parse(responseText);
380
- } catch (parseError) {
381
- // Note: Lock will be released by finally block
382
- console.error('[AUTH_REFRESH] Failed to parse IDP response:', {
383
- error: parseError instanceof Error ? parseError.message : String(parseError),
384
- status: refreshResponse.status,
385
- userId
386
- });
387
- return NextResponse.json({
388
- error: 'Invalid response from IDP',
389
- code: 'UPSTREAM_SERVICE_ERROR',
390
- retryable: true,
391
- discardToken: false
392
- }, { status: 502 });
393
- }
394
-
395
- // Handle non-OK responses with structured error format
396
- if (!refreshResponse.ok) {
397
- // Parse structured error from IDP
398
- const idpError = responseData?.error || {};
399
- const errorCode = idpError.code || 'UNKNOWN_ERROR';
400
- const errorMessage = idpError.message || 'Token refresh failed';
401
- // CRITICAL: 401 means token is definitively invalid - always discard
402
- // This prevents redirect loops where viability check keeps returning canRefresh:true
403
- const discardToken = refreshResponse.status === 401 || idpError.discard_token === true;
404
- const retryable = refreshResponse.status !== 401 && idpError.retryable === true;
405
- const resolution = idpError.resolution || null;
406
-
407
- // ============================================================================
408
- // HIGH VISIBILITY: REFRESH TOKEN FAILURE DIAGNOSTICS
409
- // ============================================================================
410
- // Translate error codes to human-readable explanations
411
- const failureReasons: Record<string, string> = {
412
- 'UNAUTHORIZED': 'Token was already used (rotation) OR token is expired OR token was revoked',
413
- 'INVALID_REFRESH_TOKEN': 'Token format invalid OR token not found in IDP database',
414
- 'TOKEN_EXPIRED': 'Refresh token exceeded its max lifetime',
415
- 'TOKEN_REVOKED': 'Token was explicitly revoked (logout, password change, admin action)',
416
- 'TOKEN_REUSE_DETECTED': 'Same refresh token used twice - possible token theft, all tokens revoked',
417
- 'UPSTREAM_SERVICE_ERROR': 'IDP internal error - token may have been rotated before failure',
418
- 'INTERNAL_ERROR': 'IDP internal error - check IDP logs for details',
419
- 'RATE_LIMITED': 'Too many refresh attempts - try again later',
420
- };
421
-
422
- const whyItFailed = failureReasons[errorCode] || 'Unknown error - check IDP logs';
423
-
424
- console.error('╔══════════════════════════════════════════════════════════════════════════════╗');
425
- console.error('║ ❌ REFRESH TOKEN FAILURE - DIAGNOSTIC REPORT ║');
426
- console.error('╠══════════════════════════════════════════════════════════════════════════════╣');
427
- console.error(`║ HTTP Status: ${String(refreshResponse.status).padEnd(60)}║`);
428
- console.error(`║ Error Code: ${errorCode.padEnd(60)}║`);
429
- console.error(`║ Discard Token: ${String(discardToken).padEnd(60)}║`);
430
- console.error(`║ Retryable: ${String(retryable).padEnd(60)}║`);
431
- console.error('╠──────────────────────────────────────────────────────────────────────────────╣');
432
- console.error(`║ WHY IT FAILED: ║`);
433
- console.error(`║ ${whyItFailed.substring(0, 76).padEnd(76)} ║`);
434
- console.error('╠──────────────────────────────────────────────────────────────────────────────╣');
435
- console.error(`║ User: ${(userId || 'unknown').substring(0, 60).padEnd(60)}║`);
436
- console.error(`║ Session: ${sessionToken.substring(0, 8)}... ║`);
437
- console.error(`║ Resolution: ${(resolution || 'Check IDP logs').substring(0, 60).padEnd(60)}║`);
438
- console.error('╚══════════════════════════════════════════════════════════════════════════════╝');
439
-
440
- // Also log the full response for detailed debugging
441
- console.error('[AUTH_REFRESH] Full IDP error response:', JSON.stringify(responseData, null, 2).substring(0, 1000));
442
-
443
- // If IDP signals to discard token, clear it from Redis
444
- if (discardToken) {
445
- console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: IDP signaled discard_token', {
446
- reason: 'IDP_DISCARD_TOKEN',
447
- errorCode,
448
- userId,
449
- sessionToken: sessionToken.substring(0, 8) + '...',
450
- hadRefreshToken: !!currentSession.idpRefreshToken,
451
- stack: new Error().stack
452
- });
453
- await updateSession(sessionToken, {
454
- idpRefreshToken: '',
455
- idpRefreshTokenExpires: undefined,
456
- refreshTokenClearedAt: Date.now(),
457
- refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${errorCode}`
458
- });
459
- }
460
-
461
- return NextResponse.json({
462
- error: errorMessage,
463
- code: errorCode,
464
- discardToken,
465
- retryable,
466
- resolution,
467
- status: refreshResponse.status
468
- }, { status: refreshResponse.status });
469
- }
470
-
471
- // Validate PayEz canonical envelope for success responses
472
- const isCompliant = responseData && typeof responseData === 'object' &&
473
- Object.prototype.hasOwnProperty.call(responseData, 'success') &&
474
- (responseData.success === true ? Object.prototype.hasOwnProperty.call(responseData, 'data') : Object.prototype.hasOwnProperty.call(responseData, 'error')) &&
475
- Object.prototype.hasOwnProperty.call(responseData, 'meta');
476
-
477
- if (!isCompliant) {
478
- console.error('[AUTH_REFRESH] Upstream non-compliant IDP response', { bodySample: responseData });
479
- return NextResponse.json({
480
- error: 'Upstream non-compliance',
481
- code: 'UPSTREAM_SERVICE_ERROR',
482
- retryable: true,
483
- discardToken: false
484
- }, { status: 502 });
485
- }
486
-
487
- if (responseData.success !== true || !responseData.data) {
488
- // Handle success:false with structured error
489
- const idpError = responseData?.error || {};
490
- const errorCode = idpError.code || 'UPSTREAM_VALIDATION_ERROR';
491
- const discardToken = idpError.discard_token === true;
492
- const retryable = idpError.retryable === true;
493
-
494
- console.warn('[AUTH_REFRESH] IDP refresh unsuccessful', {
495
- code: errorCode,
496
- discardToken,
497
- body: responseData
498
- });
499
-
500
- if (discardToken) {
501
- console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: IDP success:false with discard_token', {
502
- reason: 'IDP_SUCCESS_FALSE_DISCARD',
503
- errorCode,
504
- userId,
505
- sessionToken: sessionToken.substring(0, 8) + '...',
506
- hadRefreshToken: !!currentSession.idpRefreshToken,
507
- stack: new Error().stack
508
- });
509
- await updateSession(sessionToken, {
510
- idpRefreshToken: '',
511
- idpRefreshTokenExpires: undefined,
512
- refreshTokenClearedAt: Date.now(),
513
- refreshTokenClearedReason: `IDP_SUCCESS_FALSE_DISCARD:${errorCode}`
514
- });
515
- }
516
-
517
- return NextResponse.json({
518
- error: idpError.message || 'Token refresh failed',
519
- code: errorCode,
520
- discardToken,
521
- retryable,
522
- resolution: idpError.resolution || null
523
- }, { status: refreshResponse.status || 400 });
524
- }
525
-
526
- // Extract tokens from response
527
- const payload = responseData.data as any;
528
- const accessToken = payload?.access_token;
529
- const refreshToken = payload?.refresh_token;
530
-
531
- if (!accessToken) {
532
- console.error('[AUTH_REFRESH] Missing access token in IDP response');
533
- return NextResponse.json({
534
- error: 'Invalid token response from IDP',
535
- code: 'INTERNAL_SERVER_ERROR'
536
- }, { status: 500 });
537
- }
538
-
539
- // Decode and compute token expiries
540
- let decodedAccessToken: any;
541
- let accessTokenExpires: number;
542
- let computedRefreshTokenExpires: number | undefined;
543
- try {
544
- const result = computeTokenExpiries({ accessToken, refreshToken, preferJwt: true });
545
- decodedAccessToken = result.decodedAccessToken;
546
- accessTokenExpires = result.accessTokenExpires;
547
- computedRefreshTokenExpires = result.refreshTokenExpires;
548
-
549
- console.info('[AUTH_REFRESH] Successfully decoded new token pair', {
550
- accessTokenExpires: new Date(accessTokenExpires).toISOString(),
551
- refreshTokenExpires: computedRefreshTokenExpires ? new Date(computedRefreshTokenExpires).toISOString() : null
552
- });
553
- } catch (decodeError) {
554
- console.error('[AUTH_REFRESH] Failed to compute token expiries', { error: decodeError });
555
- return NextResponse.json({
556
- error: 'Failed to decode JWT tokens',
557
- code: 'INTERNAL_SERVER_ERROR'
558
- }, { status: 500 });
559
- }
560
-
561
- // Extract 2FA claims from the new access token
562
- let amrClaims: string[] = [];
563
- if (decodedAccessToken.amr) {
564
- try {
565
- amrClaims = typeof decodedAccessToken.amr === 'string'
566
- ? JSON.parse(decodedAccessToken.amr)
567
- : decodedAccessToken.amr;
568
- } catch (e) {
569
- console.warn('[AUTH_REFRESH] Failed to parse AMR claims', { amr: decodedAccessToken.amr });
570
- amrClaims = currentSession.authenticationMethods || [];
571
- }
572
- } else {
573
- amrClaims = currentSession.authenticationMethods || [];
574
- }
575
- const acrLevel = String(decodedAccessToken.acr || currentSession.authenticationLevel || '1');
576
-
577
- // Extract MFA timing claims
578
- const mfaTime = decodedAccessToken.mfa_time ? parseInt(decodedAccessToken.mfa_time) * 1000 : currentSession.mfaCompletedAt;
579
- const mfaExpires = decodedAccessToken.mfa_expires ? parseInt(decodedAccessToken.mfa_expires) * 1000 : currentSession.mfaExpiresAt;
580
- const mfaValidityHours = decodedAccessToken.mfa_validity_hours ? parseInt(decodedAccessToken.mfa_validity_hours) : currentSession.mfaValidityHours;
581
-
582
- // Update session with new tokens
583
- const hasNewRefresh = typeof refreshToken === 'string' && refreshToken.length > 0;
584
- const newRefreshTokenExpires = hasNewRefresh ? computedRefreshTokenExpires : undefined;
585
-
586
- // CRITICAL: Log if we're about to clear the refresh token due to missing new token
587
- if (!hasNewRefresh && currentSession.idpRefreshToken) {
588
- console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: No new refresh token in IDP response', {
589
- reason: 'NO_NEW_REFRESH_TOKEN_IN_RESPONSE',
590
- userId,
591
- sessionToken: sessionToken.substring(0, 8) + '...',
592
- hadRefreshToken: true,
593
- refreshTokenFromResponse: refreshToken,
594
- refreshTokenType: typeof refreshToken,
595
- payloadKeys: Object.keys(payload || {}),
596
- stack: new Error().stack
597
- });
598
- }
599
-
600
- // Keep existing refresh token if IDP doesn't provide a new one
601
- // Only clear if IDP explicitly signals discard_token=true (handled in error paths)
602
- // NOTE: Use normalized field names (idp* prefix) per SessionData interface
603
-
604
- // CRITICAL: Extract kid from JWT header - IDP may rotate keys during refresh
605
- const newBearerKeyId = extractKidFromToken(accessToken);
606
- if (newBearerKeyId) {
607
- console.log('[AUTH_REFRESH] Extracted bearerKeyId (kid) from new JWT header:', newBearerKeyId);
608
- } else {
609
- console.warn('[AUTH_REFRESH] No kid found in new JWT header');
610
- }
611
-
612
- const sessionUpdate: any = {
613
- ...currentSession,
614
- idpAccessToken: accessToken,
615
- idpAccessTokenExpires: accessTokenExpires,
616
- idpRefreshToken: hasNewRefresh ? (refreshToken as string) : currentSession.idpRefreshToken,
617
- idpRefreshTokenExpires: hasNewRefresh ? newRefreshTokenExpires : currentSession.idpRefreshTokenExpires,
618
- decodedAccessToken: decodedAccessToken,
619
- // Bearer key ID from JWT header (may change on key rotation)
620
- bearerKeyId: newBearerKeyId || currentSession.bearerKeyId,
621
- authenticationMethods: amrClaims,
622
- authenticationLevel: acrLevel,
623
- mfaVerified: amrClaims.includes('mfa') || currentSession.mfaVerified,
624
- mfaCompletedAt: mfaTime,
625
- mfaExpiresAt: mfaExpires,
626
- mfaValidityHours: mfaValidityHours
627
- };
628
-
629
- await updateSession(sessionToken, sessionUpdate);
630
-
631
- console.info('[AUTH_REFRESH] Token refreshed successfully', {
632
- expiresAt: new Date(accessTokenExpires).toISOString(),
633
- userId,
634
- hasNewRefreshToken: hasNewRefresh,
635
- tokenPreview: accessToken ? accessToken.substring(0, 20) + '...' : 'none'
636
- });
637
-
638
- } catch (error) {
639
- console.error('[AUTH_REFRESH] Error during token refresh', { error });
640
- return NextResponse.json({
641
- error: 'Token refresh error',
642
- code: 'INTERNAL_SERVER_ERROR'
643
- }, { status: 500 });
644
- } finally {
645
- if (weAcquiredLock) {
646
- await releaseRefreshLock(sessionToken, requestId, releaseLockVersion);
647
- }
648
- }
649
-
650
- // Load the latest session to return basic status
651
- const latest = await getSession(sessionToken);
652
- if (!latest?.idpAccessToken) {
653
- return NextResponse.json({
654
- error: 'No access token after refresh',
655
- code: 'INTERNAL_SERVER_ERROR'
656
- }, { status: 500 });
657
- }
658
-
659
- return NextResponse.json({
660
- refreshed: true,
661
- accessTokenExpires: latest.idpAccessTokenExpires,
662
- hasRefreshToken: !!latest.idpRefreshToken,
663
- });
664
- } catch (err: any) {
665
- console.error('[AUTH_REFRESH] Unexpected error', { error: err?.message || String(err) });
666
- return NextResponse.json({
667
- error: 'Unexpected error during refresh',
668
- code: 'INTERNAL_SERVER_ERROR'
669
- }, { status: 500 });
670
- }
671
- };
672
- }
673
-
674
- /**
675
- * Default POST export — drop-in for `app/api/auth/refresh/route.ts`.
676
- * Requires environment variables: IDP_URL, CLIENT_ID
677
- */
678
- export const POST = createRefreshHandler({
679
- idpBaseUrl: process.env.IDP_URL!,
680
- clientId: process.env.CLIENT_ID || 'payez_default_client',
681
- refreshEndpoint: '/api/ExternalAuth/refresh'
682
- });
1
+ /**
2
+ * CRITICAL REFRESH TOKEN API HANDLER
3
+ *
4
+ * ASK BEFORE EDITING - TESTED AND WORKING SYSTEM
5
+ *
6
+ * This handler manages the server-side refresh token cycle with:
7
+ * - Better Auth session extraction
8
+ * - Session token fallback for internal calls
9
+ * - PayEz IDP refresh token exchange
10
+ * - Session state updates with new tokens
11
+ * - Proper error handling and logging
12
+ * - Single-use semantics enforcement
13
+ *
14
+ * @version 2.0
15
+ */
16
+
17
+ import { NextRequest, NextResponse } from 'next/server';
18
+ import { getSession as getBetterAuthSession } from '../../server/auth';
19
+ import { getSession, updateSession, acquireRefreshLock, releaseRefreshLock, checkRefreshLock } from '../../lib/session-store';
20
+ import { computeTokenExpiries } from '../../lib/token-expiry';
21
+ import { extractKidFromToken } from '../../auth/utils/token-utils';
22
+
23
+ interface RefreshConfig {
24
+ idpBaseUrl: string;
25
+ clientId: string;
26
+ refreshEndpoint?: string;
27
+ }
28
+
29
+ /**
30
+ * Creates a refresh token handler for Next.js API routes
31
+ *
32
+ * @param config IDP connection settings (Better Auth handles session crypto)
33
+ * @returns Next.js POST handler function
34
+ *
35
+ * @example
36
+ * ```typescript
37
+ * // In your app's /app/api/auth/refresh/route.ts
38
+ * import { createRefreshHandler } from '@payez/next-mvp/api-handlers/auth/refresh';
39
+ *
40
+ * export const POST = createRefreshHandler({
41
+ * idpBaseUrl: process.env.IDP_URL!,
42
+ * clientId: process.env.CLIENT_ID!,
43
+ * refreshEndpoint: '/api/ExternalAuth/refresh'
44
+ * });
45
+ * ```
46
+ */
47
+ export function createRefreshHandler(config: RefreshConfig) {
48
+ const { idpBaseUrl, clientId, refreshEndpoint = '/api/ExternalAuth/refresh' } = config;
49
+
50
+ return async function POST(req: NextRequest) {
51
+ try {
52
+ // Extract session from Better Auth
53
+ const betterAuthSession = await getBetterAuthSession(req);
54
+
55
+ let sessionToken = (betterAuthSession?.session?.token) as string | undefined;
56
+ let userId = betterAuthSession?.user?.id;
57
+
58
+ if (!sessionToken) {
59
+ // Fallback: check for session token in header (for internal server-to-server calls)
60
+ const headerSessionToken = req.headers.get('x-session-token');
61
+ if (headerSessionToken) {
62
+ sessionToken = headerSessionToken;
63
+ const currentSession = await getSession(sessionToken);
64
+ userId = currentSession?.userId || currentSession?.email;
65
+ }
66
+ }
67
+
68
+ if (!sessionToken || !userId) {
69
+ console.warn('[AUTH_REFRESH] Missing sessionToken or user id on token');
70
+ return NextResponse.json({
71
+ error: 'No session available for refresh',
72
+ code: 'UNAUTHORIZED'
73
+ }, { status: 401 });
74
+ }
75
+
76
+ // Get current session
77
+ const currentSession = await getSession(sessionToken);
78
+ // NOTE: Field is idpRefreshToken (not refreshToken) per normalized naming convention
79
+ if (!currentSession?.idpRefreshToken) {
80
+ console.warn('[AUTH_REFRESH] No refresh token available', { userId });
81
+ return NextResponse.json({
82
+ error: 'No refresh token available',
83
+ code: 'NO_REFRESH_TOKEN', // Terminal state - session cannot be refreshed
84
+ terminal: true, // Signal to frontend: don't retry, redirect to login
85
+ resolution: 'User must re-authenticate'
86
+ }, { status: 401 });
87
+ }
88
+
89
+ // ============================================================================
90
+ // HIGH VISIBILITY: PRE-FLIGHT REFRESH DIAGNOSTICS
91
+ // ============================================================================
92
+ const now = Date.now();
93
+ const refreshTokenAge = currentSession.idpRefreshTokenIssuedAt
94
+ ? Math.round((now - currentSession.idpRefreshTokenIssuedAt) / 1000)
95
+ : 'unknown';
96
+ const refreshTokenExpiresIn = currentSession.idpRefreshTokenExpires
97
+ ? Math.round((currentSession.idpRefreshTokenExpires - now) / 1000)
98
+ : 'unknown';
99
+ const accessTokenExpiresIn = currentSession.idpAccessTokenExpires
100
+ ? Math.round((currentSession.idpAccessTokenExpires - now) / 1000)
101
+ : 'unknown';
102
+
103
+ console.log('╔══════════════════════════════════════════════════════════════════════════════╗');
104
+ console.log('║ REFRESH TOKEN ATTEMPT - PRE-FLIGHT ║');
105
+ console.log('╠══════════════════════════════════════════════════════════════════════════════╣');
106
+ console.log(`║ User: ${(userId || 'unknown').substring(0, 50).padEnd(50)} ║`);
107
+ console.log(`║ Session: ${sessionToken.substring(0, 8)}... ║`);
108
+ console.log('╠──────────────────────────────────────────────────────────────────────────────╣');
109
+ console.log(`║ Access Token Expires In: ${String(accessTokenExpiresIn).padEnd(10)} seconds ║`);
110
+ console.log(`║ Refresh Token Age: ${String(refreshTokenAge).padEnd(10)} seconds ║`);
111
+ console.log(`║ Refresh Token Expires In: ${String(refreshTokenExpiresIn).padEnd(10)} seconds ║`);
112
+ console.log(`║ Refresh Token Length: ${String(currentSession.idpRefreshToken?.length || 0).padEnd(10)} chars ║`);
113
+ console.log(`║ 2FA Complete: ${String(!!currentSession.mfaVerified).padEnd(10)} ║`);
114
+ console.log(`║ Auth Level (ACR): ${String(currentSession.authenticationLevel || '1').padEnd(10)} ║`);
115
+ console.log('╚══════════════════════════════════════════════════════════════════════════════╝');
116
+
117
+ // Try to acquire refresh lock to prevent concurrent refresh attempts
118
+ const requestId = req.headers.get('x-request-id') ?? `refresh_${Date.now()}`;
119
+ const lockAcquired = await acquireRefreshLock(sessionToken, requestId, 5000);
120
+
121
+ let weAcquiredLock = false;
122
+ let releaseLockVersion: number | undefined;
123
+
124
+ if (!lockAcquired.acquired) {
125
+ const existingLock = await checkRefreshLock(sessionToken);
126
+ if (existingLock && existingLock.acquiredBy === requestId) {
127
+ console.info('[AUTH_REFRESH] Proceeding under existing caller-held lock', { requestId });
128
+ } else {
129
+ // Wait for the lock to release, then check if token is now fresh
130
+ console.info('[AUTH_REFRESH] Refresh in progress by another request, waiting for completion...', { requestId });
131
+
132
+ const maxWaitMs = 5000;
133
+ const checkIntervalMs = 200;
134
+ const startWait = Date.now();
135
+
136
+ while (Date.now() - startWait < maxWaitMs) {
137
+ await new Promise(resolve => setTimeout(resolve, checkIntervalMs));
138
+
139
+ // Check if lock was released
140
+ const lockCheck = await checkRefreshLock(sessionToken);
141
+ if (!lockCheck) {
142
+ // Lock released - check if token is now fresh
143
+ const refreshedSession = await getSession(sessionToken);
144
+ if (refreshedSession?.idpAccessToken && refreshedSession?.idpAccessTokenExpires) {
145
+ const timeUntilExpiry = refreshedSession.idpAccessTokenExpires - Date.now();
146
+ const fiveMinutes = 5 * 60 * 1000;
147
+
148
+ if (timeUntilExpiry > fiveMinutes) {
149
+ console.info('[AUTH_REFRESH] Lock released, token is fresh - returning success', {
150
+ requestId,
151
+ accessTokenExpires: new Date(refreshedSession.idpAccessTokenExpires).toISOString(),
152
+ waitedMs: Date.now() - startWait,
153
+ });
154
+ return NextResponse.json({
155
+ refreshed: true,
156
+ reason: 'completed_by_concurrent_request',
157
+ accessTokenExpires: refreshedSession.idpAccessTokenExpires,
158
+ hasRefreshToken: !!refreshedSession.idpRefreshToken,
159
+ });
160
+ }
161
+ }
162
+ // Lock released but token not fresh - try to acquire lock ourselves
163
+ break;
164
+ }
165
+ }
166
+
167
+ // Still locked after waiting - return 409
168
+ console.warn('[AUTH_REFRESH] Refresh still in progress after waiting', { requestId, waitedMs: Date.now() - startWait });
169
+ return NextResponse.json({
170
+ error: 'Refresh already in progress',
171
+ code: 'CONFLICT'
172
+ }, { status: 409 });
173
+ }
174
+ } else {
175
+ weAcquiredLock = true;
176
+ releaseLockVersion = lockAcquired.lockInfo?.lockVersion;
177
+ }
178
+
179
+ // Before performing network refresh, re-check if tokens are already fresh
180
+ const latestAfterLock = await getSession(sessionToken);
181
+ const fiveMinutes = 5 * 60 * 1000;
182
+ const timeUntilExpiry = latestAfterLock?.idpAccessTokenExpires
183
+ ? latestAfterLock.idpAccessTokenExpires - Date.now()
184
+ : -1;
185
+
186
+ // CRITICAL: Also check the actual JWT's exp claim - Redis might have stale data
187
+ let actualJwtExpMs = -1;
188
+ let tokenMismatch = false;
189
+ if (latestAfterLock?.idpAccessToken) {
190
+ try {
191
+ const tokenParts = latestAfterLock.idpAccessToken.split('.');
192
+ if (tokenParts.length === 3) {
193
+ const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64url').toString());
194
+ actualJwtExpMs = (payload.exp || 0) * 1000;
195
+ const now = Date.now();
196
+ // If the actual JWT is expired, we MUST refresh regardless of what Redis says
197
+ if (actualJwtExpMs < now) {
198
+ tokenMismatch = true;
199
+ }
200
+ }
201
+ } catch (e) {
202
+ // If we can't decode, proceed with normal logic
203
+ }
204
+ }
205
+
206
+ console.log('[AUTH_REFRESH] Pre-refresh check:', {
207
+ userId,
208
+ hasAccessToken: !!latestAfterLock?.idpAccessToken,
209
+ accessTokenExpires: latestAfterLock?.idpAccessTokenExpires
210
+ ? new Date(latestAfterLock.idpAccessTokenExpires).toISOString()
211
+ : 'undefined',
212
+ actualJwtExp: actualJwtExpMs > 0 ? new Date(actualJwtExpMs).toISOString() : 'unknown',
213
+ now: new Date().toISOString(),
214
+ timeUntilExpiryMs: timeUntilExpiry,
215
+ tokenMismatch,
216
+ willRefresh: timeUntilExpiry <= fiveMinutes || tokenMismatch
217
+ });
218
+
219
+ // Only skip refresh if BOTH Redis says fresh AND the actual JWT is not expired
220
+ if (latestAfterLock?.idpAccessToken && latestAfterLock?.idpAccessTokenExpires &&
221
+ timeUntilExpiry > fiveMinutes && !tokenMismatch) {
222
+ console.info('[AUTH_REFRESH] Skipping refresh: tokens already fresh', { userId, timeUntilExpiryMs: timeUntilExpiry });
223
+ if (weAcquiredLock) {
224
+ await releaseRefreshLock(sessionToken, requestId, releaseLockVersion);
225
+ weAcquiredLock = false;
226
+ }
227
+ return NextResponse.json({
228
+ refreshed: false,
229
+ reason: 'already_fresh',
230
+ accessTokenExpires: latestAfterLock.idpAccessTokenExpires,
231
+ hasRefreshToken: !!latestAfterLock.idpRefreshToken,
232
+ });
233
+ }
234
+
235
+ // If we detected a token mismatch, log it prominently
236
+ if (tokenMismatch) {
237
+ console.warn('[AUTH_REFRESH] Token mismatch detected - forcing refresh despite Redis claiming fresh', {
238
+ userId,
239
+ redisExpires: latestAfterLock?.idpAccessTokenExpires ? new Date(latestAfterLock.idpAccessTokenExpires).toISOString() : 'N/A',
240
+ actualJwtExp: new Date(actualJwtExpMs).toISOString()
241
+ });
242
+ }
243
+
244
+ // Copy refresh token for use in IDP call
245
+ // Note: Token is NOT cleared preemptively - it will be replaced on success.
246
+ // The lock mechanism prevents concurrent refresh attempts within the same session.
247
+ // If IDP call fails, user can retry with the same token.
248
+ const originalRefreshToken = currentSession.idpRefreshToken as string;
249
+
250
+ try {
251
+ // Extract 2FA claims from current session
252
+ let authMethods: string[] = [];
253
+ if (currentSession.authenticationMethods) {
254
+ if (typeof currentSession.authenticationMethods === 'string') {
255
+ try {
256
+ authMethods = JSON.parse(currentSession.authenticationMethods);
257
+ } catch (e) {
258
+ console.warn('[AUTH_REFRESH] Failed to parse authenticationMethods', { authenticationMethods: currentSession.authenticationMethods });
259
+ }
260
+ } else if (Array.isArray(currentSession.authenticationMethods)) {
261
+ authMethods = currentSession.authenticationMethods;
262
+ }
263
+ }
264
+
265
+ // For OAuth sessions with empty AMR claims, provide defaults
266
+ // OAuth IS a form of multi-factor auth (you authenticated with another provider)
267
+ let isOAuthSession = !!currentSession.oauthProvider;
268
+ if (authMethods.length === 0 && isOAuthSession) {
269
+ // OAuth sessions get default AMR claims
270
+ authMethods = ['pwd', 'mfa'];
271
+ console.log('[AUTH_REFRESH] OAuth session with empty AMR - using defaults:', {
272
+ oauthProvider: currentSession.oauthProvider,
273
+ defaultAmr: authMethods
274
+ });
275
+ }
276
+
277
+ // Check authMethods first, then fallback to twoFactorMethod field (set by transitionTo2FASession)
278
+ // For OAuth sessions, use 'oauth' as the 2FA method since OAuth itself is the authentication
279
+ const twoFactorMethod = authMethods.find(m => ['sms', 'totp', 'email'].includes(m))
280
+ || currentSession.mfaMethod
281
+ || (isOAuthSession ? 'oauth' : null);
282
+
283
+ // DEBUG: Log what we have for 2FA
284
+ console.log('[AUTH_REFRESH] 2FA Debug:', {
285
+ authMethods,
286
+ authMethodsRaw: currentSession.authenticationMethods,
287
+ twoFactorMethodFromSession: currentSession.mfaMethod,
288
+ twoFactorMethodResolved: twoFactorMethod,
289
+ twoFactorComplete: currentSession.mfaVerified,
290
+ sessionKeys: Object.keys(currentSession)
291
+ });
292
+
293
+ // Build refresh request body per PayEz wire standard (snake_case)
294
+ // See: 2FA-TOKEN-REFRESH-CONTEXT.md - "The Ninja Shit"
295
+ // For OAuth sessions with 2FA complete, use ACR level 2
296
+ let acrValue = String(currentSession.authenticationLevel ?? '1');
297
+ if (currentSession.oauthProvider && currentSession.mfaVerified && acrValue === '1') {
298
+ acrValue = '2'; // OAuth with 2FA complete = full authentication
299
+ }
300
+ const refreshRequestBody: any = {
301
+ refresh_token: originalRefreshToken,
302
+ amr: authMethods,
303
+ acr: acrValue
304
+ };
305
+
306
+ // DEBUG: Log exact request body
307
+ console.log('[AUTH_REFRESH] Request body:', JSON.stringify({
308
+ refresh_token: '***REDACTED***',
309
+ amr: authMethods,
310
+ acr: acrValue,
311
+ acrType: typeof acrValue
312
+ }));
313
+
314
+ const twoFactorVerified = !!currentSession.mfaVerified;
315
+ if (twoFactorVerified) {
316
+ refreshRequestBody.two_factor_verified = true;
317
+ }
318
+ if (twoFactorMethod) {
319
+ refreshRequestBody.two_factor_method = twoFactorMethod;
320
+ }
321
+ if (currentSession.mfaCompletedAt) {
322
+ refreshRequestBody.two_factor_completed_at = new Date(currentSession.mfaCompletedAt).toISOString();
323
+ }
324
+
325
+ // Log the full request details for debugging
326
+ console.log('[AUTH_REFRESH] Sending refresh request:', {
327
+ url: `${idpBaseUrl}${refreshEndpoint}`,
328
+ clientId,
329
+ hasRefreshToken: !!refreshRequestBody.refresh_token,
330
+ refreshTokenLength: refreshRequestBody.refresh_token?.length,
331
+ amr: refreshRequestBody.amr,
332
+ acr: refreshRequestBody.acr
333
+ });
334
+
335
+ let refreshResponse: Response;
336
+ try {
337
+ refreshResponse = await fetch(`${idpBaseUrl}${refreshEndpoint}`, {
338
+ method: 'POST',
339
+ headers: {
340
+ 'Content-Type': 'application/json',
341
+ 'X-Client-Id': clientId,
342
+ },
343
+ body: JSON.stringify(refreshRequestBody),
344
+ });
345
+ } catch (fetchError) {
346
+ // Network error - IDP unreachable
347
+ // Note: Lock will be released by finally block
348
+ console.error('[AUTH_REFRESH] IDP unreachable:', {
349
+ error: fetchError instanceof Error ? fetchError.message : String(fetchError),
350
+ idpUrl: idpBaseUrl,
351
+ userId
352
+ });
353
+ return NextResponse.json({
354
+ error: 'IDP service unavailable',
355
+ code: 'UPSTREAM_SERVICE_UNAVAILABLE',
356
+ retryable: true,
357
+ discardToken: false
358
+ }, { status: 503 });
359
+ }
360
+
361
+ // Parse response body - handle empty or invalid JSON
362
+ let responseData: any;
363
+ try {
364
+ const responseText = await refreshResponse.text();
365
+ if (!responseText || responseText.trim() === '') {
366
+ // Note: Lock will be released by finally block
367
+ console.error('[AUTH_REFRESH] Empty response from IDP:', {
368
+ status: refreshResponse.status,
369
+ statusText: refreshResponse.statusText,
370
+ userId
371
+ });
372
+ return NextResponse.json({
373
+ error: 'Empty response from IDP',
374
+ code: 'UPSTREAM_SERVICE_ERROR',
375
+ retryable: true,
376
+ discardToken: false
377
+ }, { status: 502 });
378
+ }
379
+ responseData = JSON.parse(responseText);
380
+ } catch (parseError) {
381
+ // Note: Lock will be released by finally block
382
+ console.error('[AUTH_REFRESH] Failed to parse IDP response:', {
383
+ error: parseError instanceof Error ? parseError.message : String(parseError),
384
+ status: refreshResponse.status,
385
+ userId
386
+ });
387
+ return NextResponse.json({
388
+ error: 'Invalid response from IDP',
389
+ code: 'UPSTREAM_SERVICE_ERROR',
390
+ retryable: true,
391
+ discardToken: false
392
+ }, { status: 502 });
393
+ }
394
+
395
+ // Handle non-OK responses with structured error format
396
+ if (!refreshResponse.ok) {
397
+ // Parse structured error from IDP
398
+ const idpError = responseData?.error || {};
399
+ const errorCode = idpError.code || 'UNKNOWN_ERROR';
400
+ const errorMessage = idpError.message || 'Token refresh failed';
401
+ // CRITICAL: 401 means token is definitively invalid - always discard
402
+ // This prevents redirect loops where viability check keeps returning canRefresh:true
403
+ let discardToken = refreshResponse.status === 401 || idpError.discard_token === true;
404
+ const retryable = refreshResponse.status !== 401 && idpError.retryable === true;
405
+ const resolution = idpError.resolution || null;
406
+
407
+ // ============================================================================
408
+ // HIGH VISIBILITY: REFRESH TOKEN FAILURE DIAGNOSTICS
409
+ // ============================================================================
410
+ // Translate error codes to human-readable explanations
411
+ const failureReasons: Record<string, string> = {
412
+ 'UNAUTHORIZED': 'Token was already used (rotation) OR token is expired OR token was revoked',
413
+ 'INVALID_REFRESH_TOKEN': 'Token format invalid OR token not found in IDP database',
414
+ 'TOKEN_EXPIRED': 'Refresh token exceeded its max lifetime',
415
+ 'TOKEN_REVOKED': 'Token was explicitly revoked (logout, password change, admin action)',
416
+ 'TOKEN_REUSE_DETECTED': 'Same refresh token used twice - possible token theft, all tokens revoked',
417
+ 'UPSTREAM_SERVICE_ERROR': 'IDP internal error - token may have been rotated before failure',
418
+ 'INTERNAL_ERROR': 'IDP internal error - check IDP logs for details',
419
+ 'RATE_LIMITED': 'Too many refresh attempts - try again later',
420
+ };
421
+
422
+ const whyItFailed = failureReasons[errorCode] || 'Unknown error - check IDP logs';
423
+
424
+ console.error('╔══════════════════════════════════════════════════════════════════════════════╗');
425
+ console.error('║ ❌ REFRESH TOKEN FAILURE - DIAGNOSTIC REPORT ║');
426
+ console.error('╠══════════════════════════════════════════════════════════════════════════════╣');
427
+ console.error(`║ HTTP Status: ${String(refreshResponse.status).padEnd(60)}║`);
428
+ console.error(`║ Error Code: ${errorCode.padEnd(60)}║`);
429
+ console.error(`║ Discard Token: ${String(discardToken).padEnd(60)}║`);
430
+ console.error(`║ Retryable: ${String(retryable).padEnd(60)}║`);
431
+ console.error('╠──────────────────────────────────────────────────────────────────────────────╣');
432
+ console.error(`║ WHY IT FAILED: ║`);
433
+ console.error(`║ ${whyItFailed.substring(0, 76).padEnd(76)} ║`);
434
+ console.error('╠──────────────────────────────────────────────────────────────────────────────╣');
435
+ console.error(`║ User: ${(userId || 'unknown').substring(0, 60).padEnd(60)}║`);
436
+ console.error(`║ Session: ${sessionToken.substring(0, 8)}... ║`);
437
+ console.error(`║ Resolution: ${(resolution || 'Check IDP logs').substring(0, 60).padEnd(60)}║`);
438
+ console.error('╚══════════════════════════════════════════════════════════════════════════════╝');
439
+
440
+ // Also log the full response for detailed debugging
441
+ console.error('[AUTH_REFRESH] Full IDP error response:', JSON.stringify(responseData, null, 2).substring(0, 1000));
442
+
443
+ // If IDP signals to discard token, clear it from Redis
444
+ if (discardToken) {
445
+ // Race-safe discard (CAS): only clear if the refresh token we sent is STILL
446
+ // the current one. A concurrent refresh may have already rotated it — our 401
447
+ // is then stale, and wiping here would silently log out a healthy session
448
+ // (the rotation-race wipe, QA 6873 site 2). Skip the wipe + drop terminal if changed.
449
+ const postRefresh = await getSession(sessionToken);
450
+ const tokenUnchanged =
451
+ !!originalRefreshToken && postRefresh?.idpRefreshToken === originalRefreshToken;
452
+ if (tokenUnchanged) {
453
+ console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: IDP signaled discard_token', {
454
+ reason: 'IDP_DISCARD_TOKEN',
455
+ errorCode,
456
+ userId,
457
+ sessionToken: sessionToken.substring(0, 8) + '...',
458
+ hadRefreshToken: !!currentSession.idpRefreshToken,
459
+ stack: new Error().stack
460
+ });
461
+ await updateSession(sessionToken, {
462
+ idpRefreshToken: '',
463
+ idpRefreshTokenExpires: undefined,
464
+ refreshTokenClearedAt: Date.now(),
465
+ refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${errorCode}`
466
+ });
467
+ } else {
468
+ // Concurrent refresh already rotated the token — our 401 is stale. Return the
469
+ // freshly-rotated token directly so the loser succeeds without a retry
470
+ // (race-fix follow-up #1).
471
+ discardToken = false;
472
+ console.warn('[AUTH_REFRESH] Discard suppressed: refresh token already rotated by a concurrent refresh — returning the fresh token (race-safe).');
473
+ if (postRefresh?.idpAccessToken && postRefresh.idpAccessTokenExpires) {
474
+ return NextResponse.json({
475
+ refreshed: true,
476
+ reason: 'completed_by_concurrent_request',
477
+ accessTokenExpires: postRefresh.idpAccessTokenExpires,
478
+ hasRefreshToken: !!postRefresh.idpRefreshToken,
479
+ });
480
+ }
481
+ }
482
+ }
483
+
484
+ return NextResponse.json({
485
+ error: errorMessage,
486
+ code: errorCode,
487
+ discardToken,
488
+ retryable,
489
+ resolution,
490
+ status: refreshResponse.status
491
+ }, { status: refreshResponse.status });
492
+ }
493
+
494
+ // Validate PayEz canonical envelope for success responses
495
+ const isCompliant = responseData && typeof responseData === 'object' &&
496
+ Object.prototype.hasOwnProperty.call(responseData, 'success') &&
497
+ (responseData.success === true ? Object.prototype.hasOwnProperty.call(responseData, 'data') : Object.prototype.hasOwnProperty.call(responseData, 'error')) &&
498
+ Object.prototype.hasOwnProperty.call(responseData, 'meta');
499
+
500
+ if (!isCompliant) {
501
+ console.error('[AUTH_REFRESH] Upstream non-compliant IDP response', { bodySample: responseData });
502
+ return NextResponse.json({
503
+ error: 'Upstream non-compliance',
504
+ code: 'UPSTREAM_SERVICE_ERROR',
505
+ retryable: true,
506
+ discardToken: false
507
+ }, { status: 502 });
508
+ }
509
+
510
+ if (responseData.success !== true || !responseData.data) {
511
+ // Handle success:false with structured error
512
+ const idpError = responseData?.error || {};
513
+ const errorCode = idpError.code || 'UPSTREAM_VALIDATION_ERROR';
514
+ const discardToken = idpError.discard_token === true;
515
+ const retryable = idpError.retryable === true;
516
+
517
+ console.warn('[AUTH_REFRESH] IDP refresh unsuccessful', {
518
+ code: errorCode,
519
+ discardToken,
520
+ body: responseData
521
+ });
522
+
523
+ if (discardToken) {
524
+ console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: IDP success:false with discard_token', {
525
+ reason: 'IDP_SUCCESS_FALSE_DISCARD',
526
+ errorCode,
527
+ userId,
528
+ sessionToken: sessionToken.substring(0, 8) + '...',
529
+ hadRefreshToken: !!currentSession.idpRefreshToken,
530
+ stack: new Error().stack
531
+ });
532
+ await updateSession(sessionToken, {
533
+ idpRefreshToken: '',
534
+ idpRefreshTokenExpires: undefined,
535
+ refreshTokenClearedAt: Date.now(),
536
+ refreshTokenClearedReason: `IDP_SUCCESS_FALSE_DISCARD:${errorCode}`
537
+ });
538
+ }
539
+
540
+ return NextResponse.json({
541
+ error: idpError.message || 'Token refresh failed',
542
+ code: errorCode,
543
+ discardToken,
544
+ retryable,
545
+ resolution: idpError.resolution || null
546
+ }, { status: refreshResponse.status || 400 });
547
+ }
548
+
549
+ // Extract tokens from response
550
+ const payload = responseData.data as any;
551
+ const accessToken = payload?.access_token;
552
+ const refreshToken = payload?.refresh_token;
553
+
554
+ if (!accessToken) {
555
+ console.error('[AUTH_REFRESH] Missing access token in IDP response');
556
+ return NextResponse.json({
557
+ error: 'Invalid token response from IDP',
558
+ code: 'INTERNAL_SERVER_ERROR'
559
+ }, { status: 500 });
560
+ }
561
+
562
+ // Decode and compute token expiries
563
+ let decodedAccessToken: any;
564
+ let accessTokenExpires: number;
565
+ let computedRefreshTokenExpires: number | undefined;
566
+ try {
567
+ const result = computeTokenExpiries({ accessToken, refreshToken, preferJwt: true });
568
+ decodedAccessToken = result.decodedAccessToken;
569
+ accessTokenExpires = result.accessTokenExpires;
570
+ computedRefreshTokenExpires = result.refreshTokenExpires;
571
+
572
+ console.info('[AUTH_REFRESH] Successfully decoded new token pair', {
573
+ accessTokenExpires: new Date(accessTokenExpires).toISOString(),
574
+ refreshTokenExpires: computedRefreshTokenExpires ? new Date(computedRefreshTokenExpires).toISOString() : null
575
+ });
576
+ } catch (decodeError) {
577
+ console.error('[AUTH_REFRESH] Failed to compute token expiries', { error: decodeError });
578
+ return NextResponse.json({
579
+ error: 'Failed to decode JWT tokens',
580
+ code: 'INTERNAL_SERVER_ERROR'
581
+ }, { status: 500 });
582
+ }
583
+
584
+ // Extract 2FA claims from the new access token
585
+ let amrClaims: string[] = [];
586
+ if (decodedAccessToken.amr) {
587
+ try {
588
+ amrClaims = typeof decodedAccessToken.amr === 'string'
589
+ ? JSON.parse(decodedAccessToken.amr)
590
+ : decodedAccessToken.amr;
591
+ } catch (e) {
592
+ console.warn('[AUTH_REFRESH] Failed to parse AMR claims', { amr: decodedAccessToken.amr });
593
+ amrClaims = currentSession.authenticationMethods || [];
594
+ }
595
+ } else {
596
+ amrClaims = currentSession.authenticationMethods || [];
597
+ }
598
+ const acrLevel = String(decodedAccessToken.acr || currentSession.authenticationLevel || '1');
599
+
600
+ // Extract MFA timing claims
601
+ const mfaTime = decodedAccessToken.mfa_time ? parseInt(decodedAccessToken.mfa_time) * 1000 : currentSession.mfaCompletedAt;
602
+ const mfaExpires = decodedAccessToken.mfa_expires ? parseInt(decodedAccessToken.mfa_expires) * 1000 : currentSession.mfaExpiresAt;
603
+ const mfaValidityHours = decodedAccessToken.mfa_validity_hours ? parseInt(decodedAccessToken.mfa_validity_hours) : currentSession.mfaValidityHours;
604
+
605
+ // Update session with new tokens
606
+ const hasNewRefresh = typeof refreshToken === 'string' && refreshToken.length > 0;
607
+ const newRefreshTokenExpires = hasNewRefresh ? computedRefreshTokenExpires : undefined;
608
+
609
+ // CRITICAL: Log if we're about to clear the refresh token due to missing new token
610
+ if (!hasNewRefresh && currentSession.idpRefreshToken) {
611
+ console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: No new refresh token in IDP response', {
612
+ reason: 'NO_NEW_REFRESH_TOKEN_IN_RESPONSE',
613
+ userId,
614
+ sessionToken: sessionToken.substring(0, 8) + '...',
615
+ hadRefreshToken: true,
616
+ refreshTokenFromResponse: refreshToken,
617
+ refreshTokenType: typeof refreshToken,
618
+ payloadKeys: Object.keys(payload || {}),
619
+ stack: new Error().stack
620
+ });
621
+ }
622
+
623
+ // Keep existing refresh token if IDP doesn't provide a new one
624
+ // Only clear if IDP explicitly signals discard_token=true (handled in error paths)
625
+ // NOTE: Use normalized field names (idp* prefix) per SessionData interface
626
+
627
+ // CRITICAL: Extract kid from JWT header - IDP may rotate keys during refresh
628
+ const newBearerKeyId = extractKidFromToken(accessToken);
629
+ if (newBearerKeyId) {
630
+ console.log('[AUTH_REFRESH] Extracted bearerKeyId (kid) from new JWT header:', newBearerKeyId);
631
+ } else {
632
+ console.warn('[AUTH_REFRESH] No kid found in new JWT header');
633
+ }
634
+
635
+ const sessionUpdate: any = {
636
+ ...currentSession,
637
+ idpAccessToken: accessToken,
638
+ idpAccessTokenExpires: accessTokenExpires,
639
+ idpRefreshToken: hasNewRefresh ? (refreshToken as string) : currentSession.idpRefreshToken,
640
+ idpRefreshTokenExpires: hasNewRefresh ? newRefreshTokenExpires : currentSession.idpRefreshTokenExpires,
641
+ decodedAccessToken: decodedAccessToken,
642
+ // Bearer key ID from JWT header (may change on key rotation)
643
+ bearerKeyId: newBearerKeyId || currentSession.bearerKeyId,
644
+ authenticationMethods: amrClaims,
645
+ authenticationLevel: acrLevel,
646
+ mfaVerified: amrClaims.includes('mfa') || currentSession.mfaVerified,
647
+ mfaCompletedAt: mfaTime,
648
+ mfaExpiresAt: mfaExpires,
649
+ mfaValidityHours: mfaValidityHours
650
+ };
651
+
652
+ await updateSession(sessionToken, sessionUpdate);
653
+
654
+ console.info('[AUTH_REFRESH] Token refreshed successfully', {
655
+ expiresAt: new Date(accessTokenExpires).toISOString(),
656
+ userId,
657
+ hasNewRefreshToken: hasNewRefresh,
658
+ tokenPreview: accessToken ? accessToken.substring(0, 20) + '...' : 'none'
659
+ });
660
+
661
+ } catch (error) {
662
+ console.error('[AUTH_REFRESH] Error during token refresh', { error });
663
+ return NextResponse.json({
664
+ error: 'Token refresh error',
665
+ code: 'INTERNAL_SERVER_ERROR'
666
+ }, { status: 500 });
667
+ } finally {
668
+ if (weAcquiredLock) {
669
+ await releaseRefreshLock(sessionToken, requestId, releaseLockVersion);
670
+ }
671
+ }
672
+
673
+ // Load the latest session to return basic status
674
+ const latest = await getSession(sessionToken);
675
+ if (!latest?.idpAccessToken) {
676
+ return NextResponse.json({
677
+ error: 'No access token after refresh',
678
+ code: 'INTERNAL_SERVER_ERROR'
679
+ }, { status: 500 });
680
+ }
681
+
682
+ return NextResponse.json({
683
+ refreshed: true,
684
+ accessTokenExpires: latest.idpAccessTokenExpires,
685
+ hasRefreshToken: !!latest.idpRefreshToken,
686
+ });
687
+ } catch (err: any) {
688
+ console.error('[AUTH_REFRESH] Unexpected error', { error: err?.message || String(err) });
689
+ return NextResponse.json({
690
+ error: 'Unexpected error during refresh',
691
+ code: 'INTERNAL_SERVER_ERROR'
692
+ }, { status: 500 });
693
+ }
694
+ };
695
+ }
696
+
697
+ /**
698
+ * Default POST export — drop-in for `app/api/auth/refresh/route.ts`.
699
+ * Requires environment variables: IDP_URL, CLIENT_ID
700
+ */
701
+ export const POST = createRefreshHandler({
702
+ idpBaseUrl: process.env.IDP_URL!,
703
+ clientId: process.env.CLIENT_ID || 'payez_default_client',
704
+ refreshEndpoint: '/api/ExternalAuth/refresh'
705
+ });