@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,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;