@payez/next-mvp 4.8.1 → 4.8.3

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 (314) hide show
  1. package/POST-INSTALL.md +408 -408
  2. package/README.md +859 -859
  3. package/dist/api-handlers/admin/audit.js +13 -21
  4. package/dist/api-handlers/admin/sessions.js +59 -45
  5. package/dist/api-handlers/admin/stats.js +1 -1
  6. package/dist/api-handlers/admin/users.js +15 -23
  7. package/dist/api-handlers/admin/vibe-data.d.ts +5 -0
  8. package/dist/api-handlers/admin/vibe-data.js +5 -0
  9. package/dist/components/account/MobileNavDrawer.js +18 -18
  10. package/dist/components/account/UserAvatarMenu.js +1 -1
  11. package/dist/components/admin/AlertSettingsTab.js +40 -40
  12. package/dist/components/admin/LoggingSettingsTab.js +28 -28
  13. package/dist/lib/session-store.js +21 -21
  14. package/dist/lib/startup-init.js +19 -19
  15. package/dist/pages/login/page.js +28 -28
  16. package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
  17. package/dist/vibe/client.d.ts +1 -1
  18. package/dist/vibe/client.js +2 -1
  19. package/dist/vibe/filters.d.ts +36 -0
  20. package/dist/vibe/filters.js +47 -0
  21. package/dist/vibe/generic.js +2 -1
  22. package/dist/vibe/sessions.js +28 -3
  23. package/package.json +2 -2
  24. package/scripts/check-publish-eol.mjs +33 -15
  25. package/src/api/auth-handler.ts +550 -550
  26. package/src/api/index.ts +17 -17
  27. package/src/api-handlers/account/change-password.ts +170 -170
  28. package/src/api-handlers/account/masked-info.ts +46 -46
  29. package/src/api-handlers/account/profile.ts +99 -99
  30. package/src/api-handlers/account/recovery/forward.ts +117 -117
  31. package/src/api-handlers/account/recovery/initiate.ts +59 -59
  32. package/src/api-handlers/account/recovery/send-code.ts +21 -21
  33. package/src/api-handlers/account/recovery/verify-code.ts +20 -20
  34. package/src/api-handlers/account/reset-password.ts +87 -87
  35. package/src/api-handlers/account/send-code.ts +70 -70
  36. package/src/api-handlers/account/update-phone.ts +84 -84
  37. package/src/api-handlers/account/validate-password.ts +139 -139
  38. package/src/api-handlers/account/verify-email.ts +134 -134
  39. package/src/api-handlers/account/verify-sms.ts +134 -134
  40. package/src/api-handlers/admin/analytics.ts +452 -452
  41. package/src/api-handlers/admin/audit.ts +223 -232
  42. package/src/api-handlers/admin/index.ts +63 -63
  43. package/src/api-handlers/admin/redis-sessions.ts +259 -259
  44. package/src/api-handlers/admin/sessions.ts +341 -327
  45. package/src/api-handlers/admin/site-logs.ts +388 -388
  46. package/src/api-handlers/admin/stats.ts +260 -260
  47. package/src/api-handlers/admin/users.ts +240 -251
  48. package/src/api-handlers/admin/vibe-data.ts +338 -333
  49. package/src/api-handlers/anon/preferences.ts +123 -123
  50. package/src/api-handlers/auth/credential-login-email-code.ts +92 -92
  51. package/src/api-handlers/auth/credential-login.ts +948 -948
  52. package/src/api-handlers/auth/jwks.ts +31 -31
  53. package/src/api-handlers/auth/login.ts +252 -252
  54. package/src/api-handlers/auth/passkey-signin.ts +116 -116
  55. package/src/api-handlers/auth/refresh.ts +829 -829
  56. package/src/api-handlers/auth/signout.ts +201 -201
  57. package/src/api-handlers/auth/status.ts +23 -23
  58. package/src/api-handlers/auth/update-session.ts +144 -144
  59. package/src/api-handlers/auth/validate.ts +45 -45
  60. package/src/api-handlers/auth/verify-code.ts +116 -116
  61. package/src/api-handlers/mfa/backup-code-verify.ts +170 -170
  62. package/src/api-handlers/mfa/backup-codes.ts +42 -42
  63. package/src/api-handlers/mfa/methods.ts +47 -47
  64. package/src/api-handlers/mfa/totp-confirm.ts +43 -43
  65. package/src/api-handlers/mfa/totp-enroll.ts +43 -43
  66. package/src/api-handlers/mfa/totp-verify.ts +172 -172
  67. package/src/api-handlers/oauth/resume.ts +182 -182
  68. package/src/api-handlers/onboarding/send-email-code.ts +18 -18
  69. package/src/api-handlers/onboarding/set-password.ts +21 -21
  70. package/src/api-handlers/onboarding/verify-code.ts +20 -20
  71. package/src/api-handlers/onboarding/verify-welcome-email.ts +17 -17
  72. package/src/api-handlers/passkeys/list.ts +32 -32
  73. package/src/api-handlers/passkeys/manage.ts +55 -55
  74. package/src/api-handlers/passkeys/register-options.ts +43 -43
  75. package/src/api-handlers/passkeys/register-verify.ts +42 -42
  76. package/src/api-handlers/session/refresh-viability.ts +56 -56
  77. package/src/api-handlers/session/viability.ts +238 -238
  78. package/src/api-handlers/test/force-expire.ts +167 -167
  79. package/src/auth/auth-decision.ts +292 -292
  80. package/src/auth/better-auth.ts +1688 -1688
  81. package/src/auth/idp-oauth.ts +310 -310
  82. package/src/auth/password-reset-bridge.ts +98 -98
  83. package/src/auth/revocation-fields.ts +16 -16
  84. package/src/auth/route-config.ts +236 -236
  85. package/src/auth/safe-callback.ts +199 -199
  86. package/src/auth/session-cookie-clear.ts +30 -30
  87. package/src/auth/session-reset-link.ts +159 -159
  88. package/src/auth/session-response-sanitizer.ts +151 -151
  89. package/src/auth/types/auth-types.ts +555 -555
  90. package/src/auth/types/index.ts +7 -7
  91. package/src/auth/unauthenticated-routes.ts +2 -2
  92. package/src/auth/utils/idp-client.ts +364 -364
  93. package/src/auth/utils/index.ts +6 -6
  94. package/src/auth/utils/token-utils.ts +243 -243
  95. package/src/client/AuthContext.tsx +192 -192
  96. package/src/client/better-auth-client.ts +70 -70
  97. package/src/client/fetch-with-auth.ts +47 -47
  98. package/src/client/fetchWithSession.ts +21 -21
  99. package/src/client/index.ts +13 -13
  100. package/src/client/useAnonSession.ts +131 -131
  101. package/src/components/SessionSync.tsx +138 -138
  102. package/src/components/SignalRHealthCheck.tsx +131 -131
  103. package/src/components/account/MobileNavDrawer.tsx +305 -305
  104. package/src/components/account/UserAvatarMenu.tsx +277 -277
  105. package/src/components/account/index.ts +13 -13
  106. package/src/components/admin/AlertSettingsTab.tsx +728 -728
  107. package/src/components/admin/AnalyticsTab.tsx +703 -703
  108. package/src/components/admin/DataBrowserTab.tsx +508 -508
  109. package/src/components/admin/LoggingSettingsTab.tsx +665 -665
  110. package/src/components/admin/SessionsTab.tsx +421 -421
  111. package/src/components/admin/StatsTab.tsx +379 -379
  112. package/src/components/admin/VibeAdminContext.tsx +87 -87
  113. package/src/components/admin/VibeAdminLayout.tsx +187 -187
  114. package/src/components/admin/index.ts +59 -59
  115. package/src/components/auth/CredentialSignInForm.tsx +240 -240
  116. package/src/components/auth/FederatedAuthSection.tsx +129 -129
  117. package/src/components/auth/ModeAwareLoginPage.tsx +146 -146
  118. package/src/components/auth/ModeAwareSignupPage.tsx +200 -200
  119. package/src/components/auth/PasswordResetBridge.tsx +30 -30
  120. package/src/components/auth/TraditionalAuthSection.tsx +103 -103
  121. package/src/components/recovery/CompleteStep.tsx +36 -36
  122. package/src/components/recovery/InitiateRecoveryStep.tsx +68 -68
  123. package/src/components/recovery/RequestedStep.tsx +30 -30
  124. package/src/components/recovery/SelectMethodStep.tsx +73 -73
  125. package/src/components/recovery/SetPasswordStep.tsx +118 -118
  126. package/src/components/recovery/VerifyCodeStep.tsx +90 -90
  127. package/src/components/reserved/ReservedRecoveryWarning.tsx +160 -160
  128. package/src/components/reserved/ReservedStatusBox.tsx +118 -118
  129. package/src/components/ui/BetaBadge.tsx +58 -58
  130. package/src/components/ui/Footer.tsx +93 -93
  131. package/src/config/env.ts +55 -55
  132. package/src/config/logger.ts +62 -62
  133. package/src/config/logging-config.ts +82 -82
  134. package/src/config/unauthenticated-routes.ts +19 -19
  135. package/src/config/vibe-log-transport.ts +264 -264
  136. package/src/edge/internal-api-url.ts +65 -65
  137. package/src/edge/middleware.ts +42 -42
  138. package/src/hooks/useAuth.ts +117 -117
  139. package/src/hooks/useAuthSettings.ts +109 -109
  140. package/src/hooks/useAvailableProviders.ts +143 -143
  141. package/src/hooks/usePasswordValidation.ts +127 -127
  142. package/src/hooks/useProfile.ts +82 -82
  143. package/src/hooks/usePublicAuthSettings.ts +171 -171
  144. package/src/hooks/useSessionExpiration.ts +101 -101
  145. package/src/hooks/useViabilitySession.ts +337 -337
  146. package/src/index.ts +64 -64
  147. package/src/lib/anon-session.ts +213 -213
  148. package/src/lib/api-handler.ts +643 -643
  149. package/src/lib/app-slug.ts +228 -228
  150. package/src/lib/authorize-resume.ts +172 -172
  151. package/src/lib/bearer-fetch.ts +31 -31
  152. package/src/lib/client-key.ts +88 -88
  153. package/src/lib/code-step-copy.ts +83 -83
  154. package/src/lib/credential-sign-in-client.ts +101 -101
  155. package/src/lib/demo-mode.ts +18 -18
  156. package/src/lib/device-id.ts +67 -67
  157. package/src/lib/ensure-fresh-access-token.ts +509 -509
  158. package/src/lib/geolocation.ts +265 -265
  159. package/src/lib/idp-client-config.ts +1005 -1005
  160. package/src/lib/idp-fetch.ts +169 -169
  161. package/src/lib/idp-url-guard.ts +37 -37
  162. package/src/lib/internal-api.ts +171 -171
  163. package/src/lib/jwt-decode-client.ts +45 -45
  164. package/src/lib/jwt-decode.ts +82 -82
  165. package/src/lib/lock-window.ts +49 -49
  166. package/src/lib/login-error-message.ts +41 -41
  167. package/src/lib/never-in-response.ts +64 -64
  168. package/src/lib/nextauth-secret.ts +168 -168
  169. package/src/lib/onboarding-proxy.ts +57 -57
  170. package/src/lib/password-rules.ts +50 -50
  171. package/src/lib/rate-limit-service.ts +9 -9
  172. package/src/lib/redis.ts +105 -105
  173. package/src/lib/refresh-token-validator.ts +64 -64
  174. package/src/lib/roles.ts +177 -177
  175. package/src/lib/safe-error-response.ts +149 -149
  176. package/src/lib/secret-validation.ts +8 -8
  177. package/src/lib/session-cookie-shape.ts +185 -185
  178. package/src/lib/session-store.ts +1092 -1092
  179. package/src/lib/session.ts +34 -34
  180. package/src/lib/single-flight.ts +64 -64
  181. package/src/lib/site-logger.ts +245 -245
  182. package/src/lib/standardized-client-api.ts +901 -901
  183. package/src/lib/startup-init.ts +276 -276
  184. package/src/lib/test-aware-get-token.ts +41 -41
  185. package/src/lib/token-expiry.ts +40 -40
  186. package/src/lib/token-lifecycle.ts +546 -546
  187. package/src/lib/token-log-id.ts +69 -69
  188. package/src/lib/twofa-rejection.ts +91 -91
  189. package/src/lib/types/api-responses.ts +336 -336
  190. package/src/lib/user-agent-parser.ts +252 -252
  191. package/src/logging/api/admin-analytics.ts +51 -51
  192. package/src/logging/api/audit-log.ts +53 -53
  193. package/src/logging/components/AdminAnalyticsLayout.tsx +49 -49
  194. package/src/logging/components/AuditLogViewer.tsx +125 -125
  195. package/src/logging/components/ErrorMetricsCard.tsx +98 -98
  196. package/src/logging/components/HealthMetricsCard.tsx +70 -70
  197. package/src/logging/hooks/useAdminAnalytics.ts +22 -22
  198. package/src/logging/hooks/useAuditLog.ts +24 -24
  199. package/src/logging/hooks/useErrorMetrics.ts +40 -40
  200. package/src/logging/hooks/useHealthMetrics.ts +44 -44
  201. package/src/logging/index.ts +18 -18
  202. package/src/logging/types/analytics.ts +81 -81
  203. package/src/logging/types/audit.ts +31 -31
  204. package/src/logging/types/index.ts +3 -3
  205. package/src/middleware/create-middleware.ts +1141 -1141
  206. package/src/middleware/rbac-check.ts +279 -279
  207. package/src/middleware/twofa-presets.ts +224 -224
  208. package/src/models/DecodedAccessToken.ts +16 -16
  209. package/src/models/SessionModel.ts +331 -331
  210. package/src/pages/admin-login/page.tsx +111 -111
  211. package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -527
  212. package/src/pages/admin-page-permissions/index.ts +7 -7
  213. package/src/pages/admin-roles/RolesAdminPage.tsx +533 -533
  214. package/src/pages/admin-roles/index.ts +9 -9
  215. package/src/pages/admin-roles/modals.tsx +469 -469
  216. package/src/pages/client-admin/ClientSiteAdminPage.tsx +382 -382
  217. package/src/pages/client-admin/index.ts +33 -33
  218. package/src/pages/coming-soon/page.tsx +83 -83
  219. package/src/pages/login/page.tsx +338 -338
  220. package/src/pages/onboarding/index.ts +11 -11
  221. package/src/pages/onboarding/page.tsx +212 -212
  222. package/src/pages/profile/EnhancedProfilePage.tsx +514 -514
  223. package/src/pages/profile/index.ts +9 -9
  224. package/src/pages/profile/page.tsx +166 -166
  225. package/src/pages/recovery/page.tsx +267 -267
  226. package/src/pages/roles/MyRolesPage.tsx +211 -211
  227. package/src/pages/roles/components.tsx +294 -294
  228. package/src/pages/roles/index.ts +17 -17
  229. package/src/pages/security/EnhancedSecurityPage.tsx +660 -660
  230. package/src/pages/security/index.ts +12 -12
  231. package/src/pages/security/page.tsx +507 -507
  232. package/src/pages/service-unavailable/page.tsx +63 -63
  233. package/src/pages/settings/EnhancedSettingsPage.tsx +642 -642
  234. package/src/pages/settings/index.ts +9 -9
  235. package/src/pages/settings/page.tsx +47 -47
  236. package/src/pages/showcase/ShowcasePage.tsx +532 -532
  237. package/src/pages/showcase/index.ts +13 -13
  238. package/src/pages/test-env/EmergencyLogoutPage.tsx +192 -192
  239. package/src/pages/test-env/JwtInspectPage.tsx +449 -449
  240. package/src/pages/test-env/RefreshTokenPage.tsx +178 -178
  241. package/src/pages/test-env/TestEnvPage.tsx +130 -130
  242. package/src/pages/test-env/guarded.tsx +81 -81
  243. package/src/pages/test-env/index.ts +77 -77
  244. package/src/pages/test-env/redact-credentials.ts +96 -96
  245. package/src/pages/verify-code/page.tsx +746 -746
  246. package/src/routes/account/index.ts +31 -31
  247. package/src/routes/account/masked-info.ts +36 -36
  248. package/src/routes/account/send-code.ts +39 -39
  249. package/src/routes/account/update-phone.ts +13 -13
  250. package/src/routes/account/verify-email.ts +40 -40
  251. package/src/routes/account/verify-sms.ts +40 -40
  252. package/src/routes/auth/credential-login-email-code.ts +6 -6
  253. package/src/routes/auth/credential-login.ts +6 -6
  254. package/src/routes/auth/index.ts +22 -22
  255. package/src/routes/auth/logout.ts +111 -111
  256. package/src/routes/auth/nextauth.ts +45 -45
  257. package/src/routes/auth/passkey-signin.ts +13 -13
  258. package/src/routes/auth/refresh.ts +77 -77
  259. package/src/routes/auth/session-reset.ts +37 -37
  260. package/src/routes/auth/session.ts +261 -261
  261. package/src/routes/auth/settings.ts +181 -181
  262. package/src/routes/auth/viability.ts +519 -519
  263. package/src/routes/index.ts +17 -17
  264. package/src/routes/mfa/backup-code-verify.ts +1 -1
  265. package/src/routes/mfa/backup-codes.ts +10 -10
  266. package/src/routes/mfa/methods.ts +22 -22
  267. package/src/routes/mfa/totp-confirm.ts +10 -10
  268. package/src/routes/mfa/totp-enroll.ts +10 -10
  269. package/src/routes/mfa/totp-verify.ts +14 -14
  270. package/src/routes/onboarding/index.ts +15 -15
  271. package/src/routes/onboarding/send-email-code.ts +2 -2
  272. package/src/routes/onboarding/set-password.ts +2 -2
  273. package/src/routes/onboarding/verify-code.ts +2 -2
  274. package/src/routes/onboarding/verify-welcome-email.ts +2 -2
  275. package/src/routes/passkeys/list.ts +10 -10
  276. package/src/routes/passkeys/manage.ts +23 -23
  277. package/src/routes/passkeys/register-options.ts +10 -10
  278. package/src/routes/passkeys/register-verify.ts +10 -10
  279. package/src/routes/session/index.ts +7 -7
  280. package/src/routes/session/refresh-viability.ts +17 -17
  281. package/src/server/auth-guard.ts +203 -203
  282. package/src/server/auth.ts +486 -486
  283. package/src/server/decode-session.ts +309 -309
  284. package/src/server/slim-middleware.ts +117 -117
  285. package/src/server/verified-session-token.ts +103 -103
  286. package/src/server/with-auth.ts +93 -93
  287. package/src/services/signalrActivityService.ts +258 -258
  288. package/src/stores/authStore.ts +1900 -1900
  289. package/src/templates/instrumentation.ts +60 -60
  290. package/src/theme/ThemeProvider.tsx +39 -39
  291. package/src/theme/default.ts +33 -33
  292. package/src/theme/index.ts +31 -31
  293. package/src/theme/types.ts +69 -69
  294. package/src/theme/useTheme.ts +57 -57
  295. package/src/theme/utils.ts +40 -40
  296. package/src/types/api.ts +12 -12
  297. package/src/types/auth.d.ts +23 -23
  298. package/src/types/auth.ts +48 -48
  299. package/src/types/logging.ts +11 -11
  300. package/src/types/recovery.ts +54 -54
  301. package/src/utils/api.ts +401 -401
  302. package/src/utils/circuitBreaker.ts +40 -40
  303. package/src/utils/error-message.ts +108 -108
  304. package/src/utils/layout/reservedSpace.ts +124 -124
  305. package/src/utils/logout.ts +30 -30
  306. package/src/vibe/client.ts +878 -877
  307. package/src/vibe/enterprise-auth.ts +208 -208
  308. package/src/vibe/errors.ts +209 -209
  309. package/src/vibe/filters.ts +52 -0
  310. package/src/vibe/generic.ts +340 -339
  311. package/src/vibe/hooks/index.ts +367 -367
  312. package/src/vibe/index.ts +133 -133
  313. package/src/vibe/sessions.ts +925 -899
  314. package/src/vibe/types.ts +577 -577
@@ -1,551 +1,551 @@
1
- /**
2
- * Enhanced Auth Handler with Coordinated Token Refresh
3
- *
4
- * Provides a middleware wrapper that automatically handles token lifecycle:
5
- * - Checks token expiry before each request
6
- * - Automatically refreshes expired or near-expired tokens
7
- * - Uses Redis locks for coordinated refresh (prevents race conditions)
8
- * - Retries requests on 401 responses with fresh tokens
9
- *
10
- * Pattern ported from website-membership simple-api-handler.ts
11
- *
12
- * @version 2.1.0
13
- * @since auth-ready-v2
14
- */
15
-
16
- import { NextRequest, NextResponse } from 'next/server';
17
- import { getSession as getBetterAuthSession } from '../server/auth';
18
- import { nanoid } from 'nanoid';
19
- import {
20
- getSession,
21
- updateSession,
22
- acquireRefreshLock,
23
- releaseRefreshLock,
24
- checkRefreshLock,
25
- type SessionData
26
- } from '../lib/session-store';
27
- import { tokenLogId } from '../lib/token-log-id';
28
-
29
-
30
- export interface AuthContext {
31
- token: any;
32
- accessToken: string;
33
- userId: string;
34
- sessionToken: string;
35
- refreshToken?: string;
36
- }
37
-
38
- export interface AuthHandlerOptions {
39
- /** Whether authentication is required for this route (default: true) */
40
- requireAuth?: boolean;
41
-
42
- /** Automatically refresh expired or near-expired tokens (default: true) */
43
- autoRefresh?: boolean;
44
-
45
- /** Buffer time in seconds before token expiry to trigger refresh (default: 300 = 5 minutes) */
46
- refreshBuffer?: number;
47
-
48
- /** Retry request on 401 response after refreshing token (default: true) */
49
- retryOn401?: boolean;
50
-
51
- /** Maximum number of retry attempts on 401 (default: 1) */
52
- maxRetries?: number;
53
-
54
- /** IDP base URL for refresh requests */
55
- idpBaseUrl?: string;
56
-
57
- /** OAuth client ID */
58
- clientId?: string;
59
- }
60
-
61
- export type HandlerFunction = (
62
- req: NextRequest,
63
- context: any,
64
- auth: AuthContext
65
- ) => Promise<NextResponse | Response>;
66
-
67
- interface RefreshResult {
68
- success: boolean;
69
- accessToken?: string;
70
- refreshToken?: string;
71
- expiresIn?: number;
72
- error?: string;
73
- }
74
-
75
- /**
76
- * Creates an auth-aware handler with automatic token refresh
77
- *
78
- * @example
79
- * ```typescript
80
- * import { createAuthHandler } from '@payez/next-mvp/api';
81
- *
82
- * const handler = createAuthHandler({ requireAuth: true });
83
- *
84
- * export const GET = handler.handle(async (req, context, auth) => {
85
- * // auth.accessToken is guaranteed to be fresh
86
- * const response = await fetch('https://api.example.com/data', {
87
- * headers: { 'Authorization': `Bearer ${auth.accessToken}` }
88
- * });
89
- * return NextResponse.json(await response.json());
90
- * });
91
- * ```
92
- */
93
- export function createAuthHandler(options: AuthHandlerOptions = {}) {
94
- const {
95
- requireAuth = true,
96
- autoRefresh = true,
97
- refreshBuffer = 60, // 60 seconds - matches website-membership proven threshold
98
- retryOn401 = true,
99
- maxRetries = 1,
100
- idpBaseUrl = process.env.IDP_URL,
101
- clientId = process.env.CLIENT_ID || process.env.NEXT_PUBLIC_IDP_CLIENT_ID
102
- } = options;
103
-
104
- /**
105
- * Performs coordinated token refresh with Redis locking
106
- * This prevents multiple concurrent requests from all trying to refresh simultaneously
107
- */
108
- async function performCoordinatedRefresh(
109
- sessionToken: string,
110
- requestId: string
111
- ): Promise<RefreshResult> {
112
- // Check if refresh is already in progress
113
- const existingLock = await checkRefreshLock(sessionToken);
114
-
115
- if (existingLock) {
116
- console.info('[AUTH_HANDLER] Refresh already in progress, waiting...', {
117
- requestId,
118
- lockOwner: existingLock.acquiredBy,
119
- lockAge: Date.now() - existingLock.acquiredAt
120
- });
121
-
122
- // Wait for the refresh to complete
123
- const waitResult = await waitForRefreshCompletion(sessionToken, requestId, 10000);
124
- if (waitResult.success) {
125
- // Get the fresh token from session
126
- const freshSession = await getSession(sessionToken);
127
- if (freshSession?.accessToken) {
128
- return {
129
- success: true,
130
- accessToken: freshSession.accessToken,
131
- refreshToken: freshSession.refreshToken,
132
- expiresIn: freshSession.accessTokenExpires
133
- ? Math.floor((freshSession.accessTokenExpires - Date.now()) / 1000)
134
- : undefined
135
- };
136
- }
137
- }
138
- return { success: false, error: waitResult.reason || 'Wait for refresh failed' };
139
- }
140
-
141
- // Try to acquire the refresh lock
142
- const lockAcquired = await acquireRefreshLock(sessionToken, requestId, 5000);
143
-
144
- if (!lockAcquired) {
145
- // Another request grabbed the lock, wait for it
146
- console.info('[AUTH_HANDLER] Failed to acquire lock, waiting for other request', { requestId });
147
- const waitResult = await waitForRefreshCompletion(sessionToken, requestId, 10000);
148
- if (waitResult.success) {
149
- const freshSession = await getSession(sessionToken);
150
- if (freshSession?.accessToken) {
151
- return {
152
- success: true,
153
- accessToken: freshSession.accessToken,
154
- refreshToken: freshSession.refreshToken
155
- };
156
- }
157
- }
158
- return { success: false, error: waitResult.reason || 'Wait for refresh failed' };
159
- }
160
-
161
- try {
162
- // Double-check if tokens are still stale after acquiring lock
163
- const latestSession = await getSession(sessionToken);
164
- if (latestSession && !tokenNeedsRefresh(latestSession, refreshBuffer)) {
165
- console.info('[AUTH_HANDLER] Tokens already fresh after acquiring lock, skipping refresh', { requestId });
166
- return {
167
- success: true,
168
- accessToken: latestSession.accessToken,
169
- refreshToken: latestSession.refreshToken
170
- };
171
- }
172
-
173
- // Actually perform the refresh
174
- return await executeRefresh(sessionToken, latestSession);
175
-
176
- } finally {
177
- // Always release the lock
178
- await releaseRefreshLock(sessionToken, requestId);
179
- }
180
- }
181
-
182
- /**
183
- * Wait for an in-progress refresh to complete
184
- */
185
- async function waitForRefreshCompletion(
186
- sessionToken: string,
187
- requestId: string,
188
- maxWaitMs: number
189
- ): Promise<{ success: boolean; reason?: string }> {
190
- const startTime = Date.now();
191
- const pollInterval = 100;
192
-
193
- while (Date.now() - startTime < maxWaitMs) {
194
- const lockExists = await checkRefreshLock(sessionToken);
195
-
196
- if (!lockExists) {
197
- // Lock released, check if tokens are fresh
198
- const session = await getSession(sessionToken);
199
- if (session?.accessToken && !tokenNeedsRefresh(session, refreshBuffer)) {
200
- return { success: true };
201
- } else {
202
- return { success: false, reason: 'Lock released but tokens not fresh' };
203
- }
204
- }
205
-
206
- await new Promise(resolve => setTimeout(resolve, pollInterval));
207
- }
208
-
209
- return { success: false, reason: `Timeout waiting for refresh (${maxWaitMs}ms)` };
210
- }
211
-
212
- /**
213
- * Check if token needs refresh based on expiry and buffer
214
- */
215
- function tokenNeedsRefresh(session: SessionData, bufferSeconds: number): boolean {
216
- if (!session.accessToken) return true;
217
-
218
- const expires = session.accessTokenExpires || 0;
219
- const bufferMs = bufferSeconds * 1000;
220
- const timeUntilExpiry = expires - Date.now();
221
-
222
- return timeUntilExpiry <= bufferMs;
223
- }
224
-
225
- /**
226
- * Execute the actual token refresh against IDP
227
- */
228
- async function executeRefresh(
229
- sessionToken: string,
230
- currentSession: SessionData | null
231
- ): Promise<RefreshResult> {
232
- try {
233
- if (!idpBaseUrl || !clientId) {
234
- console.error('[AUTH_HANDLER] Missing IDP configuration for refresh');
235
- return { success: false, error: 'Missing IDP configuration' };
236
- }
237
-
238
- if (!currentSession) {
239
- console.error('[AUTH_HANDLER] No session found for refresh');
240
- return { success: false, error: 'No session found' };
241
- }
242
-
243
- if (!currentSession.refreshToken) {
244
- console.error('[AUTH_HANDLER] No refresh token available');
245
- return { success: false, error: 'No refresh token' };
246
- }
247
-
248
- // Extract authentication methods from session
249
- const authMethods = (currentSession as any).authMethods ||
250
- ((currentSession as any).token?.amr ? JSON.parse((currentSession as any).token.amr) : ['pwd', 'mfa']);
251
- const authLevel = String((currentSession as any).authenticationLevel || (currentSession as any).token?.acr || '2');
252
- const twoFactorMethod = (currentSession as any).twoFactorMethod || 'authenticator';
253
-
254
- // Build refresh request body
255
- const refreshRequestBody: any = {
256
- refresh_token: currentSession.refreshToken,
257
- amr: authMethods,
258
- acr: authLevel
259
- };
260
-
261
- if ((currentSession as any).twoFactorComplete) {
262
- refreshRequestBody.two_factor_verified = true;
263
- }
264
- if (twoFactorMethod) {
265
- refreshRequestBody.two_factor_method = twoFactorMethod;
266
- }
267
- if ((currentSession as any).mfaCompletedAt) {
268
- refreshRequestBody.two_factor_completed_at = new Date((currentSession as any).mfaCompletedAt).toISOString();
269
- }
270
-
271
- console.info('[AUTH_HANDLER] Executing refresh against IDP', {
272
- sessionToken: tokenLogId(sessionToken),
273
- hasRefreshToken: true
274
- });
275
-
276
- const response = await fetch(`${idpBaseUrl}/api/ExternalAuth/refresh`, {
277
- method: 'POST',
278
- headers: {
279
- 'Content-Type': 'application/json',
280
- 'X-Client-Id': clientId,
281
- },
282
- body: JSON.stringify(refreshRequestBody),
283
- });
284
-
285
- if (!response.ok) {
286
- const errorText = await response.text().catch(() => 'Unknown error');
287
- // PAY-1427: never the raw upstream body (the refresh.ts:528 twin PR 139 fixed). Status + length only.
288
- console.error('[AUTH_HANDLER] Refresh failed:', { status: response.status, bodyLength: errorText.length });
289
- return { success: false, error: `Refresh failed: ${response.status}` };
290
- }
291
-
292
- const data = await response.json();
293
-
294
- if (data.success === false) {
295
- return { success: false, error: data.error?.message || data.message || 'Refresh failed' };
296
- }
297
-
298
- const tokenData = data.data || data;
299
-
300
- if (!tokenData.access_token) {
301
- console.error('[AUTH_HANDLER] No access token in refresh response');
302
- return { success: false, error: 'No access token received' };
303
- }
304
-
305
- // Update session with new tokens
306
- const updatedSession = {
307
- ...currentSession,
308
- accessToken: tokenData.access_token,
309
- refreshToken: tokenData.refresh_token || currentSession.refreshToken,
310
- accessTokenExpires: tokenData.expires_in
311
- ? Date.now() + (tokenData.expires_in * 1000)
312
- : Date.now() + (3600 * 1000),
313
- };
314
-
315
- await updateSession(sessionToken, updatedSession);
316
-
317
- console.info('[AUTH_HANDLER] Token refresh successful', {
318
- newExpiry: new Date(updatedSession.accessTokenExpires).toISOString()
319
- });
320
-
321
- return {
322
- success: true,
323
- accessToken: tokenData.access_token,
324
- refreshToken: tokenData.refresh_token,
325
- expiresIn: tokenData.expires_in,
326
- };
327
- } catch (error) {
328
- console.error('[AUTH_HANDLER] Refresh exception:', error);
329
- return { success: false, error: error instanceof Error ? error.message : 'Refresh failed' };
330
- }
331
- }
332
-
333
- /**
334
- * Checks if auth context token needs refresh based on expiry and buffer
335
- */
336
- function needsRefresh(auth: AuthContext): boolean {
337
- if (!autoRefresh) return false;
338
- // No refresh token = nothing to refresh with, skip entirely
339
- if (!auth.refreshToken) return false;
340
-
341
- // Check if we have token expiry information
342
- const token = auth.token as any;
343
- const expiresAt = token.accessTokenExpires || token.exp;
344
-
345
- if (!expiresAt) {
346
- // No expiry info, can't determine if refresh needed
347
- return false;
348
- }
349
-
350
- const now = Math.floor(Date.now() / 1000);
351
- const expiryTime = typeof expiresAt === 'number' && expiresAt > 1000000000000
352
- ? Math.floor(expiresAt / 1000) // Convert milliseconds to seconds
353
- : expiresAt;
354
-
355
- // Check if token expires within the buffer period
356
- return (expiryTime - now) <= refreshBuffer;
357
- }
358
-
359
- /**
360
- * Generate a unique request ID for coordinated refresh
361
- */
362
- function generateRequestId(): string {
363
- return nanoid();
364
- }
365
-
366
- /**
367
- * Main handler wrapper
368
- */
369
- return {
370
- handle: (handler: HandlerFunction) => {
371
- return async (req: NextRequest, context: any = {}) => {
372
- // Extract session from Better Auth
373
- const betterAuthSession = await getBetterAuthSession(req);
374
- const token = betterAuthSession ? { ...betterAuthSession.user, ...betterAuthSession.session } as any : null;
375
-
376
- // Check if auth is required
377
- if (requireAuth && !betterAuthSession) {
378
- return NextResponse.json(
379
- { error: 'Authentication required', code: 'UNAUTHORIZED' },
380
- { status: 401 }
381
- );
382
- }
383
-
384
- // If no session and auth not required, call handler without auth context
385
- if (!betterAuthSession) {
386
- return handler(req, context, null as any);
387
- }
388
-
389
- // Validate client_slug (token confusion attack prevention)
390
- // SECURITY: Fail closed - require configuration to be explicitly set
391
- const expectedClientSlug = process.env.NEXT_PUBLIC_EXPECTED_CLIENT_SLUG;
392
-
393
- if (!expectedClientSlug) {
394
- console.error('[AUTH_HANDLER] SECURITY MISCONFIGURATION: NEXT_PUBLIC_EXPECTED_CLIENT_SLUG not set');
395
- return NextResponse.json(
396
- {
397
- error: 'Server configuration error',
398
- code: 'SECURITY_CONFIGURATION_MISSING'
399
- },
400
- { status: 500 }
401
- );
402
- }
403
-
404
- // Extract client_slug from token (normalize property name)
405
- const tokenClientSlug = (token as any).client_slug || (token as any).clientSlug;
406
-
407
- // SECURITY: Require client_slug claim in all tokens (no backward compat)
408
- if (!tokenClientSlug) {
409
- console.warn('[AUTH_HANDLER] Token missing required client_slug claim');
410
- return NextResponse.json(
411
- {
412
- error: 'Token missing required claim',
413
- code: 'TOKEN_MISSING_CLIENT_SLUG'
414
- },
415
- { status: 401 }
416
- );
417
- }
418
-
419
- // SECURITY: Case-insensitive comparison to avoid casing attacks
420
- if (tokenClientSlug.toLowerCase() !== expectedClientSlug.toLowerCase()) {
421
- // Log without exposing sensitive details
422
- console.warn('[AUTH_HANDLER] Token client mismatch detected');
423
- return NextResponse.json(
424
- {
425
- error: 'Token issued for different client',
426
- code: 'TOKEN_CLIENT_MISMATCH'
427
- },
428
- { status: 401 }
429
- );
430
- }
431
-
432
- // Build initial auth context
433
- let authContext: AuthContext = {
434
- token,
435
- accessToken: (token as any).accessToken || '',
436
- userId: betterAuthSession.user?.id || (token as any).userId || '',
437
- sessionToken: betterAuthSession.session?.token || '',
438
- refreshToken: (token as any).refreshToken,
439
- };
440
-
441
- // Check if token needs refresh
442
- if (needsRefresh(authContext) && authContext.refreshToken) {
443
- const requestId = generateRequestId();
444
- console.info('[AUTH_HANDLER] Token near expiry, initiating coordinated refresh', {
445
- requestId,
446
- sessionToken: tokenLogId(authContext.sessionToken)
447
- });
448
-
449
- const refreshResult = await performCoordinatedRefresh(
450
- authContext.sessionToken,
451
- requestId
452
- );
453
-
454
- if (refreshResult.success && refreshResult.accessToken) {
455
- // Update auth context with fresh token
456
- authContext.accessToken = refreshResult.accessToken;
457
- if (refreshResult.refreshToken) {
458
- authContext.refreshToken = refreshResult.refreshToken;
459
- }
460
-
461
- // Update token object for future checks
462
- (authContext.token as any).accessToken = refreshResult.accessToken;
463
- if (refreshResult.expiresIn) {
464
- (authContext.token as any).accessTokenExpires = Date.now() + (refreshResult.expiresIn * 1000);
465
- }
466
-
467
- console.info('[AUTH_HANDLER] Coordinated refresh successful', { requestId });
468
- } else {
469
- console.warn('[AUTH_HANDLER] Failed to refresh token:', refreshResult.error);
470
- // Continue with potentially expired token - handler may still succeed
471
- }
472
- }
473
-
474
- // Attach auth context to request for downstream use (following existing pattern)
475
- // IMPORTANT: Set this ONCE before the retry loop to avoid overwriting with stale data
476
- (req as any).__authContext = {
477
- accessToken: authContext.accessToken,
478
- userId: authContext.userId,
479
- sessionToken: authContext.sessionToken,
480
- };
481
-
482
- // Call the actual handler
483
- let response: NextResponse | Response;
484
- let retryCount = 0;
485
-
486
- while (retryCount <= maxRetries) {
487
- try {
488
- response = await handler(req, context, authContext);
489
-
490
- // Check if we got a 401 and should retry
491
- if (
492
- response.status === 401 &&
493
- retryOn401 &&
494
- retryCount < maxRetries &&
495
- authContext.refreshToken
496
- ) {
497
- const retryRequestId = generateRequestId();
498
- console.info('[AUTH_HANDLER] Got 401, attempting coordinated refresh and retry', { retryRequestId });
499
-
500
- const refreshResult = await performCoordinatedRefresh(
501
- authContext.sessionToken,
502
- retryRequestId
503
- );
504
-
505
- if (refreshResult.success && refreshResult.accessToken) {
506
- console.info('[AUTH_HANDLER] Refresh succeeded, updating tokens', { retryRequestId });
507
-
508
- // Update auth context with fresh token
509
- authContext.accessToken = refreshResult.accessToken;
510
- if (refreshResult.refreshToken) {
511
- authContext.refreshToken = refreshResult.refreshToken;
512
- }
513
-
514
- // Update request context
515
- (req as any).__authContext.accessToken = refreshResult.accessToken;
516
-
517
- console.info('[AUTH_HANDLER] Updated req.__authContext with new token, retrying request', { retryRequestId });
518
-
519
- retryCount++;
520
- continue; // Retry the request
521
- } else {
522
- console.warn('[AUTH_HANDLER] Refresh failed on 401 retry:', refreshResult.error);
523
- break; // Don't retry if refresh failed
524
- }
525
- }
526
-
527
- // Success or non-401 error - return response
528
- break;
529
- } catch (error) {
530
- // Handler threw an error
531
- console.error('[AUTH_HANDLER] Handler error:', error);
532
- return NextResponse.json(
533
- {
534
- error: 'Internal server error',
535
- details: error instanceof Error ? error.message : 'Unknown error'
536
- },
537
- { status: 500 }
538
- );
539
- }
540
- }
541
-
542
- return response!;
543
- };
544
- }
545
- };
546
- }
547
-
548
- /**
549
- * Default export for convenience
550
- */
1
+ /**
2
+ * Enhanced Auth Handler with Coordinated Token Refresh
3
+ *
4
+ * Provides a middleware wrapper that automatically handles token lifecycle:
5
+ * - Checks token expiry before each request
6
+ * - Automatically refreshes expired or near-expired tokens
7
+ * - Uses Redis locks for coordinated refresh (prevents race conditions)
8
+ * - Retries requests on 401 responses with fresh tokens
9
+ *
10
+ * Pattern ported from website-membership simple-api-handler.ts
11
+ *
12
+ * @version 2.1.0
13
+ * @since auth-ready-v2
14
+ */
15
+
16
+ import { NextRequest, NextResponse } from 'next/server';
17
+ import { getSession as getBetterAuthSession } from '../server/auth';
18
+ import { nanoid } from 'nanoid';
19
+ import {
20
+ getSession,
21
+ updateSession,
22
+ acquireRefreshLock,
23
+ releaseRefreshLock,
24
+ checkRefreshLock,
25
+ type SessionData
26
+ } from '../lib/session-store';
27
+ import { tokenLogId } from '../lib/token-log-id';
28
+
29
+
30
+ export interface AuthContext {
31
+ token: any;
32
+ accessToken: string;
33
+ userId: string;
34
+ sessionToken: string;
35
+ refreshToken?: string;
36
+ }
37
+
38
+ export interface AuthHandlerOptions {
39
+ /** Whether authentication is required for this route (default: true) */
40
+ requireAuth?: boolean;
41
+
42
+ /** Automatically refresh expired or near-expired tokens (default: true) */
43
+ autoRefresh?: boolean;
44
+
45
+ /** Buffer time in seconds before token expiry to trigger refresh (default: 300 = 5 minutes) */
46
+ refreshBuffer?: number;
47
+
48
+ /** Retry request on 401 response after refreshing token (default: true) */
49
+ retryOn401?: boolean;
50
+
51
+ /** Maximum number of retry attempts on 401 (default: 1) */
52
+ maxRetries?: number;
53
+
54
+ /** IDP base URL for refresh requests */
55
+ idpBaseUrl?: string;
56
+
57
+ /** OAuth client ID */
58
+ clientId?: string;
59
+ }
60
+
61
+ export type HandlerFunction = (
62
+ req: NextRequest,
63
+ context: any,
64
+ auth: AuthContext
65
+ ) => Promise<NextResponse | Response>;
66
+
67
+ interface RefreshResult {
68
+ success: boolean;
69
+ accessToken?: string;
70
+ refreshToken?: string;
71
+ expiresIn?: number;
72
+ error?: string;
73
+ }
74
+
75
+ /**
76
+ * Creates an auth-aware handler with automatic token refresh
77
+ *
78
+ * @example
79
+ * ```typescript
80
+ * import { createAuthHandler } from '@payez/next-mvp/api';
81
+ *
82
+ * const handler = createAuthHandler({ requireAuth: true });
83
+ *
84
+ * export const GET = handler.handle(async (req, context, auth) => {
85
+ * // auth.accessToken is guaranteed to be fresh
86
+ * const response = await fetch('https://api.example.com/data', {
87
+ * headers: { 'Authorization': `Bearer ${auth.accessToken}` }
88
+ * });
89
+ * return NextResponse.json(await response.json());
90
+ * });
91
+ * ```
92
+ */
93
+ export function createAuthHandler(options: AuthHandlerOptions = {}) {
94
+ const {
95
+ requireAuth = true,
96
+ autoRefresh = true,
97
+ refreshBuffer = 60, // 60 seconds - matches website-membership proven threshold
98
+ retryOn401 = true,
99
+ maxRetries = 1,
100
+ idpBaseUrl = process.env.IDP_URL,
101
+ clientId = process.env.CLIENT_ID || process.env.NEXT_PUBLIC_IDP_CLIENT_ID
102
+ } = options;
103
+
104
+ /**
105
+ * Performs coordinated token refresh with Redis locking
106
+ * This prevents multiple concurrent requests from all trying to refresh simultaneously
107
+ */
108
+ async function performCoordinatedRefresh(
109
+ sessionToken: string,
110
+ requestId: string
111
+ ): Promise<RefreshResult> {
112
+ // Check if refresh is already in progress
113
+ const existingLock = await checkRefreshLock(sessionToken);
114
+
115
+ if (existingLock) {
116
+ console.info('[AUTH_HANDLER] Refresh already in progress, waiting...', {
117
+ requestId,
118
+ lockOwner: existingLock.acquiredBy,
119
+ lockAge: Date.now() - existingLock.acquiredAt
120
+ });
121
+
122
+ // Wait for the refresh to complete
123
+ const waitResult = await waitForRefreshCompletion(sessionToken, requestId, 10000);
124
+ if (waitResult.success) {
125
+ // Get the fresh token from session
126
+ const freshSession = await getSession(sessionToken);
127
+ if (freshSession?.accessToken) {
128
+ return {
129
+ success: true,
130
+ accessToken: freshSession.accessToken,
131
+ refreshToken: freshSession.refreshToken,
132
+ expiresIn: freshSession.accessTokenExpires
133
+ ? Math.floor((freshSession.accessTokenExpires - Date.now()) / 1000)
134
+ : undefined
135
+ };
136
+ }
137
+ }
138
+ return { success: false, error: waitResult.reason || 'Wait for refresh failed' };
139
+ }
140
+
141
+ // Try to acquire the refresh lock
142
+ const lockAcquired = await acquireRefreshLock(sessionToken, requestId, 5000);
143
+
144
+ if (!lockAcquired) {
145
+ // Another request grabbed the lock, wait for it
146
+ console.info('[AUTH_HANDLER] Failed to acquire lock, waiting for other request', { requestId });
147
+ const waitResult = await waitForRefreshCompletion(sessionToken, requestId, 10000);
148
+ if (waitResult.success) {
149
+ const freshSession = await getSession(sessionToken);
150
+ if (freshSession?.accessToken) {
151
+ return {
152
+ success: true,
153
+ accessToken: freshSession.accessToken,
154
+ refreshToken: freshSession.refreshToken
155
+ };
156
+ }
157
+ }
158
+ return { success: false, error: waitResult.reason || 'Wait for refresh failed' };
159
+ }
160
+
161
+ try {
162
+ // Double-check if tokens are still stale after acquiring lock
163
+ const latestSession = await getSession(sessionToken);
164
+ if (latestSession && !tokenNeedsRefresh(latestSession, refreshBuffer)) {
165
+ console.info('[AUTH_HANDLER] Tokens already fresh after acquiring lock, skipping refresh', { requestId });
166
+ return {
167
+ success: true,
168
+ accessToken: latestSession.accessToken,
169
+ refreshToken: latestSession.refreshToken
170
+ };
171
+ }
172
+
173
+ // Actually perform the refresh
174
+ return await executeRefresh(sessionToken, latestSession);
175
+
176
+ } finally {
177
+ // Always release the lock
178
+ await releaseRefreshLock(sessionToken, requestId);
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Wait for an in-progress refresh to complete
184
+ */
185
+ async function waitForRefreshCompletion(
186
+ sessionToken: string,
187
+ requestId: string,
188
+ maxWaitMs: number
189
+ ): Promise<{ success: boolean; reason?: string }> {
190
+ const startTime = Date.now();
191
+ const pollInterval = 100;
192
+
193
+ while (Date.now() - startTime < maxWaitMs) {
194
+ const lockExists = await checkRefreshLock(sessionToken);
195
+
196
+ if (!lockExists) {
197
+ // Lock released, check if tokens are fresh
198
+ const session = await getSession(sessionToken);
199
+ if (session?.accessToken && !tokenNeedsRefresh(session, refreshBuffer)) {
200
+ return { success: true };
201
+ } else {
202
+ return { success: false, reason: 'Lock released but tokens not fresh' };
203
+ }
204
+ }
205
+
206
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
207
+ }
208
+
209
+ return { success: false, reason: `Timeout waiting for refresh (${maxWaitMs}ms)` };
210
+ }
211
+
212
+ /**
213
+ * Check if token needs refresh based on expiry and buffer
214
+ */
215
+ function tokenNeedsRefresh(session: SessionData, bufferSeconds: number): boolean {
216
+ if (!session.accessToken) return true;
217
+
218
+ const expires = session.accessTokenExpires || 0;
219
+ const bufferMs = bufferSeconds * 1000;
220
+ const timeUntilExpiry = expires - Date.now();
221
+
222
+ return timeUntilExpiry <= bufferMs;
223
+ }
224
+
225
+ /**
226
+ * Execute the actual token refresh against IDP
227
+ */
228
+ async function executeRefresh(
229
+ sessionToken: string,
230
+ currentSession: SessionData | null
231
+ ): Promise<RefreshResult> {
232
+ try {
233
+ if (!idpBaseUrl || !clientId) {
234
+ console.error('[AUTH_HANDLER] Missing IDP configuration for refresh');
235
+ return { success: false, error: 'Missing IDP configuration' };
236
+ }
237
+
238
+ if (!currentSession) {
239
+ console.error('[AUTH_HANDLER] No session found for refresh');
240
+ return { success: false, error: 'No session found' };
241
+ }
242
+
243
+ if (!currentSession.refreshToken) {
244
+ console.error('[AUTH_HANDLER] No refresh token available');
245
+ return { success: false, error: 'No refresh token' };
246
+ }
247
+
248
+ // Extract authentication methods from session
249
+ const authMethods = (currentSession as any).authMethods ||
250
+ ((currentSession as any).token?.amr ? JSON.parse((currentSession as any).token.amr) : ['pwd', 'mfa']);
251
+ const authLevel = String((currentSession as any).authenticationLevel || (currentSession as any).token?.acr || '2');
252
+ const twoFactorMethod = (currentSession as any).twoFactorMethod || 'authenticator';
253
+
254
+ // Build refresh request body
255
+ const refreshRequestBody: any = {
256
+ refresh_token: currentSession.refreshToken,
257
+ amr: authMethods,
258
+ acr: authLevel
259
+ };
260
+
261
+ if ((currentSession as any).twoFactorComplete) {
262
+ refreshRequestBody.two_factor_verified = true;
263
+ }
264
+ if (twoFactorMethod) {
265
+ refreshRequestBody.two_factor_method = twoFactorMethod;
266
+ }
267
+ if ((currentSession as any).mfaCompletedAt) {
268
+ refreshRequestBody.two_factor_completed_at = new Date((currentSession as any).mfaCompletedAt).toISOString();
269
+ }
270
+
271
+ console.info('[AUTH_HANDLER] Executing refresh against IDP', {
272
+ sessionToken: tokenLogId(sessionToken),
273
+ hasRefreshToken: true
274
+ });
275
+
276
+ const response = await fetch(`${idpBaseUrl}/api/ExternalAuth/refresh`, {
277
+ method: 'POST',
278
+ headers: {
279
+ 'Content-Type': 'application/json',
280
+ 'X-Client-Id': clientId,
281
+ },
282
+ body: JSON.stringify(refreshRequestBody),
283
+ });
284
+
285
+ if (!response.ok) {
286
+ const errorText = await response.text().catch(() => 'Unknown error');
287
+ // PAY-1427: never the raw upstream body (the refresh.ts:528 twin PR 139 fixed). Status + length only.
288
+ console.error('[AUTH_HANDLER] Refresh failed:', { status: response.status, bodyLength: errorText.length });
289
+ return { success: false, error: `Refresh failed: ${response.status}` };
290
+ }
291
+
292
+ const data = await response.json();
293
+
294
+ if (data.success === false) {
295
+ return { success: false, error: data.error?.message || data.message || 'Refresh failed' };
296
+ }
297
+
298
+ const tokenData = data.data || data;
299
+
300
+ if (!tokenData.access_token) {
301
+ console.error('[AUTH_HANDLER] No access token in refresh response');
302
+ return { success: false, error: 'No access token received' };
303
+ }
304
+
305
+ // Update session with new tokens
306
+ const updatedSession = {
307
+ ...currentSession,
308
+ accessToken: tokenData.access_token,
309
+ refreshToken: tokenData.refresh_token || currentSession.refreshToken,
310
+ accessTokenExpires: tokenData.expires_in
311
+ ? Date.now() + (tokenData.expires_in * 1000)
312
+ : Date.now() + (3600 * 1000),
313
+ };
314
+
315
+ await updateSession(sessionToken, updatedSession);
316
+
317
+ console.info('[AUTH_HANDLER] Token refresh successful', {
318
+ newExpiry: new Date(updatedSession.accessTokenExpires).toISOString()
319
+ });
320
+
321
+ return {
322
+ success: true,
323
+ accessToken: tokenData.access_token,
324
+ refreshToken: tokenData.refresh_token,
325
+ expiresIn: tokenData.expires_in,
326
+ };
327
+ } catch (error) {
328
+ console.error('[AUTH_HANDLER] Refresh exception:', error);
329
+ return { success: false, error: error instanceof Error ? error.message : 'Refresh failed' };
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Checks if auth context token needs refresh based on expiry and buffer
335
+ */
336
+ function needsRefresh(auth: AuthContext): boolean {
337
+ if (!autoRefresh) return false;
338
+ // No refresh token = nothing to refresh with, skip entirely
339
+ if (!auth.refreshToken) return false;
340
+
341
+ // Check if we have token expiry information
342
+ const token = auth.token as any;
343
+ const expiresAt = token.accessTokenExpires || token.exp;
344
+
345
+ if (!expiresAt) {
346
+ // No expiry info, can't determine if refresh needed
347
+ return false;
348
+ }
349
+
350
+ const now = Math.floor(Date.now() / 1000);
351
+ const expiryTime = typeof expiresAt === 'number' && expiresAt > 1000000000000
352
+ ? Math.floor(expiresAt / 1000) // Convert milliseconds to seconds
353
+ : expiresAt;
354
+
355
+ // Check if token expires within the buffer period
356
+ return (expiryTime - now) <= refreshBuffer;
357
+ }
358
+
359
+ /**
360
+ * Generate a unique request ID for coordinated refresh
361
+ */
362
+ function generateRequestId(): string {
363
+ return nanoid();
364
+ }
365
+
366
+ /**
367
+ * Main handler wrapper
368
+ */
369
+ return {
370
+ handle: (handler: HandlerFunction) => {
371
+ return async (req: NextRequest, context: any = {}) => {
372
+ // Extract session from Better Auth
373
+ const betterAuthSession = await getBetterAuthSession(req);
374
+ const token = betterAuthSession ? { ...betterAuthSession.user, ...betterAuthSession.session } as any : null;
375
+
376
+ // Check if auth is required
377
+ if (requireAuth && !betterAuthSession) {
378
+ return NextResponse.json(
379
+ { error: 'Authentication required', code: 'UNAUTHORIZED' },
380
+ { status: 401 }
381
+ );
382
+ }
383
+
384
+ // If no session and auth not required, call handler without auth context
385
+ if (!betterAuthSession) {
386
+ return handler(req, context, null as any);
387
+ }
388
+
389
+ // Validate client_slug (token confusion attack prevention)
390
+ // SECURITY: Fail closed - require configuration to be explicitly set
391
+ const expectedClientSlug = process.env.NEXT_PUBLIC_EXPECTED_CLIENT_SLUG;
392
+
393
+ if (!expectedClientSlug) {
394
+ console.error('[AUTH_HANDLER] SECURITY MISCONFIGURATION: NEXT_PUBLIC_EXPECTED_CLIENT_SLUG not set');
395
+ return NextResponse.json(
396
+ {
397
+ error: 'Server configuration error',
398
+ code: 'SECURITY_CONFIGURATION_MISSING'
399
+ },
400
+ { status: 500 }
401
+ );
402
+ }
403
+
404
+ // Extract client_slug from token (normalize property name)
405
+ const tokenClientSlug = (token as any).client_slug || (token as any).clientSlug;
406
+
407
+ // SECURITY: Require client_slug claim in all tokens (no backward compat)
408
+ if (!tokenClientSlug) {
409
+ console.warn('[AUTH_HANDLER] Token missing required client_slug claim');
410
+ return NextResponse.json(
411
+ {
412
+ error: 'Token missing required claim',
413
+ code: 'TOKEN_MISSING_CLIENT_SLUG'
414
+ },
415
+ { status: 401 }
416
+ );
417
+ }
418
+
419
+ // SECURITY: Case-insensitive comparison to avoid casing attacks
420
+ if (tokenClientSlug.toLowerCase() !== expectedClientSlug.toLowerCase()) {
421
+ // Log without exposing sensitive details
422
+ console.warn('[AUTH_HANDLER] Token client mismatch detected');
423
+ return NextResponse.json(
424
+ {
425
+ error: 'Token issued for different client',
426
+ code: 'TOKEN_CLIENT_MISMATCH'
427
+ },
428
+ { status: 401 }
429
+ );
430
+ }
431
+
432
+ // Build initial auth context
433
+ let authContext: AuthContext = {
434
+ token,
435
+ accessToken: (token as any).accessToken || '',
436
+ userId: betterAuthSession.user?.id || (token as any).userId || '',
437
+ sessionToken: betterAuthSession.session?.token || '',
438
+ refreshToken: (token as any).refreshToken,
439
+ };
440
+
441
+ // Check if token needs refresh
442
+ if (needsRefresh(authContext) && authContext.refreshToken) {
443
+ const requestId = generateRequestId();
444
+ console.info('[AUTH_HANDLER] Token near expiry, initiating coordinated refresh', {
445
+ requestId,
446
+ sessionToken: tokenLogId(authContext.sessionToken)
447
+ });
448
+
449
+ const refreshResult = await performCoordinatedRefresh(
450
+ authContext.sessionToken,
451
+ requestId
452
+ );
453
+
454
+ if (refreshResult.success && refreshResult.accessToken) {
455
+ // Update auth context with fresh token
456
+ authContext.accessToken = refreshResult.accessToken;
457
+ if (refreshResult.refreshToken) {
458
+ authContext.refreshToken = refreshResult.refreshToken;
459
+ }
460
+
461
+ // Update token object for future checks
462
+ (authContext.token as any).accessToken = refreshResult.accessToken;
463
+ if (refreshResult.expiresIn) {
464
+ (authContext.token as any).accessTokenExpires = Date.now() + (refreshResult.expiresIn * 1000);
465
+ }
466
+
467
+ console.info('[AUTH_HANDLER] Coordinated refresh successful', { requestId });
468
+ } else {
469
+ console.warn('[AUTH_HANDLER] Failed to refresh token:', refreshResult.error);
470
+ // Continue with potentially expired token - handler may still succeed
471
+ }
472
+ }
473
+
474
+ // Attach auth context to request for downstream use (following existing pattern)
475
+ // IMPORTANT: Set this ONCE before the retry loop to avoid overwriting with stale data
476
+ (req as any).__authContext = {
477
+ accessToken: authContext.accessToken,
478
+ userId: authContext.userId,
479
+ sessionToken: authContext.sessionToken,
480
+ };
481
+
482
+ // Call the actual handler
483
+ let response: NextResponse | Response;
484
+ let retryCount = 0;
485
+
486
+ while (retryCount <= maxRetries) {
487
+ try {
488
+ response = await handler(req, context, authContext);
489
+
490
+ // Check if we got a 401 and should retry
491
+ if (
492
+ response.status === 401 &&
493
+ retryOn401 &&
494
+ retryCount < maxRetries &&
495
+ authContext.refreshToken
496
+ ) {
497
+ const retryRequestId = generateRequestId();
498
+ console.info('[AUTH_HANDLER] Got 401, attempting coordinated refresh and retry', { retryRequestId });
499
+
500
+ const refreshResult = await performCoordinatedRefresh(
501
+ authContext.sessionToken,
502
+ retryRequestId
503
+ );
504
+
505
+ if (refreshResult.success && refreshResult.accessToken) {
506
+ console.info('[AUTH_HANDLER] Refresh succeeded, updating tokens', { retryRequestId });
507
+
508
+ // Update auth context with fresh token
509
+ authContext.accessToken = refreshResult.accessToken;
510
+ if (refreshResult.refreshToken) {
511
+ authContext.refreshToken = refreshResult.refreshToken;
512
+ }
513
+
514
+ // Update request context
515
+ (req as any).__authContext.accessToken = refreshResult.accessToken;
516
+
517
+ console.info('[AUTH_HANDLER] Updated req.__authContext with new token, retrying request', { retryRequestId });
518
+
519
+ retryCount++;
520
+ continue; // Retry the request
521
+ } else {
522
+ console.warn('[AUTH_HANDLER] Refresh failed on 401 retry:', refreshResult.error);
523
+ break; // Don't retry if refresh failed
524
+ }
525
+ }
526
+
527
+ // Success or non-401 error - return response
528
+ break;
529
+ } catch (error) {
530
+ // Handler threw an error
531
+ console.error('[AUTH_HANDLER] Handler error:', error);
532
+ return NextResponse.json(
533
+ {
534
+ error: 'Internal server error',
535
+ details: error instanceof Error ? error.message : 'Unknown error'
536
+ },
537
+ { status: 500 }
538
+ );
539
+ }
540
+ }
541
+
542
+ return response!;
543
+ };
544
+ }
545
+ };
546
+ }
547
+
548
+ /**
549
+ * Default export for convenience
550
+ */
551
551
  export default createAuthHandler;