@payez/next-mvp 4.8.1 → 4.8.2

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 (302) hide show
  1. package/POST-INSTALL.md +408 -408
  2. package/README.md +859 -859
  3. package/dist/components/account/MobileNavDrawer.js +18 -18
  4. package/dist/components/account/UserAvatarMenu.js +1 -1
  5. package/dist/components/admin/AlertSettingsTab.js +40 -40
  6. package/dist/components/admin/LoggingSettingsTab.js +28 -28
  7. package/dist/lib/session-store.js +21 -21
  8. package/dist/lib/startup-init.js +19 -19
  9. package/dist/pages/login/page.js +28 -28
  10. package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
  11. package/dist/vibe/sessions.js +28 -3
  12. package/package.json +2 -2
  13. package/scripts/check-publish-eol.mjs +33 -15
  14. package/src/api/auth-handler.ts +550 -550
  15. package/src/api/index.ts +17 -17
  16. package/src/api-handlers/account/change-password.ts +170 -170
  17. package/src/api-handlers/account/masked-info.ts +46 -46
  18. package/src/api-handlers/account/profile.ts +99 -99
  19. package/src/api-handlers/account/recovery/forward.ts +117 -117
  20. package/src/api-handlers/account/recovery/initiate.ts +59 -59
  21. package/src/api-handlers/account/recovery/send-code.ts +21 -21
  22. package/src/api-handlers/account/recovery/verify-code.ts +20 -20
  23. package/src/api-handlers/account/reset-password.ts +87 -87
  24. package/src/api-handlers/account/send-code.ts +70 -70
  25. package/src/api-handlers/account/update-phone.ts +84 -84
  26. package/src/api-handlers/account/validate-password.ts +139 -139
  27. package/src/api-handlers/account/verify-email.ts +134 -134
  28. package/src/api-handlers/account/verify-sms.ts +134 -134
  29. package/src/api-handlers/admin/analytics.ts +452 -452
  30. package/src/api-handlers/admin/audit.ts +232 -232
  31. package/src/api-handlers/admin/index.ts +63 -63
  32. package/src/api-handlers/admin/redis-sessions.ts +259 -259
  33. package/src/api-handlers/admin/sessions.ts +327 -327
  34. package/src/api-handlers/admin/site-logs.ts +388 -388
  35. package/src/api-handlers/admin/stats.ts +260 -260
  36. package/src/api-handlers/admin/users.ts +251 -251
  37. package/src/api-handlers/admin/vibe-data.ts +333 -333
  38. package/src/api-handlers/anon/preferences.ts +123 -123
  39. package/src/api-handlers/auth/credential-login-email-code.ts +92 -92
  40. package/src/api-handlers/auth/credential-login.ts +948 -948
  41. package/src/api-handlers/auth/jwks.ts +31 -31
  42. package/src/api-handlers/auth/login.ts +252 -252
  43. package/src/api-handlers/auth/passkey-signin.ts +116 -116
  44. package/src/api-handlers/auth/refresh.ts +829 -829
  45. package/src/api-handlers/auth/signout.ts +201 -201
  46. package/src/api-handlers/auth/status.ts +23 -23
  47. package/src/api-handlers/auth/update-session.ts +144 -144
  48. package/src/api-handlers/auth/validate.ts +45 -45
  49. package/src/api-handlers/auth/verify-code.ts +116 -116
  50. package/src/api-handlers/mfa/backup-code-verify.ts +170 -170
  51. package/src/api-handlers/mfa/backup-codes.ts +42 -42
  52. package/src/api-handlers/mfa/methods.ts +47 -47
  53. package/src/api-handlers/mfa/totp-confirm.ts +43 -43
  54. package/src/api-handlers/mfa/totp-enroll.ts +43 -43
  55. package/src/api-handlers/mfa/totp-verify.ts +172 -172
  56. package/src/api-handlers/oauth/resume.ts +182 -182
  57. package/src/api-handlers/onboarding/send-email-code.ts +18 -18
  58. package/src/api-handlers/onboarding/set-password.ts +21 -21
  59. package/src/api-handlers/onboarding/verify-code.ts +20 -20
  60. package/src/api-handlers/onboarding/verify-welcome-email.ts +17 -17
  61. package/src/api-handlers/passkeys/list.ts +32 -32
  62. package/src/api-handlers/passkeys/manage.ts +55 -55
  63. package/src/api-handlers/passkeys/register-options.ts +43 -43
  64. package/src/api-handlers/passkeys/register-verify.ts +42 -42
  65. package/src/api-handlers/session/refresh-viability.ts +56 -56
  66. package/src/api-handlers/session/viability.ts +238 -238
  67. package/src/api-handlers/test/force-expire.ts +167 -167
  68. package/src/auth/auth-decision.ts +292 -292
  69. package/src/auth/better-auth.ts +1688 -1688
  70. package/src/auth/idp-oauth.ts +310 -310
  71. package/src/auth/password-reset-bridge.ts +98 -98
  72. package/src/auth/revocation-fields.ts +16 -16
  73. package/src/auth/route-config.ts +236 -236
  74. package/src/auth/safe-callback.ts +199 -199
  75. package/src/auth/session-cookie-clear.ts +30 -30
  76. package/src/auth/session-reset-link.ts +159 -159
  77. package/src/auth/session-response-sanitizer.ts +151 -151
  78. package/src/auth/types/auth-types.ts +555 -555
  79. package/src/auth/types/index.ts +7 -7
  80. package/src/auth/unauthenticated-routes.ts +2 -2
  81. package/src/auth/utils/idp-client.ts +364 -364
  82. package/src/auth/utils/index.ts +6 -6
  83. package/src/auth/utils/token-utils.ts +243 -243
  84. package/src/client/AuthContext.tsx +192 -192
  85. package/src/client/better-auth-client.ts +70 -70
  86. package/src/client/fetch-with-auth.ts +47 -47
  87. package/src/client/fetchWithSession.ts +21 -21
  88. package/src/client/index.ts +13 -13
  89. package/src/client/useAnonSession.ts +131 -131
  90. package/src/components/SessionSync.tsx +138 -138
  91. package/src/components/SignalRHealthCheck.tsx +131 -131
  92. package/src/components/account/MobileNavDrawer.tsx +305 -305
  93. package/src/components/account/UserAvatarMenu.tsx +277 -277
  94. package/src/components/account/index.ts +13 -13
  95. package/src/components/admin/AlertSettingsTab.tsx +728 -728
  96. package/src/components/admin/AnalyticsTab.tsx +703 -703
  97. package/src/components/admin/DataBrowserTab.tsx +508 -508
  98. package/src/components/admin/LoggingSettingsTab.tsx +665 -665
  99. package/src/components/admin/SessionsTab.tsx +421 -421
  100. package/src/components/admin/StatsTab.tsx +379 -379
  101. package/src/components/admin/VibeAdminContext.tsx +87 -87
  102. package/src/components/admin/VibeAdminLayout.tsx +187 -187
  103. package/src/components/admin/index.ts +59 -59
  104. package/src/components/auth/CredentialSignInForm.tsx +240 -240
  105. package/src/components/auth/FederatedAuthSection.tsx +129 -129
  106. package/src/components/auth/ModeAwareLoginPage.tsx +146 -146
  107. package/src/components/auth/ModeAwareSignupPage.tsx +200 -200
  108. package/src/components/auth/PasswordResetBridge.tsx +30 -30
  109. package/src/components/auth/TraditionalAuthSection.tsx +103 -103
  110. package/src/components/recovery/CompleteStep.tsx +36 -36
  111. package/src/components/recovery/InitiateRecoveryStep.tsx +68 -68
  112. package/src/components/recovery/RequestedStep.tsx +30 -30
  113. package/src/components/recovery/SelectMethodStep.tsx +73 -73
  114. package/src/components/recovery/SetPasswordStep.tsx +118 -118
  115. package/src/components/recovery/VerifyCodeStep.tsx +90 -90
  116. package/src/components/reserved/ReservedRecoveryWarning.tsx +160 -160
  117. package/src/components/reserved/ReservedStatusBox.tsx +118 -118
  118. package/src/components/ui/BetaBadge.tsx +58 -58
  119. package/src/components/ui/Footer.tsx +93 -93
  120. package/src/config/env.ts +55 -55
  121. package/src/config/logger.ts +62 -62
  122. package/src/config/logging-config.ts +82 -82
  123. package/src/config/unauthenticated-routes.ts +19 -19
  124. package/src/config/vibe-log-transport.ts +264 -264
  125. package/src/edge/internal-api-url.ts +65 -65
  126. package/src/edge/middleware.ts +42 -42
  127. package/src/hooks/useAuth.ts +117 -117
  128. package/src/hooks/useAuthSettings.ts +109 -109
  129. package/src/hooks/useAvailableProviders.ts +143 -143
  130. package/src/hooks/usePasswordValidation.ts +127 -127
  131. package/src/hooks/useProfile.ts +82 -82
  132. package/src/hooks/usePublicAuthSettings.ts +171 -171
  133. package/src/hooks/useSessionExpiration.ts +101 -101
  134. package/src/hooks/useViabilitySession.ts +337 -337
  135. package/src/index.ts +64 -64
  136. package/src/lib/anon-session.ts +213 -213
  137. package/src/lib/api-handler.ts +643 -643
  138. package/src/lib/app-slug.ts +228 -228
  139. package/src/lib/authorize-resume.ts +172 -172
  140. package/src/lib/bearer-fetch.ts +31 -31
  141. package/src/lib/client-key.ts +88 -88
  142. package/src/lib/code-step-copy.ts +83 -83
  143. package/src/lib/credential-sign-in-client.ts +101 -101
  144. package/src/lib/demo-mode.ts +18 -18
  145. package/src/lib/device-id.ts +67 -67
  146. package/src/lib/ensure-fresh-access-token.ts +509 -509
  147. package/src/lib/geolocation.ts +265 -265
  148. package/src/lib/idp-client-config.ts +1005 -1005
  149. package/src/lib/idp-fetch.ts +169 -169
  150. package/src/lib/idp-url-guard.ts +37 -37
  151. package/src/lib/internal-api.ts +171 -171
  152. package/src/lib/jwt-decode-client.ts +45 -45
  153. package/src/lib/jwt-decode.ts +82 -82
  154. package/src/lib/lock-window.ts +49 -49
  155. package/src/lib/login-error-message.ts +41 -41
  156. package/src/lib/never-in-response.ts +64 -64
  157. package/src/lib/nextauth-secret.ts +168 -168
  158. package/src/lib/onboarding-proxy.ts +57 -57
  159. package/src/lib/password-rules.ts +50 -50
  160. package/src/lib/rate-limit-service.ts +9 -9
  161. package/src/lib/redis.ts +105 -105
  162. package/src/lib/refresh-token-validator.ts +64 -64
  163. package/src/lib/roles.ts +177 -177
  164. package/src/lib/safe-error-response.ts +149 -149
  165. package/src/lib/secret-validation.ts +8 -8
  166. package/src/lib/session-cookie-shape.ts +185 -185
  167. package/src/lib/session-store.ts +1092 -1092
  168. package/src/lib/session.ts +34 -34
  169. package/src/lib/single-flight.ts +64 -64
  170. package/src/lib/site-logger.ts +245 -245
  171. package/src/lib/standardized-client-api.ts +901 -901
  172. package/src/lib/startup-init.ts +276 -276
  173. package/src/lib/test-aware-get-token.ts +41 -41
  174. package/src/lib/token-expiry.ts +40 -40
  175. package/src/lib/token-lifecycle.ts +546 -546
  176. package/src/lib/token-log-id.ts +69 -69
  177. package/src/lib/twofa-rejection.ts +91 -91
  178. package/src/lib/types/api-responses.ts +336 -336
  179. package/src/lib/user-agent-parser.ts +252 -252
  180. package/src/logging/api/admin-analytics.ts +51 -51
  181. package/src/logging/api/audit-log.ts +53 -53
  182. package/src/logging/components/AdminAnalyticsLayout.tsx +49 -49
  183. package/src/logging/components/AuditLogViewer.tsx +125 -125
  184. package/src/logging/components/ErrorMetricsCard.tsx +98 -98
  185. package/src/logging/components/HealthMetricsCard.tsx +70 -70
  186. package/src/logging/hooks/useAdminAnalytics.ts +22 -22
  187. package/src/logging/hooks/useAuditLog.ts +24 -24
  188. package/src/logging/hooks/useErrorMetrics.ts +40 -40
  189. package/src/logging/hooks/useHealthMetrics.ts +44 -44
  190. package/src/logging/index.ts +18 -18
  191. package/src/logging/types/analytics.ts +81 -81
  192. package/src/logging/types/audit.ts +31 -31
  193. package/src/logging/types/index.ts +3 -3
  194. package/src/middleware/create-middleware.ts +1141 -1141
  195. package/src/middleware/rbac-check.ts +279 -279
  196. package/src/middleware/twofa-presets.ts +224 -224
  197. package/src/models/DecodedAccessToken.ts +16 -16
  198. package/src/models/SessionModel.ts +331 -331
  199. package/src/pages/admin-login/page.tsx +111 -111
  200. package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -527
  201. package/src/pages/admin-page-permissions/index.ts +7 -7
  202. package/src/pages/admin-roles/RolesAdminPage.tsx +533 -533
  203. package/src/pages/admin-roles/index.ts +9 -9
  204. package/src/pages/admin-roles/modals.tsx +469 -469
  205. package/src/pages/client-admin/ClientSiteAdminPage.tsx +382 -382
  206. package/src/pages/client-admin/index.ts +33 -33
  207. package/src/pages/coming-soon/page.tsx +83 -83
  208. package/src/pages/login/page.tsx +338 -338
  209. package/src/pages/onboarding/index.ts +11 -11
  210. package/src/pages/onboarding/page.tsx +212 -212
  211. package/src/pages/profile/EnhancedProfilePage.tsx +514 -514
  212. package/src/pages/profile/index.ts +9 -9
  213. package/src/pages/profile/page.tsx +166 -166
  214. package/src/pages/recovery/page.tsx +267 -267
  215. package/src/pages/roles/MyRolesPage.tsx +211 -211
  216. package/src/pages/roles/components.tsx +294 -294
  217. package/src/pages/roles/index.ts +17 -17
  218. package/src/pages/security/EnhancedSecurityPage.tsx +660 -660
  219. package/src/pages/security/index.ts +12 -12
  220. package/src/pages/security/page.tsx +507 -507
  221. package/src/pages/service-unavailable/page.tsx +63 -63
  222. package/src/pages/settings/EnhancedSettingsPage.tsx +642 -642
  223. package/src/pages/settings/index.ts +9 -9
  224. package/src/pages/settings/page.tsx +47 -47
  225. package/src/pages/showcase/ShowcasePage.tsx +532 -532
  226. package/src/pages/showcase/index.ts +13 -13
  227. package/src/pages/test-env/EmergencyLogoutPage.tsx +192 -192
  228. package/src/pages/test-env/JwtInspectPage.tsx +449 -449
  229. package/src/pages/test-env/RefreshTokenPage.tsx +178 -178
  230. package/src/pages/test-env/TestEnvPage.tsx +130 -130
  231. package/src/pages/test-env/guarded.tsx +81 -81
  232. package/src/pages/test-env/index.ts +77 -77
  233. package/src/pages/test-env/redact-credentials.ts +96 -96
  234. package/src/pages/verify-code/page.tsx +746 -746
  235. package/src/routes/account/index.ts +31 -31
  236. package/src/routes/account/masked-info.ts +36 -36
  237. package/src/routes/account/send-code.ts +39 -39
  238. package/src/routes/account/update-phone.ts +13 -13
  239. package/src/routes/account/verify-email.ts +40 -40
  240. package/src/routes/account/verify-sms.ts +40 -40
  241. package/src/routes/auth/credential-login-email-code.ts +6 -6
  242. package/src/routes/auth/credential-login.ts +6 -6
  243. package/src/routes/auth/index.ts +22 -22
  244. package/src/routes/auth/logout.ts +111 -111
  245. package/src/routes/auth/nextauth.ts +45 -45
  246. package/src/routes/auth/passkey-signin.ts +13 -13
  247. package/src/routes/auth/refresh.ts +77 -77
  248. package/src/routes/auth/session-reset.ts +37 -37
  249. package/src/routes/auth/session.ts +261 -261
  250. package/src/routes/auth/settings.ts +181 -181
  251. package/src/routes/auth/viability.ts +519 -519
  252. package/src/routes/index.ts +17 -17
  253. package/src/routes/mfa/backup-code-verify.ts +1 -1
  254. package/src/routes/mfa/backup-codes.ts +10 -10
  255. package/src/routes/mfa/methods.ts +22 -22
  256. package/src/routes/mfa/totp-confirm.ts +10 -10
  257. package/src/routes/mfa/totp-enroll.ts +10 -10
  258. package/src/routes/mfa/totp-verify.ts +14 -14
  259. package/src/routes/onboarding/index.ts +15 -15
  260. package/src/routes/onboarding/send-email-code.ts +2 -2
  261. package/src/routes/onboarding/set-password.ts +2 -2
  262. package/src/routes/onboarding/verify-code.ts +2 -2
  263. package/src/routes/onboarding/verify-welcome-email.ts +2 -2
  264. package/src/routes/passkeys/list.ts +10 -10
  265. package/src/routes/passkeys/manage.ts +23 -23
  266. package/src/routes/passkeys/register-options.ts +10 -10
  267. package/src/routes/passkeys/register-verify.ts +10 -10
  268. package/src/routes/session/index.ts +7 -7
  269. package/src/routes/session/refresh-viability.ts +17 -17
  270. package/src/server/auth-guard.ts +203 -203
  271. package/src/server/auth.ts +486 -486
  272. package/src/server/decode-session.ts +309 -309
  273. package/src/server/slim-middleware.ts +117 -117
  274. package/src/server/verified-session-token.ts +103 -103
  275. package/src/server/with-auth.ts +93 -93
  276. package/src/services/signalrActivityService.ts +258 -258
  277. package/src/stores/authStore.ts +1900 -1900
  278. package/src/templates/instrumentation.ts +60 -60
  279. package/src/theme/ThemeProvider.tsx +39 -39
  280. package/src/theme/default.ts +33 -33
  281. package/src/theme/index.ts +31 -31
  282. package/src/theme/types.ts +69 -69
  283. package/src/theme/useTheme.ts +57 -57
  284. package/src/theme/utils.ts +40 -40
  285. package/src/types/api.ts +12 -12
  286. package/src/types/auth.d.ts +23 -23
  287. package/src/types/auth.ts +48 -48
  288. package/src/types/logging.ts +11 -11
  289. package/src/types/recovery.ts +54 -54
  290. package/src/utils/api.ts +401 -401
  291. package/src/utils/circuitBreaker.ts +40 -40
  292. package/src/utils/error-message.ts +108 -108
  293. package/src/utils/layout/reservedSpace.ts +124 -124
  294. package/src/utils/logout.ts +30 -30
  295. package/src/vibe/client.ts +877 -877
  296. package/src/vibe/enterprise-auth.ts +208 -208
  297. package/src/vibe/errors.ts +209 -209
  298. package/src/vibe/generic.ts +339 -339
  299. package/src/vibe/hooks/index.ts +367 -367
  300. package/src/vibe/index.ts +133 -133
  301. package/src/vibe/sessions.ts +925 -899
  302. package/src/vibe/types.ts +577 -577
@@ -1,829 +1,829 @@
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 (the ONLY way a session is established here)
8
- * - PayEz IDP refresh token exchange
9
- * - Session state updates with new tokens
10
- * - Proper error handling and logging
11
- * - Single-use semantics enforcement
12
- *
13
- * ── 246007: THE `x-session-token` FALLBACK IS GONE. IT NOW HARD-DENIES. ────
14
- * This list previously advertised an x-session-token fallback for internal
15
- * callers as a current capability. That behaviour has been REMOVED: a request presenting `x-session-token` with
16
- * no cookie-resolved session of its own is rejected 401 UNAUTHORIZED, because
17
- * there is no session to establish ownership of the token it names. Accepting
18
- * it let a caller refresh a session they did not own.
19
- *
20
- * The line is corrected here rather than merely deleted, and it is called out
21
- * rather than quietly dropped, because THIS DOCSTRING SHIPS. It is compiled
22
- * into dist and into the .d.ts, so it reaches every consumer's editor as hover
23
- * text. QAPert-NightHawk caught that the fixed build was still describing the
24
- * removed capability as a current feature (34973) — her verifier tests code
25
- * properties and reads no comments, so a correct build could have published
26
- * documentation asserting the opposite of what the code does.
27
- *
28
- * That matters twice over: an internal caller relying on the fallback gets a
29
- * silent behavioural break while the docs promise it still works, and a
30
- * published package that misdescribes its own auth behaviour is the same
31
- * amplifier that put other defects onto three surfaces — shipped code carrying
32
- * instructions for using it wrongly.
33
- *
34
- * @version 2.1
35
- */
36
-
37
- import { NextRequest, NextResponse } from 'next/server';
38
- import { randomUUID } from 'crypto';
39
- import { getSession as getBetterAuthSession } from '../../server/auth';
40
- import { getSession, updateSession, acquireRefreshLock, releaseRefreshLock, checkRefreshLock } from '../../lib/session-store';
41
- import { computeTokenExpiries } from '../../lib/token-expiry';
42
- import { extractKidFromToken } from '../../auth/utils/token-utils';
43
- import { tokenLogId } from '../../lib/token-log-id';
44
-
45
- interface RefreshConfig {
46
- idpBaseUrl: string;
47
- clientId: string;
48
- refreshEndpoint?: string;
49
- }
50
-
51
- /**
52
- * Creates a refresh token handler for Next.js API routes
53
- *
54
- * @param config IDP connection settings (Better Auth handles session crypto)
55
- * @returns Next.js POST handler function
56
- *
57
- * @example
58
- * ```typescript
59
- * // In your app's /app/api/auth/refresh/route.ts
60
- * import { createRefreshHandler } from '@payez/next-mvp/api-handlers/auth/refresh';
61
- *
62
- * export const POST = createRefreshHandler({
63
- * idpBaseUrl: process.env.IDP_URL!,
64
- * clientId: process.env.CLIENT_ID!,
65
- * refreshEndpoint: '/api/ExternalAuth/refresh'
66
- * });
67
- * ```
68
- */
69
- export function createRefreshHandler(config: RefreshConfig) {
70
- const { idpBaseUrl, clientId, refreshEndpoint = '/api/ExternalAuth/refresh' } = config;
71
-
72
- return async function POST(req: NextRequest) {
73
- try {
74
- // Extract session from Better Auth
75
- const betterAuthSession = await getBetterAuthSession(req);
76
-
77
- let sessionToken = (betterAuthSession?.session?.token) as string | undefined;
78
- let userId = betterAuthSession?.user?.id;
79
-
80
- if (!sessionToken) {
81
- // OWNERSHIP CHECK on the x-session-token fallback (246007). BAPert ruled
82
- // 34708: ship it, accept the one-time re-login cost, do not gate on 240654.
83
- //
84
- // WHAT THIS CLOSED: the fallback took a session token from a CALLER-SUPPLIED
85
- // header and acted on that session with no check that it belonged to the
86
- // caller. /api/auth/* is in publicRoutes on the consuming surfaces, so an
87
- // UNAUTHENTICATED caller who knew any session token could cause that session
88
- // to be refreshed -- an identity assumption, and the precondition for the
89
- // forced-logout chain via IDP reuse detection.
90
- //
91
- // WHY THE CHECK IS A HARD DENY RATHER THAN A COMPARISON, which is the part
92
- // worth understanding before anyone "improves" it: this branch is reached
93
- // ONLY when getBetterAuthSession() failed to resolve a session from the
94
- // request's own cookie. The natural ownership check -- "the header may only
95
- // name the session the caller's cookie already resolves to" -- therefore has
96
- // nothing to compare against by construction. Any correct ownership test at
97
- // this point evaluates to reject. So the check and outright removal coincide
98
- // here; a more permissive version is not possible without moving the check
99
- // somewhere it can actually see an authenticated identity.
100
- //
101
- // WHO THIS AFFECTS, and it is a deliberate accepted cost, not an oversight:
102
- // internal callers (lib/internal-api internalRefresh, from api-handler and
103
- // idp-fetch) forward the end user's cookie. Where that cookie resolves they
104
- // never enter this branch and are unaffected. Where it does not -- sessions
105
- // minted before the graft dual-write (8a5ac21) whose cookie carries only the
106
- // __Secure- name, which the pre-240654 resolver could not read -- the refresh
107
- // now fails and the user re-authenticates ONCE. Their next login writes both
108
- // cookie names and they are permanently fine. That population cannot grow, does
109
- // not age out (session TTL slides on every write), and does not self-heal on
110
- // use (this handler sets no cookie) -- so the only ways out were to accept the
111
- // re-login or leave the defect live. BAPert chose to accept it.
112
- //
113
- // If server-to-server refresh must work WITHOUT an end-user cookie, that needs
114
- // real service authentication -- a shared secret, mTLS, or a network policy --
115
- // never a header any client can set.
116
- const headerSessionToken = req.headers.get('x-session-token');
117
- if (headerSessionToken) {
118
- console.warn(
119
- '[AUTH_REFRESH] REJECTED x-session-token: no cookie-resolved session to own it. ' +
120
- 'A header-named session is not proof of ownership; the caller must present a ' +
121
- 'session cookie this server can resolve.',
122
- );
123
- return NextResponse.json(
124
- {
125
- error: 'Session ownership could not be established',
126
- code: 'UNAUTHORIZED',
127
- },
128
- { status: 401 },
129
- );
130
- }
131
- }
132
-
133
- if (!sessionToken || !userId) {
134
- console.warn('[AUTH_REFRESH] Missing sessionToken or user id on token');
135
- return NextResponse.json({
136
- error: 'No session available for refresh',
137
- code: 'UNAUTHORIZED'
138
- }, { status: 401 });
139
- }
140
-
141
- // Get current session
142
- const currentSession = await getSession(sessionToken);
143
- // NOTE: Field is idpRefreshToken (not refreshToken) per normalized naming convention
144
- if (!currentSession?.idpRefreshToken) {
145
- console.warn('[AUTH_REFRESH] No refresh token available', { userId });
146
- return NextResponse.json({
147
- error: 'No refresh token available',
148
- code: 'NO_REFRESH_TOKEN', // Terminal state - session cannot be refreshed
149
- terminal: true, // Signal to frontend: don't retry, redirect to login
150
- resolution: 'User must re-authenticate'
151
- }, { status: 401 });
152
- }
153
-
154
- // ============================================================================
155
- // HIGH VISIBILITY: PRE-FLIGHT REFRESH DIAGNOSTICS
156
- // ============================================================================
157
- const now = Date.now();
158
- const refreshTokenAge = currentSession.idpRefreshTokenIssuedAt
159
- ? Math.round((now - currentSession.idpRefreshTokenIssuedAt) / 1000)
160
- : 'unknown';
161
- const refreshTokenExpiresIn = currentSession.idpRefreshTokenExpires
162
- ? Math.round((currentSession.idpRefreshTokenExpires - now) / 1000)
163
- : 'unknown';
164
- const accessTokenExpiresIn = currentSession.idpAccessTokenExpires
165
- ? Math.round((currentSession.idpAccessTokenExpires - now) / 1000)
166
- : 'unknown';
167
-
168
- console.log('╔══════════════════════════════════════════════════════════════════════════════╗');
169
- console.log('║ REFRESH TOKEN ATTEMPT - PRE-FLIGHT ║');
170
- console.log('╠══════════════════════════════════════════════════════════════════════════════╣');
171
- console.log(`║ User: ${(userId || 'unknown').substring(0, 50).padEnd(50)} ║`);
172
- console.log(`║ Session: ${tokenLogId(sessionToken)} ║`);
173
- console.log('╠──────────────────────────────────────────────────────────────────────────────╣');
174
- console.log(`║ Access Token Expires In: ${String(accessTokenExpiresIn).padEnd(10)} seconds ║`);
175
- console.log(`║ Refresh Token Age: ${String(refreshTokenAge).padEnd(10)} seconds ║`);
176
- console.log(`║ Refresh Token Expires In: ${String(refreshTokenExpiresIn).padEnd(10)} seconds ║`);
177
- console.log(`║ Refresh Token Length: ${String(currentSession.idpRefreshToken?.length || 0).padEnd(10)} chars ║`);
178
- console.log(`║ 2FA Complete: ${String(!!currentSession.mfaVerified).padEnd(10)} ║`);
179
- console.log(`║ Auth Level (ACR): ${String(currentSession.authenticationLevel || '1').padEnd(10)} ║`);
180
- console.log('╚══════════════════════════════════════════════════════════════════════════════╝');
181
-
182
- // LOCK IDENTITY IS MINTED SERVER-SIDE. It must never come from a header.
183
- //
184
- // This was `req.headers.get('x-request-id') ?? \`refresh_${Date.now()}\``, and the
185
- // value was used as the lock's `acquiredBy`. Below it, a branch let a caller
186
- // PROCEED WITHOUT THE LOCK when the existing lock's acquiredBy equalled the id
187
- // they had supplied. Since the caller chose that id, the branch was a lock
188
- // bypass requiring no guesswork: send N concurrent requests all carrying the
189
- // same x-request-id, and every one after the first skips the wait.
190
- //
191
- // That completed an attacker-controlled forced logout, because the serialisation
192
- // is what prevents refresh-token REUSE, and the IDP treats reuse as compromise:
193
- // IdentityRefreshTokenService:166 -> RevokeDescendantTokens ->
194
- // IdentityRefreshTokenRepository:142 revokes every ACTIVE token for that USER
195
- // issued after the reused one -- across all devices and all client apps. So a
196
- // caller who knew one session token could log a user out everywhere by sending
197
- // a few requests with a header value of their own choosing.
198
- //
199
- // Minting server-side removes the bypass by construction: no caller can produce
200
- // a value that matches an in-flight lock, so the equality branch can never be
201
- // true and is deleted rather than left inert -- an unreachable branch that
202
- // trusts a header is a trap for whoever edits this next.
203
- //
204
- // The anti-deadlock intent that branch served is preserved by the wait path
205
- // below, which is the correct handling anyway: a genuine duplicate waits for the
206
- // in-flight refresh and returns its result instead of starting a second one.
207
- // The header is still read, for TRACING ONLY, and never influences control flow.
208
- const traceId = req.headers.get('x-request-id') ?? undefined;
209
- const requestId = `refresh_${randomUUID()}`;
210
- const lockAcquired = await acquireRefreshLock(sessionToken, requestId, 5000);
211
-
212
- let weAcquiredLock = false;
213
- let releaseLockVersion: number | undefined;
214
-
215
- if (!lockAcquired.acquired) {
216
- {
217
- // Wait for the lock to release, then check if token is now fresh
218
- console.info('[AUTH_REFRESH] Refresh in progress by another request, waiting for completion...', { requestId, traceId });
219
-
220
- const maxWaitMs = 5000;
221
- const checkIntervalMs = 200;
222
- const startWait = Date.now();
223
-
224
- while (Date.now() - startWait < maxWaitMs) {
225
- await new Promise(resolve => setTimeout(resolve, checkIntervalMs));
226
-
227
- // Check if lock was released
228
- const lockCheck = await checkRefreshLock(sessionToken);
229
- if (!lockCheck) {
230
- // Lock released - check if token is now fresh
231
- const refreshedSession = await getSession(sessionToken);
232
- if (refreshedSession?.idpAccessToken && refreshedSession?.idpAccessTokenExpires) {
233
- const timeUntilExpiry = refreshedSession.idpAccessTokenExpires - Date.now();
234
- const fiveMinutes = 5 * 60 * 1000;
235
-
236
- if (timeUntilExpiry > fiveMinutes) {
237
- console.info('[AUTH_REFRESH] Lock released, token is fresh - returning success', {
238
- requestId,
239
- accessTokenExpires: new Date(refreshedSession.idpAccessTokenExpires).toISOString(),
240
- waitedMs: Date.now() - startWait,
241
- });
242
- return NextResponse.json({
243
- refreshed: true,
244
- reason: 'completed_by_concurrent_request',
245
- accessTokenExpires: refreshedSession.idpAccessTokenExpires,
246
- hasRefreshToken: !!refreshedSession.idpRefreshToken,
247
- });
248
- }
249
- }
250
- // Lock released but token not fresh - try to acquire lock ourselves
251
- break;
252
- }
253
- }
254
-
255
- // Still locked after waiting - return 409
256
- console.warn('[AUTH_REFRESH] Refresh still in progress after waiting', { requestId, waitedMs: Date.now() - startWait });
257
- return NextResponse.json({
258
- error: 'Refresh already in progress',
259
- code: 'CONFLICT'
260
- }, { status: 409 });
261
- }
262
- } else {
263
- weAcquiredLock = true;
264
- releaseLockVersion = lockAcquired.lockInfo?.lockVersion;
265
- }
266
-
267
- // Before performing network refresh, re-check if tokens are already fresh
268
- const latestAfterLock = await getSession(sessionToken);
269
- const fiveMinutes = 5 * 60 * 1000;
270
- const timeUntilExpiry = latestAfterLock?.idpAccessTokenExpires
271
- ? latestAfterLock.idpAccessTokenExpires - Date.now()
272
- : -1;
273
-
274
- // CRITICAL: Also check the actual JWT's exp claim - Redis might have stale data
275
- let actualJwtExpMs = -1;
276
- let tokenMismatch = false;
277
- if (latestAfterLock?.idpAccessToken) {
278
- try {
279
- const tokenParts = latestAfterLock.idpAccessToken.split('.');
280
- if (tokenParts.length === 3) {
281
- const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64url').toString());
282
- actualJwtExpMs = (payload.exp || 0) * 1000;
283
- const now = Date.now();
284
- // If the actual JWT is expired, we MUST refresh regardless of what Redis says
285
- if (actualJwtExpMs < now) {
286
- tokenMismatch = true;
287
- }
288
- }
289
- } catch (e) {
290
- // If we can't decode, proceed with normal logic
291
- }
292
- }
293
-
294
- console.log('[AUTH_REFRESH] Pre-refresh check:', {
295
- userId,
296
- hasAccessToken: !!latestAfterLock?.idpAccessToken,
297
- accessTokenExpires: latestAfterLock?.idpAccessTokenExpires
298
- ? new Date(latestAfterLock.idpAccessTokenExpires).toISOString()
299
- : 'undefined',
300
- actualJwtExp: actualJwtExpMs > 0 ? new Date(actualJwtExpMs).toISOString() : 'unknown',
301
- now: new Date().toISOString(),
302
- timeUntilExpiryMs: timeUntilExpiry,
303
- tokenMismatch,
304
- willRefresh: timeUntilExpiry <= fiveMinutes || tokenMismatch
305
- });
306
-
307
- // Only skip refresh if BOTH Redis says fresh AND the actual JWT is not expired
308
- if (latestAfterLock?.idpAccessToken && latestAfterLock?.idpAccessTokenExpires &&
309
- timeUntilExpiry > fiveMinutes && !tokenMismatch) {
310
- console.info('[AUTH_REFRESH] Skipping refresh: tokens already fresh', { userId, timeUntilExpiryMs: timeUntilExpiry });
311
- if (weAcquiredLock) {
312
- await releaseRefreshLock(sessionToken, requestId, releaseLockVersion);
313
- weAcquiredLock = false;
314
- }
315
- return NextResponse.json({
316
- refreshed: false,
317
- reason: 'already_fresh',
318
- accessTokenExpires: latestAfterLock.idpAccessTokenExpires,
319
- hasRefreshToken: !!latestAfterLock.idpRefreshToken,
320
- });
321
- }
322
-
323
- // If we detected a token mismatch, log it prominently
324
- if (tokenMismatch) {
325
- console.warn('[AUTH_REFRESH] Token mismatch detected - forcing refresh despite Redis claiming fresh', {
326
- userId,
327
- redisExpires: latestAfterLock?.idpAccessTokenExpires ? new Date(latestAfterLock.idpAccessTokenExpires).toISOString() : 'N/A',
328
- actualJwtExp: new Date(actualJwtExpMs).toISOString()
329
- });
330
- }
331
-
332
- // Copy refresh token for use in IDP call
333
- // Note: Token is NOT cleared preemptively - it will be replaced on success.
334
- // The lock mechanism prevents concurrent refresh attempts within the same session.
335
- // If IDP call fails, user can retry with the same token.
336
- const originalRefreshToken = currentSession.idpRefreshToken as string;
337
-
338
- try {
339
- // Extract 2FA claims from current session
340
- let authMethods: string[] = [];
341
- if (currentSession.authenticationMethods) {
342
- if (typeof currentSession.authenticationMethods === 'string') {
343
- try {
344
- authMethods = JSON.parse(currentSession.authenticationMethods);
345
- } catch (e) {
346
- console.warn('[AUTH_REFRESH] Failed to parse authenticationMethods', { authenticationMethods: currentSession.authenticationMethods });
347
- }
348
- } else if (Array.isArray(currentSession.authenticationMethods)) {
349
- authMethods = currentSession.authenticationMethods;
350
- }
351
- }
352
-
353
- // For OAuth sessions with empty AMR claims, provide defaults
354
- // OAuth IS a form of multi-factor auth (you authenticated with another provider)
355
- let isOAuthSession = !!currentSession.oauthProvider;
356
- if (authMethods.length === 0 && isOAuthSession) {
357
- // OAuth sessions get default AMR claims
358
- authMethods = ['pwd', 'mfa'];
359
- console.log('[AUTH_REFRESH] OAuth session with empty AMR - using defaults:', {
360
- oauthProvider: currentSession.oauthProvider,
361
- defaultAmr: authMethods
362
- });
363
- }
364
-
365
- // Check authMethods first, then fallback to twoFactorMethod field (set by transitionTo2FASession)
366
- // For OAuth sessions, use 'oauth' as the 2FA method since OAuth itself is the authentication
367
- const twoFactorMethod = authMethods.find(m => ['sms', 'totp', 'email'].includes(m))
368
- || currentSession.mfaMethod
369
- || (isOAuthSession ? 'oauth' : null);
370
-
371
- // DEBUG: Log what we have for 2FA
372
- console.log('[AUTH_REFRESH] 2FA Debug:', {
373
- authMethods,
374
- authMethodsRaw: currentSession.authenticationMethods,
375
- twoFactorMethodFromSession: currentSession.mfaMethod,
376
- twoFactorMethodResolved: twoFactorMethod,
377
- twoFactorComplete: currentSession.mfaVerified,
378
- sessionKeys: Object.keys(currentSession)
379
- });
380
-
381
- // Build refresh request body per PayEz wire standard (snake_case)
382
- // See: 2FA-TOKEN-REFRESH-CONTEXT.md - "The Ninja Shit"
383
- // For OAuth sessions with 2FA complete, use ACR level 2
384
- let acrValue = String(currentSession.authenticationLevel ?? '1');
385
- if (currentSession.oauthProvider && currentSession.mfaVerified && acrValue === '1') {
386
- acrValue = '2'; // OAuth with 2FA complete = full authentication
387
- }
388
- const refreshRequestBody: any = {
389
- refresh_token: originalRefreshToken,
390
- amr: authMethods,
391
- acr: acrValue
392
- };
393
-
394
- // DEBUG: Log exact request body
395
- console.log('[AUTH_REFRESH] Request body:', JSON.stringify({
396
- refresh_token: '***REDACTED***',
397
- amr: authMethods,
398
- acr: acrValue,
399
- acrType: typeof acrValue
400
- }));
401
-
402
- const twoFactorVerified = !!currentSession.mfaVerified;
403
- if (twoFactorVerified) {
404
- refreshRequestBody.two_factor_verified = true;
405
- }
406
- if (twoFactorMethod) {
407
- refreshRequestBody.two_factor_method = twoFactorMethod;
408
- }
409
- if (currentSession.mfaCompletedAt) {
410
- refreshRequestBody.two_factor_completed_at = new Date(currentSession.mfaCompletedAt).toISOString();
411
- }
412
-
413
- // Log the full request details for debugging
414
- console.log('[AUTH_REFRESH] Sending refresh request:', {
415
- url: `${idpBaseUrl}${refreshEndpoint}`,
416
- clientId,
417
- hasRefreshToken: !!refreshRequestBody.refresh_token,
418
- refreshTokenLength: refreshRequestBody.refresh_token?.length,
419
- amr: refreshRequestBody.amr,
420
- acr: refreshRequestBody.acr
421
- });
422
-
423
- let refreshResponse: Response;
424
- try {
425
- refreshResponse = await fetch(`${idpBaseUrl}${refreshEndpoint}`, {
426
- method: 'POST',
427
- headers: {
428
- 'Content-Type': 'application/json',
429
- 'X-Client-Id': clientId,
430
- },
431
- body: JSON.stringify(refreshRequestBody),
432
- });
433
- } catch (fetchError) {
434
- // Network error - IDP unreachable
435
- // Note: Lock will be released by finally block
436
- console.error('[AUTH_REFRESH] IDP unreachable:', {
437
- error: fetchError instanceof Error ? fetchError.message : String(fetchError),
438
- idpUrl: idpBaseUrl,
439
- userId
440
- });
441
- return NextResponse.json({
442
- error: 'IDP service unavailable',
443
- code: 'UPSTREAM_SERVICE_UNAVAILABLE',
444
- retryable: true,
445
- discardToken: false
446
- }, { status: 503 });
447
- }
448
-
449
- // Parse response body - handle empty or invalid JSON
450
- let responseData: any;
451
- try {
452
- const responseText = await refreshResponse.text();
453
- if (!responseText || responseText.trim() === '') {
454
- // Note: Lock will be released by finally block
455
- console.error('[AUTH_REFRESH] Empty response from IDP:', {
456
- status: refreshResponse.status,
457
- statusText: refreshResponse.statusText,
458
- userId
459
- });
460
- return NextResponse.json({
461
- error: 'Empty response from IDP',
462
- code: 'UPSTREAM_SERVICE_ERROR',
463
- retryable: true,
464
- discardToken: false
465
- }, { status: 502 });
466
- }
467
- responseData = JSON.parse(responseText);
468
- } catch (parseError) {
469
- // Note: Lock will be released by finally block
470
- console.error('[AUTH_REFRESH] Failed to parse IDP response:', {
471
- error: parseError instanceof Error ? parseError.message : String(parseError),
472
- status: refreshResponse.status,
473
- userId
474
- });
475
- return NextResponse.json({
476
- error: 'Invalid response from IDP',
477
- code: 'UPSTREAM_SERVICE_ERROR',
478
- retryable: true,
479
- discardToken: false
480
- }, { status: 502 });
481
- }
482
-
483
- // Handle non-OK responses with structured error format
484
- if (!refreshResponse.ok) {
485
- // Parse structured error from IDP
486
- const idpError = responseData?.error || {};
487
- const errorCode = idpError.code || 'UNKNOWN_ERROR';
488
- const errorMessage = idpError.message || 'Token refresh failed';
489
- // CRITICAL: 401 means token is definitively invalid - always discard
490
- // This prevents redirect loops where viability check keeps returning canRefresh:true
491
- let discardToken = refreshResponse.status === 401 || idpError.discard_token === true;
492
- const retryable = refreshResponse.status !== 401 && idpError.retryable === true;
493
- const resolution = idpError.resolution || null;
494
-
495
- // ============================================================================
496
- // HIGH VISIBILITY: REFRESH TOKEN FAILURE DIAGNOSTICS
497
- // ============================================================================
498
- // Translate error codes to human-readable explanations
499
- const failureReasons: Record<string, string> = {
500
- 'UNAUTHORIZED': 'Token was already used (rotation) OR token is expired OR token was revoked',
501
- 'INVALID_REFRESH_TOKEN': 'Token format invalid OR token not found in IDP database',
502
- 'TOKEN_EXPIRED': 'Refresh token exceeded its max lifetime',
503
- 'TOKEN_REVOKED': 'Token was explicitly revoked (logout, password change, admin action)',
504
- 'TOKEN_REUSE_DETECTED': 'Same refresh token used twice - possible token theft, all tokens revoked',
505
- 'UPSTREAM_SERVICE_ERROR': 'IDP internal error - token may have been rotated before failure',
506
- 'INTERNAL_ERROR': 'IDP internal error - check IDP logs for details',
507
- 'RATE_LIMITED': 'Too many refresh attempts - try again later',
508
- };
509
-
510
- const whyItFailed = failureReasons[errorCode] || 'Unknown error - check IDP logs';
511
-
512
- console.error('╔══════════════════════════════════════════════════════════════════════════════╗');
513
- console.error('║ ❌ REFRESH TOKEN FAILURE - DIAGNOSTIC REPORT ║');
514
- console.error('╠══════════════════════════════════════════════════════════════════════════════╣');
515
- console.error(`║ HTTP Status: ${String(refreshResponse.status).padEnd(60)}║`);
516
- console.error(`║ Error Code: ${errorCode.padEnd(60)}║`);
517
- console.error(`║ Discard Token: ${String(discardToken).padEnd(60)}║`);
518
- console.error(`║ Retryable: ${String(retryable).padEnd(60)}║`);
519
- console.error('╠──────────────────────────────────────────────────────────────────────────────╣');
520
- console.error(`║ WHY IT FAILED: ║`);
521
- console.error(`║ ${whyItFailed.substring(0, 76).padEnd(76)} ║`);
522
- console.error('╠──────────────────────────────────────────────────────────────────────────────╣');
523
- console.error(`║ User: ${(userId || 'unknown').substring(0, 60).padEnd(60)}║`);
524
- console.error(`║ Session: ${tokenLogId(sessionToken)} ║`);
525
- console.error(`║ Resolution: ${(resolution || 'Check IDP logs').substring(0, 60).padEnd(60)}║`);
526
- console.error('╚══════════════════════════════════════════════════════════════════════════════╝');
527
-
528
- // NEVER the raw envelope (DotNetPert-Scout, PR 139 review / mail 58691): this used to print
529
- // a stringified dump of responseData (first 1000 chars) - the same whole IDP body the :618
530
- // "unsuccessful" branch stopped logging. The box above already carries status, code and
531
- // flags; add envelope SHAPE only (key names), never values.
532
- console.error('[AUTH_REFRESH] IDP error response shape', {
533
- bodyKeys: responseData && typeof responseData === 'object' ? Object.keys(responseData) : undefined,
534
- errorKeys: idpError && typeof idpError === 'object' ? Object.keys(idpError) : undefined,
535
- errorHasMessage: !!idpError.message,
536
- });
537
-
538
- // If IDP signals to discard token, clear it from Redis
539
- if (discardToken) {
540
- // Race-safe discard (CAS): only clear if the refresh token we sent is STILL
541
- // the current one. A concurrent refresh may have already rotated it — our 401
542
- // is then stale, and wiping here would silently log out a healthy session
543
- // (the rotation-race wipe, QA 6873 site 2). Skip the wipe + drop terminal if changed.
544
- const postRefresh = await getSession(sessionToken);
545
- const tokenUnchanged =
546
- !!originalRefreshToken && postRefresh?.idpRefreshToken === originalRefreshToken;
547
- if (tokenUnchanged) {
548
- console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: IDP signaled discard_token', {
549
- reason: 'IDP_DISCARD_TOKEN',
550
- errorCode,
551
- userId,
552
- sessionToken: tokenLogId(sessionToken),
553
- hadRefreshToken: !!currentSession.idpRefreshToken,
554
- stack: new Error().stack
555
- });
556
- await updateSession(sessionToken, {
557
- idpRefreshToken: '',
558
- idpRefreshTokenExpires: undefined,
559
- refreshTokenClearedAt: Date.now(),
560
- refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${errorCode}`
561
- });
562
- } else {
563
- // Concurrent refresh already rotated the token — our 401 is stale. Return the
564
- // freshly-rotated token directly so the loser succeeds without a retry
565
- // (race-fix follow-up #1).
566
- discardToken = false;
567
- console.warn('[AUTH_REFRESH] Discard suppressed: refresh token already rotated by a concurrent refresh — returning the fresh token (race-safe).');
568
- if (postRefresh?.idpAccessToken && postRefresh.idpAccessTokenExpires) {
569
- return NextResponse.json({
570
- refreshed: true,
571
- reason: 'completed_by_concurrent_request',
572
- accessTokenExpires: postRefresh.idpAccessTokenExpires,
573
- hasRefreshToken: !!postRefresh.idpRefreshToken,
574
- });
575
- }
576
- }
577
- }
578
-
579
- return NextResponse.json({
580
- error: errorMessage,
581
- code: errorCode,
582
- discardToken,
583
- retryable,
584
- resolution,
585
- status: refreshResponse.status
586
- }, { status: refreshResponse.status });
587
- }
588
-
589
- // Validate PayEz canonical envelope for success responses
590
- const isCompliant = responseData && typeof responseData === 'object' &&
591
- Object.prototype.hasOwnProperty.call(responseData, 'success') &&
592
- (responseData.success === true ? Object.prototype.hasOwnProperty.call(responseData, 'data') : Object.prototype.hasOwnProperty.call(responseData, 'error')) &&
593
- Object.prototype.hasOwnProperty.call(responseData, 'meta');
594
-
595
- if (!isCompliant) {
596
- // NEVER the raw body (DotNetPert-Scout, mail 58678): a non-compliant response has an
597
- // UNKNOWN shape by definition, so there is no basis to assume it is free of tokens or
598
- // other IDP-side secrets. Log shape metadata only - same "kind, never value" reasoning
599
- // as the session-store.ts / refresh.ts REFRESH_TOKEN_REMOVAL fixes in PR 138.
600
- console.error('[AUTH_REFRESH] Upstream non-compliant IDP response', {
601
- bodyType: typeof responseData,
602
- bodyKeys: responseData && typeof responseData === 'object' ? Object.keys(responseData) : undefined,
603
- hasSuccessKey: !!(responseData && typeof responseData === 'object' && Object.prototype.hasOwnProperty.call(responseData, 'success')),
604
- hasMetaKey: !!(responseData && typeof responseData === 'object' && Object.prototype.hasOwnProperty.call(responseData, 'meta')),
605
- });
606
- return NextResponse.json({
607
- error: 'Upstream non-compliance',
608
- code: 'UPSTREAM_SERVICE_ERROR',
609
- retryable: true,
610
- discardToken: false
611
- }, { status: 502 });
612
- }
613
-
614
- if (responseData.success !== true || !responseData.data) {
615
- // Handle success:false with structured error
616
- const idpError = responseData?.error || {};
617
- const errorCode = idpError.code || 'UPSTREAM_VALIDATION_ERROR';
618
- const discardToken = idpError.discard_token === true;
619
- const retryable = idpError.retryable === true;
620
-
621
- // NEVER the raw envelope (DotNetPert-Scout, mail 58678): `responseData` here is the
622
- // WHOLE IDP response body, and its `error` sub-object's contents are not something this
623
- // handler controls or has audited - printing it wholesale would ship whatever the IDP
624
- // decided to put there straight to the log sink. Log the fields THIS handler already
625
- // extracts and acts on (retryable, plus envelope-shape flags for diagnosis) instead.
626
- console.warn('[AUTH_REFRESH] IDP refresh unsuccessful', {
627
- code: errorCode,
628
- discardToken,
629
- retryable,
630
- errorHasMessage: !!idpError.message,
631
- errorKeys: Object.keys(idpError),
632
- });
633
-
634
- if (discardToken) {
635
- console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: IDP success:false with discard_token', {
636
- reason: 'IDP_SUCCESS_FALSE_DISCARD',
637
- errorCode,
638
- userId,
639
- sessionToken: tokenLogId(sessionToken),
640
- hadRefreshToken: !!currentSession.idpRefreshToken,
641
- stack: new Error().stack
642
- });
643
- await updateSession(sessionToken, {
644
- idpRefreshToken: '',
645
- idpRefreshTokenExpires: undefined,
646
- refreshTokenClearedAt: Date.now(),
647
- refreshTokenClearedReason: `IDP_SUCCESS_FALSE_DISCARD:${errorCode}`
648
- });
649
- }
650
-
651
- return NextResponse.json({
652
- error: idpError.message || 'Token refresh failed',
653
- code: errorCode,
654
- discardToken,
655
- retryable,
656
- resolution: idpError.resolution || null
657
- }, { status: refreshResponse.status || 400 });
658
- }
659
-
660
- // Extract tokens from response
661
- const payload = responseData.data as any;
662
- const accessToken = payload?.access_token;
663
- const refreshToken = payload?.refresh_token;
664
-
665
- if (!accessToken) {
666
- console.error('[AUTH_REFRESH] Missing access token in IDP response');
667
- return NextResponse.json({
668
- error: 'Invalid token response from IDP',
669
- code: 'INTERNAL_SERVER_ERROR'
670
- }, { status: 500 });
671
- }
672
-
673
- // Decode and compute token expiries
674
- let decodedAccessToken: any;
675
- let accessTokenExpires: number;
676
- let computedRefreshTokenExpires: number | undefined;
677
- try {
678
- const result = computeTokenExpiries({ accessToken, refreshToken, preferJwt: true });
679
- decodedAccessToken = result.decodedAccessToken;
680
- accessTokenExpires = result.accessTokenExpires;
681
- computedRefreshTokenExpires = result.refreshTokenExpires;
682
-
683
- console.info('[AUTH_REFRESH] Successfully decoded new token pair', {
684
- accessTokenExpires: new Date(accessTokenExpires).toISOString(),
685
- refreshTokenExpires: computedRefreshTokenExpires ? new Date(computedRefreshTokenExpires).toISOString() : null
686
- });
687
- } catch (decodeError) {
688
- console.error('[AUTH_REFRESH] Failed to compute token expiries', { error: decodeError });
689
- return NextResponse.json({
690
- error: 'Failed to decode JWT tokens',
691
- code: 'INTERNAL_SERVER_ERROR'
692
- }, { status: 500 });
693
- }
694
-
695
- // Extract 2FA claims from the new access token
696
- let amrClaims: string[] = [];
697
- if (decodedAccessToken.amr) {
698
- try {
699
- amrClaims = typeof decodedAccessToken.amr === 'string'
700
- ? JSON.parse(decodedAccessToken.amr)
701
- : decodedAccessToken.amr;
702
- } catch (e) {
703
- console.warn('[AUTH_REFRESH] Failed to parse AMR claims', { amr: decodedAccessToken.amr });
704
- amrClaims = currentSession.authenticationMethods || [];
705
- }
706
- } else {
707
- amrClaims = currentSession.authenticationMethods || [];
708
- }
709
- const acrLevel = String(decodedAccessToken.acr || currentSession.authenticationLevel || '1');
710
-
711
- // Extract MFA timing claims
712
- const mfaTime = decodedAccessToken.mfa_time ? parseInt(decodedAccessToken.mfa_time) * 1000 : currentSession.mfaCompletedAt;
713
- const mfaExpires = decodedAccessToken.mfa_expires ? parseInt(decodedAccessToken.mfa_expires) * 1000 : currentSession.mfaExpiresAt;
714
- const mfaValidityHours = decodedAccessToken.mfa_validity_hours ? parseInt(decodedAccessToken.mfa_validity_hours) : currentSession.mfaValidityHours;
715
-
716
- // Update session with new tokens
717
- const hasNewRefresh = typeof refreshToken === 'string' && refreshToken.length > 0;
718
- const newRefreshTokenExpires = hasNewRefresh ? computedRefreshTokenExpires : undefined;
719
-
720
- // CRITICAL: Log if we're about to clear the refresh token due to missing new token
721
- if (!hasNewRefresh && currentSession.idpRefreshToken) {
722
- console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: No new refresh token in IDP response', {
723
- reason: 'NO_NEW_REFRESH_TOKEN_IN_RESPONSE',
724
- userId,
725
- sessionToken: tokenLogId(sessionToken),
726
- hadRefreshToken: true,
727
- // Kind only, never the value (credential). This branch runs when refreshToken is not a
728
- // non-empty string, so the old raw value was almost always empty - but a token-shaped
729
- // non-string would have been printed. refreshTokenType below already carries the kind.
730
- refreshTokenFromResponsePresent: refreshToken !== undefined && refreshToken !== null && refreshToken !== '',
731
- refreshTokenType: typeof refreshToken,
732
- payloadKeys: Object.keys(payload || {}),
733
- stack: new Error().stack
734
- });
735
- }
736
-
737
- // Keep existing refresh token if IDP doesn't provide a new one
738
- // Only clear if IDP explicitly signals discard_token=true (handled in error paths)
739
- // NOTE: Use normalized field names (idp* prefix) per SessionData interface
740
-
741
- // CRITICAL: Extract kid from JWT header - IDP may rotate keys during refresh
742
- const newBearerKeyId = extractKidFromToken(accessToken);
743
- if (newBearerKeyId) {
744
- console.log('[AUTH_REFRESH] Extracted bearerKeyId (kid) from new JWT header:', newBearerKeyId);
745
- } else {
746
- console.warn('[AUTH_REFRESH] No kid found in new JWT header');
747
- }
748
-
749
- const sessionUpdate: any = {
750
- ...currentSession,
751
- idpAccessToken: accessToken,
752
- idpAccessTokenExpires: accessTokenExpires,
753
- idpRefreshToken: hasNewRefresh ? (refreshToken as string) : currentSession.idpRefreshToken,
754
- idpRefreshTokenExpires: hasNewRefresh ? newRefreshTokenExpires : currentSession.idpRefreshTokenExpires,
755
- decodedAccessToken: decodedAccessToken,
756
- // Bearer key ID from JWT header (may change on key rotation)
757
- bearerKeyId: newBearerKeyId || currentSession.bearerKeyId,
758
- authenticationMethods: amrClaims,
759
- authenticationLevel: acrLevel,
760
- // 170007 rule 2 (BAPert ruling on 121350): '||' is the wrong operator
761
- // for a fact with an expiry. mfaVerified is derived from amrClaims,
762
- // which is FRESH every exchange (decoded from the token just
763
- // returned, falling back to the prior session's amr only on a
764
- // genuine decode failure a few lines up — not on "this token
765
- // doesn't currently carry mfa"). ORing in currentSession.mfaVerified
766
- // made the flag MONOTONIC against a claim that DECAYS (mfa_time,
767
- // 10 minutes): once true, it stayed true forever regardless of
768
- // what the current token actually says, silently re-opening the
769
- // 2FA bypass with no bug in the bridge. Re-derive only.
770
- mfaVerified: amrClaims.includes('mfa'),
771
- mfaCompletedAt: mfaTime,
772
- mfaExpiresAt: mfaExpires,
773
- mfaValidityHours: mfaValidityHours
774
- };
775
-
776
- await updateSession(sessionToken, sessionUpdate);
777
-
778
- console.info('[AUTH_REFRESH] Token refreshed successfully', {
779
- expiresAt: new Date(accessTokenExpires).toISOString(),
780
- userId,
781
- hasNewRefreshToken: hasNewRefresh,
782
- hasAccessToken: !!accessToken // PAY-1427: was tokenPreview (first 20 chars of the new access token)
783
- });
784
-
785
- } catch (error) {
786
- console.error('[AUTH_REFRESH] Error during token refresh', { error });
787
- return NextResponse.json({
788
- error: 'Token refresh error',
789
- code: 'INTERNAL_SERVER_ERROR'
790
- }, { status: 500 });
791
- } finally {
792
- if (weAcquiredLock) {
793
- await releaseRefreshLock(sessionToken, requestId, releaseLockVersion);
794
- }
795
- }
796
-
797
- // Load the latest session to return basic status
798
- const latest = await getSession(sessionToken);
799
- if (!latest?.idpAccessToken) {
800
- return NextResponse.json({
801
- error: 'No access token after refresh',
802
- code: 'INTERNAL_SERVER_ERROR'
803
- }, { status: 500 });
804
- }
805
-
806
- return NextResponse.json({
807
- refreshed: true,
808
- accessTokenExpires: latest.idpAccessTokenExpires,
809
- hasRefreshToken: !!latest.idpRefreshToken,
810
- });
811
- } catch (err: any) {
812
- console.error('[AUTH_REFRESH] Unexpected error', { error: err?.message || String(err) });
813
- return NextResponse.json({
814
- error: 'Unexpected error during refresh',
815
- code: 'INTERNAL_SERVER_ERROR'
816
- }, { status: 500 });
817
- }
818
- };
819
- }
820
-
821
- /**
822
- * Default POST export — drop-in for `app/api/auth/refresh/route.ts`.
823
- * Requires environment variables: IDP_URL, CLIENT_ID
824
- */
825
- export const POST = createRefreshHandler({
826
- idpBaseUrl: process.env.IDP_URL!,
827
- clientId: process.env.CLIENT_ID || 'payez_default_client',
828
- refreshEndpoint: '/api/ExternalAuth/refresh'
829
- });
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 (the ONLY way a session is established here)
8
+ * - PayEz IDP refresh token exchange
9
+ * - Session state updates with new tokens
10
+ * - Proper error handling and logging
11
+ * - Single-use semantics enforcement
12
+ *
13
+ * ── 246007: THE `x-session-token` FALLBACK IS GONE. IT NOW HARD-DENIES. ────
14
+ * This list previously advertised an x-session-token fallback for internal
15
+ * callers as a current capability. That behaviour has been REMOVED: a request presenting `x-session-token` with
16
+ * no cookie-resolved session of its own is rejected 401 UNAUTHORIZED, because
17
+ * there is no session to establish ownership of the token it names. Accepting
18
+ * it let a caller refresh a session they did not own.
19
+ *
20
+ * The line is corrected here rather than merely deleted, and it is called out
21
+ * rather than quietly dropped, because THIS DOCSTRING SHIPS. It is compiled
22
+ * into dist and into the .d.ts, so it reaches every consumer's editor as hover
23
+ * text. QAPert-NightHawk caught that the fixed build was still describing the
24
+ * removed capability as a current feature (34973) — her verifier tests code
25
+ * properties and reads no comments, so a correct build could have published
26
+ * documentation asserting the opposite of what the code does.
27
+ *
28
+ * That matters twice over: an internal caller relying on the fallback gets a
29
+ * silent behavioural break while the docs promise it still works, and a
30
+ * published package that misdescribes its own auth behaviour is the same
31
+ * amplifier that put other defects onto three surfaces — shipped code carrying
32
+ * instructions for using it wrongly.
33
+ *
34
+ * @version 2.1
35
+ */
36
+
37
+ import { NextRequest, NextResponse } from 'next/server';
38
+ import { randomUUID } from 'crypto';
39
+ import { getSession as getBetterAuthSession } from '../../server/auth';
40
+ import { getSession, updateSession, acquireRefreshLock, releaseRefreshLock, checkRefreshLock } from '../../lib/session-store';
41
+ import { computeTokenExpiries } from '../../lib/token-expiry';
42
+ import { extractKidFromToken } from '../../auth/utils/token-utils';
43
+ import { tokenLogId } from '../../lib/token-log-id';
44
+
45
+ interface RefreshConfig {
46
+ idpBaseUrl: string;
47
+ clientId: string;
48
+ refreshEndpoint?: string;
49
+ }
50
+
51
+ /**
52
+ * Creates a refresh token handler for Next.js API routes
53
+ *
54
+ * @param config IDP connection settings (Better Auth handles session crypto)
55
+ * @returns Next.js POST handler function
56
+ *
57
+ * @example
58
+ * ```typescript
59
+ * // In your app's /app/api/auth/refresh/route.ts
60
+ * import { createRefreshHandler } from '@payez/next-mvp/api-handlers/auth/refresh';
61
+ *
62
+ * export const POST = createRefreshHandler({
63
+ * idpBaseUrl: process.env.IDP_URL!,
64
+ * clientId: process.env.CLIENT_ID!,
65
+ * refreshEndpoint: '/api/ExternalAuth/refresh'
66
+ * });
67
+ * ```
68
+ */
69
+ export function createRefreshHandler(config: RefreshConfig) {
70
+ const { idpBaseUrl, clientId, refreshEndpoint = '/api/ExternalAuth/refresh' } = config;
71
+
72
+ return async function POST(req: NextRequest) {
73
+ try {
74
+ // Extract session from Better Auth
75
+ const betterAuthSession = await getBetterAuthSession(req);
76
+
77
+ let sessionToken = (betterAuthSession?.session?.token) as string | undefined;
78
+ let userId = betterAuthSession?.user?.id;
79
+
80
+ if (!sessionToken) {
81
+ // OWNERSHIP CHECK on the x-session-token fallback (246007). BAPert ruled
82
+ // 34708: ship it, accept the one-time re-login cost, do not gate on 240654.
83
+ //
84
+ // WHAT THIS CLOSED: the fallback took a session token from a CALLER-SUPPLIED
85
+ // header and acted on that session with no check that it belonged to the
86
+ // caller. /api/auth/* is in publicRoutes on the consuming surfaces, so an
87
+ // UNAUTHENTICATED caller who knew any session token could cause that session
88
+ // to be refreshed -- an identity assumption, and the precondition for the
89
+ // forced-logout chain via IDP reuse detection.
90
+ //
91
+ // WHY THE CHECK IS A HARD DENY RATHER THAN A COMPARISON, which is the part
92
+ // worth understanding before anyone "improves" it: this branch is reached
93
+ // ONLY when getBetterAuthSession() failed to resolve a session from the
94
+ // request's own cookie. The natural ownership check -- "the header may only
95
+ // name the session the caller's cookie already resolves to" -- therefore has
96
+ // nothing to compare against by construction. Any correct ownership test at
97
+ // this point evaluates to reject. So the check and outright removal coincide
98
+ // here; a more permissive version is not possible without moving the check
99
+ // somewhere it can actually see an authenticated identity.
100
+ //
101
+ // WHO THIS AFFECTS, and it is a deliberate accepted cost, not an oversight:
102
+ // internal callers (lib/internal-api internalRefresh, from api-handler and
103
+ // idp-fetch) forward the end user's cookie. Where that cookie resolves they
104
+ // never enter this branch and are unaffected. Where it does not -- sessions
105
+ // minted before the graft dual-write (8a5ac21) whose cookie carries only the
106
+ // __Secure- name, which the pre-240654 resolver could not read -- the refresh
107
+ // now fails and the user re-authenticates ONCE. Their next login writes both
108
+ // cookie names and they are permanently fine. That population cannot grow, does
109
+ // not age out (session TTL slides on every write), and does not self-heal on
110
+ // use (this handler sets no cookie) -- so the only ways out were to accept the
111
+ // re-login or leave the defect live. BAPert chose to accept it.
112
+ //
113
+ // If server-to-server refresh must work WITHOUT an end-user cookie, that needs
114
+ // real service authentication -- a shared secret, mTLS, or a network policy --
115
+ // never a header any client can set.
116
+ const headerSessionToken = req.headers.get('x-session-token');
117
+ if (headerSessionToken) {
118
+ console.warn(
119
+ '[AUTH_REFRESH] REJECTED x-session-token: no cookie-resolved session to own it. ' +
120
+ 'A header-named session is not proof of ownership; the caller must present a ' +
121
+ 'session cookie this server can resolve.',
122
+ );
123
+ return NextResponse.json(
124
+ {
125
+ error: 'Session ownership could not be established',
126
+ code: 'UNAUTHORIZED',
127
+ },
128
+ { status: 401 },
129
+ );
130
+ }
131
+ }
132
+
133
+ if (!sessionToken || !userId) {
134
+ console.warn('[AUTH_REFRESH] Missing sessionToken or user id on token');
135
+ return NextResponse.json({
136
+ error: 'No session available for refresh',
137
+ code: 'UNAUTHORIZED'
138
+ }, { status: 401 });
139
+ }
140
+
141
+ // Get current session
142
+ const currentSession = await getSession(sessionToken);
143
+ // NOTE: Field is idpRefreshToken (not refreshToken) per normalized naming convention
144
+ if (!currentSession?.idpRefreshToken) {
145
+ console.warn('[AUTH_REFRESH] No refresh token available', { userId });
146
+ return NextResponse.json({
147
+ error: 'No refresh token available',
148
+ code: 'NO_REFRESH_TOKEN', // Terminal state - session cannot be refreshed
149
+ terminal: true, // Signal to frontend: don't retry, redirect to login
150
+ resolution: 'User must re-authenticate'
151
+ }, { status: 401 });
152
+ }
153
+
154
+ // ============================================================================
155
+ // HIGH VISIBILITY: PRE-FLIGHT REFRESH DIAGNOSTICS
156
+ // ============================================================================
157
+ const now = Date.now();
158
+ const refreshTokenAge = currentSession.idpRefreshTokenIssuedAt
159
+ ? Math.round((now - currentSession.idpRefreshTokenIssuedAt) / 1000)
160
+ : 'unknown';
161
+ const refreshTokenExpiresIn = currentSession.idpRefreshTokenExpires
162
+ ? Math.round((currentSession.idpRefreshTokenExpires - now) / 1000)
163
+ : 'unknown';
164
+ const accessTokenExpiresIn = currentSession.idpAccessTokenExpires
165
+ ? Math.round((currentSession.idpAccessTokenExpires - now) / 1000)
166
+ : 'unknown';
167
+
168
+ console.log('╔══════════════════════════════════════════════════════════════════════════════╗');
169
+ console.log('║ REFRESH TOKEN ATTEMPT - PRE-FLIGHT ║');
170
+ console.log('╠══════════════════════════════════════════════════════════════════════════════╣');
171
+ console.log(`║ User: ${(userId || 'unknown').substring(0, 50).padEnd(50)} ║`);
172
+ console.log(`║ Session: ${tokenLogId(sessionToken)} ║`);
173
+ console.log('╠──────────────────────────────────────────────────────────────────────────────╣');
174
+ console.log(`║ Access Token Expires In: ${String(accessTokenExpiresIn).padEnd(10)} seconds ║`);
175
+ console.log(`║ Refresh Token Age: ${String(refreshTokenAge).padEnd(10)} seconds ║`);
176
+ console.log(`║ Refresh Token Expires In: ${String(refreshTokenExpiresIn).padEnd(10)} seconds ║`);
177
+ console.log(`║ Refresh Token Length: ${String(currentSession.idpRefreshToken?.length || 0).padEnd(10)} chars ║`);
178
+ console.log(`║ 2FA Complete: ${String(!!currentSession.mfaVerified).padEnd(10)} ║`);
179
+ console.log(`║ Auth Level (ACR): ${String(currentSession.authenticationLevel || '1').padEnd(10)} ║`);
180
+ console.log('╚══════════════════════════════════════════════════════════════════════════════╝');
181
+
182
+ // LOCK IDENTITY IS MINTED SERVER-SIDE. It must never come from a header.
183
+ //
184
+ // This was `req.headers.get('x-request-id') ?? \`refresh_${Date.now()}\``, and the
185
+ // value was used as the lock's `acquiredBy`. Below it, a branch let a caller
186
+ // PROCEED WITHOUT THE LOCK when the existing lock's acquiredBy equalled the id
187
+ // they had supplied. Since the caller chose that id, the branch was a lock
188
+ // bypass requiring no guesswork: send N concurrent requests all carrying the
189
+ // same x-request-id, and every one after the first skips the wait.
190
+ //
191
+ // That completed an attacker-controlled forced logout, because the serialisation
192
+ // is what prevents refresh-token REUSE, and the IDP treats reuse as compromise:
193
+ // IdentityRefreshTokenService:166 -> RevokeDescendantTokens ->
194
+ // IdentityRefreshTokenRepository:142 revokes every ACTIVE token for that USER
195
+ // issued after the reused one -- across all devices and all client apps. So a
196
+ // caller who knew one session token could log a user out everywhere by sending
197
+ // a few requests with a header value of their own choosing.
198
+ //
199
+ // Minting server-side removes the bypass by construction: no caller can produce
200
+ // a value that matches an in-flight lock, so the equality branch can never be
201
+ // true and is deleted rather than left inert -- an unreachable branch that
202
+ // trusts a header is a trap for whoever edits this next.
203
+ //
204
+ // The anti-deadlock intent that branch served is preserved by the wait path
205
+ // below, which is the correct handling anyway: a genuine duplicate waits for the
206
+ // in-flight refresh and returns its result instead of starting a second one.
207
+ // The header is still read, for TRACING ONLY, and never influences control flow.
208
+ const traceId = req.headers.get('x-request-id') ?? undefined;
209
+ const requestId = `refresh_${randomUUID()}`;
210
+ const lockAcquired = await acquireRefreshLock(sessionToken, requestId, 5000);
211
+
212
+ let weAcquiredLock = false;
213
+ let releaseLockVersion: number | undefined;
214
+
215
+ if (!lockAcquired.acquired) {
216
+ {
217
+ // Wait for the lock to release, then check if token is now fresh
218
+ console.info('[AUTH_REFRESH] Refresh in progress by another request, waiting for completion...', { requestId, traceId });
219
+
220
+ const maxWaitMs = 5000;
221
+ const checkIntervalMs = 200;
222
+ const startWait = Date.now();
223
+
224
+ while (Date.now() - startWait < maxWaitMs) {
225
+ await new Promise(resolve => setTimeout(resolve, checkIntervalMs));
226
+
227
+ // Check if lock was released
228
+ const lockCheck = await checkRefreshLock(sessionToken);
229
+ if (!lockCheck) {
230
+ // Lock released - check if token is now fresh
231
+ const refreshedSession = await getSession(sessionToken);
232
+ if (refreshedSession?.idpAccessToken && refreshedSession?.idpAccessTokenExpires) {
233
+ const timeUntilExpiry = refreshedSession.idpAccessTokenExpires - Date.now();
234
+ const fiveMinutes = 5 * 60 * 1000;
235
+
236
+ if (timeUntilExpiry > fiveMinutes) {
237
+ console.info('[AUTH_REFRESH] Lock released, token is fresh - returning success', {
238
+ requestId,
239
+ accessTokenExpires: new Date(refreshedSession.idpAccessTokenExpires).toISOString(),
240
+ waitedMs: Date.now() - startWait,
241
+ });
242
+ return NextResponse.json({
243
+ refreshed: true,
244
+ reason: 'completed_by_concurrent_request',
245
+ accessTokenExpires: refreshedSession.idpAccessTokenExpires,
246
+ hasRefreshToken: !!refreshedSession.idpRefreshToken,
247
+ });
248
+ }
249
+ }
250
+ // Lock released but token not fresh - try to acquire lock ourselves
251
+ break;
252
+ }
253
+ }
254
+
255
+ // Still locked after waiting - return 409
256
+ console.warn('[AUTH_REFRESH] Refresh still in progress after waiting', { requestId, waitedMs: Date.now() - startWait });
257
+ return NextResponse.json({
258
+ error: 'Refresh already in progress',
259
+ code: 'CONFLICT'
260
+ }, { status: 409 });
261
+ }
262
+ } else {
263
+ weAcquiredLock = true;
264
+ releaseLockVersion = lockAcquired.lockInfo?.lockVersion;
265
+ }
266
+
267
+ // Before performing network refresh, re-check if tokens are already fresh
268
+ const latestAfterLock = await getSession(sessionToken);
269
+ const fiveMinutes = 5 * 60 * 1000;
270
+ const timeUntilExpiry = latestAfterLock?.idpAccessTokenExpires
271
+ ? latestAfterLock.idpAccessTokenExpires - Date.now()
272
+ : -1;
273
+
274
+ // CRITICAL: Also check the actual JWT's exp claim - Redis might have stale data
275
+ let actualJwtExpMs = -1;
276
+ let tokenMismatch = false;
277
+ if (latestAfterLock?.idpAccessToken) {
278
+ try {
279
+ const tokenParts = latestAfterLock.idpAccessToken.split('.');
280
+ if (tokenParts.length === 3) {
281
+ const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64url').toString());
282
+ actualJwtExpMs = (payload.exp || 0) * 1000;
283
+ const now = Date.now();
284
+ // If the actual JWT is expired, we MUST refresh regardless of what Redis says
285
+ if (actualJwtExpMs < now) {
286
+ tokenMismatch = true;
287
+ }
288
+ }
289
+ } catch (e) {
290
+ // If we can't decode, proceed with normal logic
291
+ }
292
+ }
293
+
294
+ console.log('[AUTH_REFRESH] Pre-refresh check:', {
295
+ userId,
296
+ hasAccessToken: !!latestAfterLock?.idpAccessToken,
297
+ accessTokenExpires: latestAfterLock?.idpAccessTokenExpires
298
+ ? new Date(latestAfterLock.idpAccessTokenExpires).toISOString()
299
+ : 'undefined',
300
+ actualJwtExp: actualJwtExpMs > 0 ? new Date(actualJwtExpMs).toISOString() : 'unknown',
301
+ now: new Date().toISOString(),
302
+ timeUntilExpiryMs: timeUntilExpiry,
303
+ tokenMismatch,
304
+ willRefresh: timeUntilExpiry <= fiveMinutes || tokenMismatch
305
+ });
306
+
307
+ // Only skip refresh if BOTH Redis says fresh AND the actual JWT is not expired
308
+ if (latestAfterLock?.idpAccessToken && latestAfterLock?.idpAccessTokenExpires &&
309
+ timeUntilExpiry > fiveMinutes && !tokenMismatch) {
310
+ console.info('[AUTH_REFRESH] Skipping refresh: tokens already fresh', { userId, timeUntilExpiryMs: timeUntilExpiry });
311
+ if (weAcquiredLock) {
312
+ await releaseRefreshLock(sessionToken, requestId, releaseLockVersion);
313
+ weAcquiredLock = false;
314
+ }
315
+ return NextResponse.json({
316
+ refreshed: false,
317
+ reason: 'already_fresh',
318
+ accessTokenExpires: latestAfterLock.idpAccessTokenExpires,
319
+ hasRefreshToken: !!latestAfterLock.idpRefreshToken,
320
+ });
321
+ }
322
+
323
+ // If we detected a token mismatch, log it prominently
324
+ if (tokenMismatch) {
325
+ console.warn('[AUTH_REFRESH] Token mismatch detected - forcing refresh despite Redis claiming fresh', {
326
+ userId,
327
+ redisExpires: latestAfterLock?.idpAccessTokenExpires ? new Date(latestAfterLock.idpAccessTokenExpires).toISOString() : 'N/A',
328
+ actualJwtExp: new Date(actualJwtExpMs).toISOString()
329
+ });
330
+ }
331
+
332
+ // Copy refresh token for use in IDP call
333
+ // Note: Token is NOT cleared preemptively - it will be replaced on success.
334
+ // The lock mechanism prevents concurrent refresh attempts within the same session.
335
+ // If IDP call fails, user can retry with the same token.
336
+ const originalRefreshToken = currentSession.idpRefreshToken as string;
337
+
338
+ try {
339
+ // Extract 2FA claims from current session
340
+ let authMethods: string[] = [];
341
+ if (currentSession.authenticationMethods) {
342
+ if (typeof currentSession.authenticationMethods === 'string') {
343
+ try {
344
+ authMethods = JSON.parse(currentSession.authenticationMethods);
345
+ } catch (e) {
346
+ console.warn('[AUTH_REFRESH] Failed to parse authenticationMethods', { authenticationMethods: currentSession.authenticationMethods });
347
+ }
348
+ } else if (Array.isArray(currentSession.authenticationMethods)) {
349
+ authMethods = currentSession.authenticationMethods;
350
+ }
351
+ }
352
+
353
+ // For OAuth sessions with empty AMR claims, provide defaults
354
+ // OAuth IS a form of multi-factor auth (you authenticated with another provider)
355
+ let isOAuthSession = !!currentSession.oauthProvider;
356
+ if (authMethods.length === 0 && isOAuthSession) {
357
+ // OAuth sessions get default AMR claims
358
+ authMethods = ['pwd', 'mfa'];
359
+ console.log('[AUTH_REFRESH] OAuth session with empty AMR - using defaults:', {
360
+ oauthProvider: currentSession.oauthProvider,
361
+ defaultAmr: authMethods
362
+ });
363
+ }
364
+
365
+ // Check authMethods first, then fallback to twoFactorMethod field (set by transitionTo2FASession)
366
+ // For OAuth sessions, use 'oauth' as the 2FA method since OAuth itself is the authentication
367
+ const twoFactorMethod = authMethods.find(m => ['sms', 'totp', 'email'].includes(m))
368
+ || currentSession.mfaMethod
369
+ || (isOAuthSession ? 'oauth' : null);
370
+
371
+ // DEBUG: Log what we have for 2FA
372
+ console.log('[AUTH_REFRESH] 2FA Debug:', {
373
+ authMethods,
374
+ authMethodsRaw: currentSession.authenticationMethods,
375
+ twoFactorMethodFromSession: currentSession.mfaMethod,
376
+ twoFactorMethodResolved: twoFactorMethod,
377
+ twoFactorComplete: currentSession.mfaVerified,
378
+ sessionKeys: Object.keys(currentSession)
379
+ });
380
+
381
+ // Build refresh request body per PayEz wire standard (snake_case)
382
+ // See: 2FA-TOKEN-REFRESH-CONTEXT.md - "The Ninja Shit"
383
+ // For OAuth sessions with 2FA complete, use ACR level 2
384
+ let acrValue = String(currentSession.authenticationLevel ?? '1');
385
+ if (currentSession.oauthProvider && currentSession.mfaVerified && acrValue === '1') {
386
+ acrValue = '2'; // OAuth with 2FA complete = full authentication
387
+ }
388
+ const refreshRequestBody: any = {
389
+ refresh_token: originalRefreshToken,
390
+ amr: authMethods,
391
+ acr: acrValue
392
+ };
393
+
394
+ // DEBUG: Log exact request body
395
+ console.log('[AUTH_REFRESH] Request body:', JSON.stringify({
396
+ refresh_token: '***REDACTED***',
397
+ amr: authMethods,
398
+ acr: acrValue,
399
+ acrType: typeof acrValue
400
+ }));
401
+
402
+ const twoFactorVerified = !!currentSession.mfaVerified;
403
+ if (twoFactorVerified) {
404
+ refreshRequestBody.two_factor_verified = true;
405
+ }
406
+ if (twoFactorMethod) {
407
+ refreshRequestBody.two_factor_method = twoFactorMethod;
408
+ }
409
+ if (currentSession.mfaCompletedAt) {
410
+ refreshRequestBody.two_factor_completed_at = new Date(currentSession.mfaCompletedAt).toISOString();
411
+ }
412
+
413
+ // Log the full request details for debugging
414
+ console.log('[AUTH_REFRESH] Sending refresh request:', {
415
+ url: `${idpBaseUrl}${refreshEndpoint}`,
416
+ clientId,
417
+ hasRefreshToken: !!refreshRequestBody.refresh_token,
418
+ refreshTokenLength: refreshRequestBody.refresh_token?.length,
419
+ amr: refreshRequestBody.amr,
420
+ acr: refreshRequestBody.acr
421
+ });
422
+
423
+ let refreshResponse: Response;
424
+ try {
425
+ refreshResponse = await fetch(`${idpBaseUrl}${refreshEndpoint}`, {
426
+ method: 'POST',
427
+ headers: {
428
+ 'Content-Type': 'application/json',
429
+ 'X-Client-Id': clientId,
430
+ },
431
+ body: JSON.stringify(refreshRequestBody),
432
+ });
433
+ } catch (fetchError) {
434
+ // Network error - IDP unreachable
435
+ // Note: Lock will be released by finally block
436
+ console.error('[AUTH_REFRESH] IDP unreachable:', {
437
+ error: fetchError instanceof Error ? fetchError.message : String(fetchError),
438
+ idpUrl: idpBaseUrl,
439
+ userId
440
+ });
441
+ return NextResponse.json({
442
+ error: 'IDP service unavailable',
443
+ code: 'UPSTREAM_SERVICE_UNAVAILABLE',
444
+ retryable: true,
445
+ discardToken: false
446
+ }, { status: 503 });
447
+ }
448
+
449
+ // Parse response body - handle empty or invalid JSON
450
+ let responseData: any;
451
+ try {
452
+ const responseText = await refreshResponse.text();
453
+ if (!responseText || responseText.trim() === '') {
454
+ // Note: Lock will be released by finally block
455
+ console.error('[AUTH_REFRESH] Empty response from IDP:', {
456
+ status: refreshResponse.status,
457
+ statusText: refreshResponse.statusText,
458
+ userId
459
+ });
460
+ return NextResponse.json({
461
+ error: 'Empty response from IDP',
462
+ code: 'UPSTREAM_SERVICE_ERROR',
463
+ retryable: true,
464
+ discardToken: false
465
+ }, { status: 502 });
466
+ }
467
+ responseData = JSON.parse(responseText);
468
+ } catch (parseError) {
469
+ // Note: Lock will be released by finally block
470
+ console.error('[AUTH_REFRESH] Failed to parse IDP response:', {
471
+ error: parseError instanceof Error ? parseError.message : String(parseError),
472
+ status: refreshResponse.status,
473
+ userId
474
+ });
475
+ return NextResponse.json({
476
+ error: 'Invalid response from IDP',
477
+ code: 'UPSTREAM_SERVICE_ERROR',
478
+ retryable: true,
479
+ discardToken: false
480
+ }, { status: 502 });
481
+ }
482
+
483
+ // Handle non-OK responses with structured error format
484
+ if (!refreshResponse.ok) {
485
+ // Parse structured error from IDP
486
+ const idpError = responseData?.error || {};
487
+ const errorCode = idpError.code || 'UNKNOWN_ERROR';
488
+ const errorMessage = idpError.message || 'Token refresh failed';
489
+ // CRITICAL: 401 means token is definitively invalid - always discard
490
+ // This prevents redirect loops where viability check keeps returning canRefresh:true
491
+ let discardToken = refreshResponse.status === 401 || idpError.discard_token === true;
492
+ const retryable = refreshResponse.status !== 401 && idpError.retryable === true;
493
+ const resolution = idpError.resolution || null;
494
+
495
+ // ============================================================================
496
+ // HIGH VISIBILITY: REFRESH TOKEN FAILURE DIAGNOSTICS
497
+ // ============================================================================
498
+ // Translate error codes to human-readable explanations
499
+ const failureReasons: Record<string, string> = {
500
+ 'UNAUTHORIZED': 'Token was already used (rotation) OR token is expired OR token was revoked',
501
+ 'INVALID_REFRESH_TOKEN': 'Token format invalid OR token not found in IDP database',
502
+ 'TOKEN_EXPIRED': 'Refresh token exceeded its max lifetime',
503
+ 'TOKEN_REVOKED': 'Token was explicitly revoked (logout, password change, admin action)',
504
+ 'TOKEN_REUSE_DETECTED': 'Same refresh token used twice - possible token theft, all tokens revoked',
505
+ 'UPSTREAM_SERVICE_ERROR': 'IDP internal error - token may have been rotated before failure',
506
+ 'INTERNAL_ERROR': 'IDP internal error - check IDP logs for details',
507
+ 'RATE_LIMITED': 'Too many refresh attempts - try again later',
508
+ };
509
+
510
+ const whyItFailed = failureReasons[errorCode] || 'Unknown error - check IDP logs';
511
+
512
+ console.error('╔══════════════════════════════════════════════════════════════════════════════╗');
513
+ console.error('║ ❌ REFRESH TOKEN FAILURE - DIAGNOSTIC REPORT ║');
514
+ console.error('╠══════════════════════════════════════════════════════════════════════════════╣');
515
+ console.error(`║ HTTP Status: ${String(refreshResponse.status).padEnd(60)}║`);
516
+ console.error(`║ Error Code: ${errorCode.padEnd(60)}║`);
517
+ console.error(`║ Discard Token: ${String(discardToken).padEnd(60)}║`);
518
+ console.error(`║ Retryable: ${String(retryable).padEnd(60)}║`);
519
+ console.error('╠──────────────────────────────────────────────────────────────────────────────╣');
520
+ console.error(`║ WHY IT FAILED: ║`);
521
+ console.error(`║ ${whyItFailed.substring(0, 76).padEnd(76)} ║`);
522
+ console.error('╠──────────────────────────────────────────────────────────────────────────────╣');
523
+ console.error(`║ User: ${(userId || 'unknown').substring(0, 60).padEnd(60)}║`);
524
+ console.error(`║ Session: ${tokenLogId(sessionToken)} ║`);
525
+ console.error(`║ Resolution: ${(resolution || 'Check IDP logs').substring(0, 60).padEnd(60)}║`);
526
+ console.error('╚══════════════════════════════════════════════════════════════════════════════╝');
527
+
528
+ // NEVER the raw envelope (DotNetPert-Scout, PR 139 review / mail 58691): this used to print
529
+ // a stringified dump of responseData (first 1000 chars) - the same whole IDP body the :618
530
+ // "unsuccessful" branch stopped logging. The box above already carries status, code and
531
+ // flags; add envelope SHAPE only (key names), never values.
532
+ console.error('[AUTH_REFRESH] IDP error response shape', {
533
+ bodyKeys: responseData && typeof responseData === 'object' ? Object.keys(responseData) : undefined,
534
+ errorKeys: idpError && typeof idpError === 'object' ? Object.keys(idpError) : undefined,
535
+ errorHasMessage: !!idpError.message,
536
+ });
537
+
538
+ // If IDP signals to discard token, clear it from Redis
539
+ if (discardToken) {
540
+ // Race-safe discard (CAS): only clear if the refresh token we sent is STILL
541
+ // the current one. A concurrent refresh may have already rotated it — our 401
542
+ // is then stale, and wiping here would silently log out a healthy session
543
+ // (the rotation-race wipe, QA 6873 site 2). Skip the wipe + drop terminal if changed.
544
+ const postRefresh = await getSession(sessionToken);
545
+ const tokenUnchanged =
546
+ !!originalRefreshToken && postRefresh?.idpRefreshToken === originalRefreshToken;
547
+ if (tokenUnchanged) {
548
+ console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: IDP signaled discard_token', {
549
+ reason: 'IDP_DISCARD_TOKEN',
550
+ errorCode,
551
+ userId,
552
+ sessionToken: tokenLogId(sessionToken),
553
+ hadRefreshToken: !!currentSession.idpRefreshToken,
554
+ stack: new Error().stack
555
+ });
556
+ await updateSession(sessionToken, {
557
+ idpRefreshToken: '',
558
+ idpRefreshTokenExpires: undefined,
559
+ refreshTokenClearedAt: Date.now(),
560
+ refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${errorCode}`
561
+ });
562
+ } else {
563
+ // Concurrent refresh already rotated the token — our 401 is stale. Return the
564
+ // freshly-rotated token directly so the loser succeeds without a retry
565
+ // (race-fix follow-up #1).
566
+ discardToken = false;
567
+ console.warn('[AUTH_REFRESH] Discard suppressed: refresh token already rotated by a concurrent refresh — returning the fresh token (race-safe).');
568
+ if (postRefresh?.idpAccessToken && postRefresh.idpAccessTokenExpires) {
569
+ return NextResponse.json({
570
+ refreshed: true,
571
+ reason: 'completed_by_concurrent_request',
572
+ accessTokenExpires: postRefresh.idpAccessTokenExpires,
573
+ hasRefreshToken: !!postRefresh.idpRefreshToken,
574
+ });
575
+ }
576
+ }
577
+ }
578
+
579
+ return NextResponse.json({
580
+ error: errorMessage,
581
+ code: errorCode,
582
+ discardToken,
583
+ retryable,
584
+ resolution,
585
+ status: refreshResponse.status
586
+ }, { status: refreshResponse.status });
587
+ }
588
+
589
+ // Validate PayEz canonical envelope for success responses
590
+ const isCompliant = responseData && typeof responseData === 'object' &&
591
+ Object.prototype.hasOwnProperty.call(responseData, 'success') &&
592
+ (responseData.success === true ? Object.prototype.hasOwnProperty.call(responseData, 'data') : Object.prototype.hasOwnProperty.call(responseData, 'error')) &&
593
+ Object.prototype.hasOwnProperty.call(responseData, 'meta');
594
+
595
+ if (!isCompliant) {
596
+ // NEVER the raw body (DotNetPert-Scout, mail 58678): a non-compliant response has an
597
+ // UNKNOWN shape by definition, so there is no basis to assume it is free of tokens or
598
+ // other IDP-side secrets. Log shape metadata only - same "kind, never value" reasoning
599
+ // as the session-store.ts / refresh.ts REFRESH_TOKEN_REMOVAL fixes in PR 138.
600
+ console.error('[AUTH_REFRESH] Upstream non-compliant IDP response', {
601
+ bodyType: typeof responseData,
602
+ bodyKeys: responseData && typeof responseData === 'object' ? Object.keys(responseData) : undefined,
603
+ hasSuccessKey: !!(responseData && typeof responseData === 'object' && Object.prototype.hasOwnProperty.call(responseData, 'success')),
604
+ hasMetaKey: !!(responseData && typeof responseData === 'object' && Object.prototype.hasOwnProperty.call(responseData, 'meta')),
605
+ });
606
+ return NextResponse.json({
607
+ error: 'Upstream non-compliance',
608
+ code: 'UPSTREAM_SERVICE_ERROR',
609
+ retryable: true,
610
+ discardToken: false
611
+ }, { status: 502 });
612
+ }
613
+
614
+ if (responseData.success !== true || !responseData.data) {
615
+ // Handle success:false with structured error
616
+ const idpError = responseData?.error || {};
617
+ const errorCode = idpError.code || 'UPSTREAM_VALIDATION_ERROR';
618
+ const discardToken = idpError.discard_token === true;
619
+ const retryable = idpError.retryable === true;
620
+
621
+ // NEVER the raw envelope (DotNetPert-Scout, mail 58678): `responseData` here is the
622
+ // WHOLE IDP response body, and its `error` sub-object's contents are not something this
623
+ // handler controls or has audited - printing it wholesale would ship whatever the IDP
624
+ // decided to put there straight to the log sink. Log the fields THIS handler already
625
+ // extracts and acts on (retryable, plus envelope-shape flags for diagnosis) instead.
626
+ console.warn('[AUTH_REFRESH] IDP refresh unsuccessful', {
627
+ code: errorCode,
628
+ discardToken,
629
+ retryable,
630
+ errorHasMessage: !!idpError.message,
631
+ errorKeys: Object.keys(idpError),
632
+ });
633
+
634
+ if (discardToken) {
635
+ console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: IDP success:false with discard_token', {
636
+ reason: 'IDP_SUCCESS_FALSE_DISCARD',
637
+ errorCode,
638
+ userId,
639
+ sessionToken: tokenLogId(sessionToken),
640
+ hadRefreshToken: !!currentSession.idpRefreshToken,
641
+ stack: new Error().stack
642
+ });
643
+ await updateSession(sessionToken, {
644
+ idpRefreshToken: '',
645
+ idpRefreshTokenExpires: undefined,
646
+ refreshTokenClearedAt: Date.now(),
647
+ refreshTokenClearedReason: `IDP_SUCCESS_FALSE_DISCARD:${errorCode}`
648
+ });
649
+ }
650
+
651
+ return NextResponse.json({
652
+ error: idpError.message || 'Token refresh failed',
653
+ code: errorCode,
654
+ discardToken,
655
+ retryable,
656
+ resolution: idpError.resolution || null
657
+ }, { status: refreshResponse.status || 400 });
658
+ }
659
+
660
+ // Extract tokens from response
661
+ const payload = responseData.data as any;
662
+ const accessToken = payload?.access_token;
663
+ const refreshToken = payload?.refresh_token;
664
+
665
+ if (!accessToken) {
666
+ console.error('[AUTH_REFRESH] Missing access token in IDP response');
667
+ return NextResponse.json({
668
+ error: 'Invalid token response from IDP',
669
+ code: 'INTERNAL_SERVER_ERROR'
670
+ }, { status: 500 });
671
+ }
672
+
673
+ // Decode and compute token expiries
674
+ let decodedAccessToken: any;
675
+ let accessTokenExpires: number;
676
+ let computedRefreshTokenExpires: number | undefined;
677
+ try {
678
+ const result = computeTokenExpiries({ accessToken, refreshToken, preferJwt: true });
679
+ decodedAccessToken = result.decodedAccessToken;
680
+ accessTokenExpires = result.accessTokenExpires;
681
+ computedRefreshTokenExpires = result.refreshTokenExpires;
682
+
683
+ console.info('[AUTH_REFRESH] Successfully decoded new token pair', {
684
+ accessTokenExpires: new Date(accessTokenExpires).toISOString(),
685
+ refreshTokenExpires: computedRefreshTokenExpires ? new Date(computedRefreshTokenExpires).toISOString() : null
686
+ });
687
+ } catch (decodeError) {
688
+ console.error('[AUTH_REFRESH] Failed to compute token expiries', { error: decodeError });
689
+ return NextResponse.json({
690
+ error: 'Failed to decode JWT tokens',
691
+ code: 'INTERNAL_SERVER_ERROR'
692
+ }, { status: 500 });
693
+ }
694
+
695
+ // Extract 2FA claims from the new access token
696
+ let amrClaims: string[] = [];
697
+ if (decodedAccessToken.amr) {
698
+ try {
699
+ amrClaims = typeof decodedAccessToken.amr === 'string'
700
+ ? JSON.parse(decodedAccessToken.amr)
701
+ : decodedAccessToken.amr;
702
+ } catch (e) {
703
+ console.warn('[AUTH_REFRESH] Failed to parse AMR claims', { amr: decodedAccessToken.amr });
704
+ amrClaims = currentSession.authenticationMethods || [];
705
+ }
706
+ } else {
707
+ amrClaims = currentSession.authenticationMethods || [];
708
+ }
709
+ const acrLevel = String(decodedAccessToken.acr || currentSession.authenticationLevel || '1');
710
+
711
+ // Extract MFA timing claims
712
+ const mfaTime = decodedAccessToken.mfa_time ? parseInt(decodedAccessToken.mfa_time) * 1000 : currentSession.mfaCompletedAt;
713
+ const mfaExpires = decodedAccessToken.mfa_expires ? parseInt(decodedAccessToken.mfa_expires) * 1000 : currentSession.mfaExpiresAt;
714
+ const mfaValidityHours = decodedAccessToken.mfa_validity_hours ? parseInt(decodedAccessToken.mfa_validity_hours) : currentSession.mfaValidityHours;
715
+
716
+ // Update session with new tokens
717
+ const hasNewRefresh = typeof refreshToken === 'string' && refreshToken.length > 0;
718
+ const newRefreshTokenExpires = hasNewRefresh ? computedRefreshTokenExpires : undefined;
719
+
720
+ // CRITICAL: Log if we're about to clear the refresh token due to missing new token
721
+ if (!hasNewRefresh && currentSession.idpRefreshToken) {
722
+ console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: No new refresh token in IDP response', {
723
+ reason: 'NO_NEW_REFRESH_TOKEN_IN_RESPONSE',
724
+ userId,
725
+ sessionToken: tokenLogId(sessionToken),
726
+ hadRefreshToken: true,
727
+ // Kind only, never the value (credential). This branch runs when refreshToken is not a
728
+ // non-empty string, so the old raw value was almost always empty - but a token-shaped
729
+ // non-string would have been printed. refreshTokenType below already carries the kind.
730
+ refreshTokenFromResponsePresent: refreshToken !== undefined && refreshToken !== null && refreshToken !== '',
731
+ refreshTokenType: typeof refreshToken,
732
+ payloadKeys: Object.keys(payload || {}),
733
+ stack: new Error().stack
734
+ });
735
+ }
736
+
737
+ // Keep existing refresh token if IDP doesn't provide a new one
738
+ // Only clear if IDP explicitly signals discard_token=true (handled in error paths)
739
+ // NOTE: Use normalized field names (idp* prefix) per SessionData interface
740
+
741
+ // CRITICAL: Extract kid from JWT header - IDP may rotate keys during refresh
742
+ const newBearerKeyId = extractKidFromToken(accessToken);
743
+ if (newBearerKeyId) {
744
+ console.log('[AUTH_REFRESH] Extracted bearerKeyId (kid) from new JWT header:', newBearerKeyId);
745
+ } else {
746
+ console.warn('[AUTH_REFRESH] No kid found in new JWT header');
747
+ }
748
+
749
+ const sessionUpdate: any = {
750
+ ...currentSession,
751
+ idpAccessToken: accessToken,
752
+ idpAccessTokenExpires: accessTokenExpires,
753
+ idpRefreshToken: hasNewRefresh ? (refreshToken as string) : currentSession.idpRefreshToken,
754
+ idpRefreshTokenExpires: hasNewRefresh ? newRefreshTokenExpires : currentSession.idpRefreshTokenExpires,
755
+ decodedAccessToken: decodedAccessToken,
756
+ // Bearer key ID from JWT header (may change on key rotation)
757
+ bearerKeyId: newBearerKeyId || currentSession.bearerKeyId,
758
+ authenticationMethods: amrClaims,
759
+ authenticationLevel: acrLevel,
760
+ // 170007 rule 2 (BAPert ruling on 121350): '||' is the wrong operator
761
+ // for a fact with an expiry. mfaVerified is derived from amrClaims,
762
+ // which is FRESH every exchange (decoded from the token just
763
+ // returned, falling back to the prior session's amr only on a
764
+ // genuine decode failure a few lines up — not on "this token
765
+ // doesn't currently carry mfa"). ORing in currentSession.mfaVerified
766
+ // made the flag MONOTONIC against a claim that DECAYS (mfa_time,
767
+ // 10 minutes): once true, it stayed true forever regardless of
768
+ // what the current token actually says, silently re-opening the
769
+ // 2FA bypass with no bug in the bridge. Re-derive only.
770
+ mfaVerified: amrClaims.includes('mfa'),
771
+ mfaCompletedAt: mfaTime,
772
+ mfaExpiresAt: mfaExpires,
773
+ mfaValidityHours: mfaValidityHours
774
+ };
775
+
776
+ await updateSession(sessionToken, sessionUpdate);
777
+
778
+ console.info('[AUTH_REFRESH] Token refreshed successfully', {
779
+ expiresAt: new Date(accessTokenExpires).toISOString(),
780
+ userId,
781
+ hasNewRefreshToken: hasNewRefresh,
782
+ hasAccessToken: !!accessToken // PAY-1427: was tokenPreview (first 20 chars of the new access token)
783
+ });
784
+
785
+ } catch (error) {
786
+ console.error('[AUTH_REFRESH] Error during token refresh', { error });
787
+ return NextResponse.json({
788
+ error: 'Token refresh error',
789
+ code: 'INTERNAL_SERVER_ERROR'
790
+ }, { status: 500 });
791
+ } finally {
792
+ if (weAcquiredLock) {
793
+ await releaseRefreshLock(sessionToken, requestId, releaseLockVersion);
794
+ }
795
+ }
796
+
797
+ // Load the latest session to return basic status
798
+ const latest = await getSession(sessionToken);
799
+ if (!latest?.idpAccessToken) {
800
+ return NextResponse.json({
801
+ error: 'No access token after refresh',
802
+ code: 'INTERNAL_SERVER_ERROR'
803
+ }, { status: 500 });
804
+ }
805
+
806
+ return NextResponse.json({
807
+ refreshed: true,
808
+ accessTokenExpires: latest.idpAccessTokenExpires,
809
+ hasRefreshToken: !!latest.idpRefreshToken,
810
+ });
811
+ } catch (err: any) {
812
+ console.error('[AUTH_REFRESH] Unexpected error', { error: err?.message || String(err) });
813
+ return NextResponse.json({
814
+ error: 'Unexpected error during refresh',
815
+ code: 'INTERNAL_SERVER_ERROR'
816
+ }, { status: 500 });
817
+ }
818
+ };
819
+ }
820
+
821
+ /**
822
+ * Default POST export — drop-in for `app/api/auth/refresh/route.ts`.
823
+ * Requires environment variables: IDP_URL, CLIENT_ID
824
+ */
825
+ export const POST = createRefreshHandler({
826
+ idpBaseUrl: process.env.IDP_URL!,
827
+ clientId: process.env.CLIENT_ID || 'payez_default_client',
828
+ refreshEndpoint: '/api/ExternalAuth/refresh'
829
+ });