@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,159 +1,159 @@
1
- /**
2
- * PAY-2218 PR 2: the hand-off between the middleware and the session-reset route.
3
- *
4
- * WHY A ROUTE AT ALL. Next 15.5.x and 16.x (16.2.x without `skipProxyUrlNormalize`) strip every router flight
5
- * header (`rsc`, `next-router-prefetch`, `next-router-segment-prefetch`, `next-router-state-tree`) from the request
6
- * handed to middleware, so middleware cannot tell a router prefetch from a real navigation and its prefetch guard is
7
- * dead. A ROUTE HANDLER does see them, on 15.5.9, 16.1.5 and 16.2.12 (measured with a real Chromium against real
8
- * `next start` servers, PAY-2218 comments 2026-10-11). So the middleware no longer writes the clearing Set-Cookie
9
- * itself: it redirects to the reset route, and the route decides.
10
- *
11
- * WHY THE ROUTE ANSWERS A PREFETCH WITH 204, NOT A REDIRECT. On 15.5.9 the router CACHES a prefetched redirect chain
12
- * and the later click reuses it without calling middleware or the route again - so "decline on the prefetch, clear
13
- * on the real navigation" never clears there. A 204 + no-store gives the router nothing to cache, so the real click
14
- * reaches the route and clears (measured 2/2 on 15.5.9 and 16.2.12; the 307 mutant never clears on 15.5.9).
15
- *
16
- * WHY SIGNED. The route clears cookies on a GET. Unsigned, any page could link a signed-in user to it and log them
17
- * out. The middleware signs (callbackUrl, reason, error, exp, session binding); the route clears only on a valid,
18
- * unexpired signature and otherwise just sends the user to the login page with the cookies untouched.
19
- *
20
- * KEY SEPARATION (BAPert 72219). The signing key is DERIVED - HMAC(authSecret, 'next-mvp/session-reset/v1') - never
21
- * the raw auth secret, so no value the app signs with that secret for any other purpose can pass as a reset token.
22
- *
23
- * SESSION BINDING (BAPert 72219). The signature covers SHA-256 of the session cookie the redirect was issued FOR. The
24
- * route recomputes it from ITS OWN request's cookie and clears only on a match, so a token captured from one user
25
- * clears nothing when sent to another (logout-CSRF), and a second use is a no-op (the first use cleared the cookie it
26
- * was bound to). The hash never travels in the URL; it is only an input to the signature.
27
- *
28
- * WHY THE ROUTE REBUILDS THE LOGIN URL. The route never redirects to a URL it was handed. It takes `callbackUrl`
29
- * (through safeCallbackPath), `reason` and `error` (allowlisted) and builds `${LOGIN_PAGE}?...` itself, so it cannot
30
- * become an open redirect whatever the query says.
31
- *
32
- * Edge-safe: Web Crypto only (middleware runs on the edge runtime on 15.5.x).
33
- */
34
- import { safeCallbackPath } from './safe-callback';
35
-
36
- export const LOGIN_PAGE = '/account-auth/login';
37
-
38
- /** The mount point consumers use: `app/api/auth/session-reset/route.ts` (under /api/auth/, which middleware bypasses). */
39
- export const DEFAULT_SESSION_RESET_ROUTE = '/api/auth/session-reset';
40
-
41
- /** How long a signed reset link stays valid. A real navigation follows the 307 at once; this only bounds replay. */
42
- export const RESET_LINK_TTL_SECONDS = 300;
43
-
44
- /** The purpose label for the derived signing key. Changing it invalidates every outstanding reset link. */
45
- export const RESET_KEY_PURPOSE = 'next-mvp/session-reset/v1';
46
-
47
- /** The only `reason` / `error` values the middleware emits on a clearing redirect. Anything else is dropped. */
48
- export const RESET_REASONS: readonly string[] = ['stale', 'invalidated'];
49
- export const RESET_ERRORS: readonly string[] = ['SessionExpired', 'TwoFactorTimeout'];
50
-
51
- export interface ResetParams {
52
- callbackUrl: string;
53
- reason?: string;
54
- error?: string;
55
- }
56
-
57
- const encoder = new TextEncoder();
58
- const FIELD_SEPARATOR = String.fromCharCode(10);
59
-
60
- function b64url(bytes: Uint8Array): string {
61
- return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
62
- }
63
-
64
- async function hmacBytes(key: Uint8Array, message: string): Promise<Uint8Array> {
65
- const k = await crypto.subtle.importKey('raw', key as BufferSource, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
66
- return new Uint8Array(await crypto.subtle.sign('HMAC', k, encoder.encode(message)));
67
- }
68
-
69
- /** Sign with the DERIVED key: HMAC(HMAC(authSecret, purpose), message). Never with the raw auth secret. */
70
- async function sign(secret: string, message: string): Promise<string> {
71
- const derived = await hmacBytes(encoder.encode(secret), RESET_KEY_PURPOSE);
72
- return b64url(await hmacBytes(derived, message));
73
- }
74
-
75
- /** SHA-256 of the session cookie value, b64url. No session cookie binds to the empty string. */
76
- async function sessionBinding(sessionCookieValue: string | undefined): Promise<string> {
77
- if (!sessionCookieValue) return '';
78
- return b64url(new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(sessionCookieValue))));
79
- }
80
-
81
- /** Constant-time over equal lengths, and unequal lengths never match. */
82
- function sameString(a: string, b: string): boolean {
83
- if (a.length !== b.length) return false;
84
- let diff = 0;
85
- for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
86
- return diff === 0;
87
- }
88
-
89
- /** The secret the app already signs its session cookie with. Undefined means "cannot sign": callers fall back. */
90
- export function resetSecret(): string | undefined {
91
- return process.env.BETTER_AUTH_SECRET || process.env.NEXTAUTH_SECRET || undefined;
92
- }
93
-
94
- /** The session cookie a reset is bound to: the __Secure- one when present, else the plain one. */
95
- export function boundSessionCookie(get: (name: string) => string | undefined, plainName: string, secureName: string): string | undefined {
96
- return get(secureName) || get(plainName) || undefined;
97
- }
98
-
99
- /** Normalise to exactly what the route will honour, so what is signed is what is verified. */
100
- export function normaliseResetParams(raw: { callbackUrl?: string | null; reason?: string | null; error?: string | null }): ResetParams {
101
- const out: ResetParams = { callbackUrl: safeCallbackPath(raw.callbackUrl ?? '') };
102
- if (raw.reason && RESET_REASONS.includes(raw.reason)) out.reason = raw.reason;
103
- if (raw.error && RESET_ERRORS.includes(raw.error)) out.error = raw.error;
104
- return out;
105
- }
106
-
107
- /** What the signature covers. Exported for the key-separation row, which signs it with the RAW secret. */
108
- export function resetPayload(p: ResetParams, exp: number, binding: string): string {
109
- return ['v2', p.callbackUrl, p.reason ?? '', p.error ?? '', String(exp), binding].join(FIELD_SEPARATOR);
110
- }
111
-
112
- /** The login URL for these params, in the exact shape the middleware has always emitted. */
113
- export function loginLocation(p: ResetParams): string {
114
- let loc = `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(p.callbackUrl)}`;
115
- if (p.reason) loc += `&reason=${p.reason}`;
116
- if (p.error) loc += `&error=${p.error}`;
117
- return loc;
118
- }
119
-
120
- /**
121
- * Path + query for the reset route, bound to `sessionCookieValue` (the session cookie on the request being ended).
122
- * `nowMs` is injectable for tests.
123
- */
124
- export async function signedResetHref(
125
- route: string,
126
- p: ResetParams,
127
- secret: string,
128
- sessionCookieValue: string | undefined,
129
- nowMs: number = Date.now()
130
- ): Promise<string> {
131
- const exp = Math.floor(nowMs / 1000) + RESET_LINK_TTL_SECONDS;
132
- const q = new URLSearchParams({ callbackUrl: p.callbackUrl });
133
- if (p.reason) q.set('reason', p.reason);
134
- if (p.error) q.set('error', p.error);
135
- q.set('exp', String(exp));
136
- q.set('sig', await sign(secret, resetPayload(p, exp, await sessionBinding(sessionCookieValue))));
137
- return `${route}?${q.toString()}`;
138
- }
139
-
140
- /**
141
- * Read a reset request's query. `signed` is true only for a valid, unexpired signature over the normalised params AND
142
- * this request's own session cookie (`sessionCookieValue`). With no session cookie there is nothing to clear: false.
143
- * The params are returned either way: an unsigned request still gets a sensible login redirect, just no clearing.
144
- */
145
- export async function readResetRequest(
146
- search: URLSearchParams,
147
- secret: string | undefined,
148
- sessionCookieValue: string | undefined,
149
- nowMs: number = Date.now()
150
- ): Promise<{ params: ResetParams; signed: boolean }> {
151
- const params = normaliseResetParams({ callbackUrl: search.get('callbackUrl'), reason: search.get('reason'), error: search.get('error') });
152
- const sig = search.get('sig');
153
- const exp = Number(search.get('exp'));
154
- if (!secret || !sig || !sessionCookieValue || !Number.isInteger(exp) || exp < Math.floor(nowMs / 1000)) return { params, signed: false };
155
- // The signature covers the NORMALISED params and THIS request's session, so a tampered value, or the token arriving
156
- // with a different session cookie (another user, or the same user after signing in again), fails here.
157
- const want = await sign(secret, resetPayload(params, exp, await sessionBinding(sessionCookieValue)));
158
- return { params, signed: sameString(sig, want) };
159
- }
1
+ /**
2
+ * PAY-2218 PR 2: the hand-off between the middleware and the session-reset route.
3
+ *
4
+ * WHY A ROUTE AT ALL. Next 15.5.x and 16.x (16.2.x without `skipProxyUrlNormalize`) strip every router flight
5
+ * header (`rsc`, `next-router-prefetch`, `next-router-segment-prefetch`, `next-router-state-tree`) from the request
6
+ * handed to middleware, so middleware cannot tell a router prefetch from a real navigation and its prefetch guard is
7
+ * dead. A ROUTE HANDLER does see them, on 15.5.9, 16.1.5 and 16.2.12 (measured with a real Chromium against real
8
+ * `next start` servers, PAY-2218 comments 2026-10-11). So the middleware no longer writes the clearing Set-Cookie
9
+ * itself: it redirects to the reset route, and the route decides.
10
+ *
11
+ * WHY THE ROUTE ANSWERS A PREFETCH WITH 204, NOT A REDIRECT. On 15.5.9 the router CACHES a prefetched redirect chain
12
+ * and the later click reuses it without calling middleware or the route again - so "decline on the prefetch, clear
13
+ * on the real navigation" never clears there. A 204 + no-store gives the router nothing to cache, so the real click
14
+ * reaches the route and clears (measured 2/2 on 15.5.9 and 16.2.12; the 307 mutant never clears on 15.5.9).
15
+ *
16
+ * WHY SIGNED. The route clears cookies on a GET. Unsigned, any page could link a signed-in user to it and log them
17
+ * out. The middleware signs (callbackUrl, reason, error, exp, session binding); the route clears only on a valid,
18
+ * unexpired signature and otherwise just sends the user to the login page with the cookies untouched.
19
+ *
20
+ * KEY SEPARATION (BAPert 72219). The signing key is DERIVED - HMAC(authSecret, 'next-mvp/session-reset/v1') - never
21
+ * the raw auth secret, so no value the app signs with that secret for any other purpose can pass as a reset token.
22
+ *
23
+ * SESSION BINDING (BAPert 72219). The signature covers SHA-256 of the session cookie the redirect was issued FOR. The
24
+ * route recomputes it from ITS OWN request's cookie and clears only on a match, so a token captured from one user
25
+ * clears nothing when sent to another (logout-CSRF), and a second use is a no-op (the first use cleared the cookie it
26
+ * was bound to). The hash never travels in the URL; it is only an input to the signature.
27
+ *
28
+ * WHY THE ROUTE REBUILDS THE LOGIN URL. The route never redirects to a URL it was handed. It takes `callbackUrl`
29
+ * (through safeCallbackPath), `reason` and `error` (allowlisted) and builds `${LOGIN_PAGE}?...` itself, so it cannot
30
+ * become an open redirect whatever the query says.
31
+ *
32
+ * Edge-safe: Web Crypto only (middleware runs on the edge runtime on 15.5.x).
33
+ */
34
+ import { safeCallbackPath } from './safe-callback';
35
+
36
+ export const LOGIN_PAGE = '/account-auth/login';
37
+
38
+ /** The mount point consumers use: `app/api/auth/session-reset/route.ts` (under /api/auth/, which middleware bypasses). */
39
+ export const DEFAULT_SESSION_RESET_ROUTE = '/api/auth/session-reset';
40
+
41
+ /** How long a signed reset link stays valid. A real navigation follows the 307 at once; this only bounds replay. */
42
+ export const RESET_LINK_TTL_SECONDS = 300;
43
+
44
+ /** The purpose label for the derived signing key. Changing it invalidates every outstanding reset link. */
45
+ export const RESET_KEY_PURPOSE = 'next-mvp/session-reset/v1';
46
+
47
+ /** The only `reason` / `error` values the middleware emits on a clearing redirect. Anything else is dropped. */
48
+ export const RESET_REASONS: readonly string[] = ['stale', 'invalidated'];
49
+ export const RESET_ERRORS: readonly string[] = ['SessionExpired', 'TwoFactorTimeout'];
50
+
51
+ export interface ResetParams {
52
+ callbackUrl: string;
53
+ reason?: string;
54
+ error?: string;
55
+ }
56
+
57
+ const encoder = new TextEncoder();
58
+ const FIELD_SEPARATOR = String.fromCharCode(10);
59
+
60
+ function b64url(bytes: Uint8Array): string {
61
+ return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
62
+ }
63
+
64
+ async function hmacBytes(key: Uint8Array, message: string): Promise<Uint8Array> {
65
+ const k = await crypto.subtle.importKey('raw', key as BufferSource, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
66
+ return new Uint8Array(await crypto.subtle.sign('HMAC', k, encoder.encode(message)));
67
+ }
68
+
69
+ /** Sign with the DERIVED key: HMAC(HMAC(authSecret, purpose), message). Never with the raw auth secret. */
70
+ async function sign(secret: string, message: string): Promise<string> {
71
+ const derived = await hmacBytes(encoder.encode(secret), RESET_KEY_PURPOSE);
72
+ return b64url(await hmacBytes(derived, message));
73
+ }
74
+
75
+ /** SHA-256 of the session cookie value, b64url. No session cookie binds to the empty string. */
76
+ async function sessionBinding(sessionCookieValue: string | undefined): Promise<string> {
77
+ if (!sessionCookieValue) return '';
78
+ return b64url(new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(sessionCookieValue))));
79
+ }
80
+
81
+ /** Constant-time over equal lengths, and unequal lengths never match. */
82
+ function sameString(a: string, b: string): boolean {
83
+ if (a.length !== b.length) return false;
84
+ let diff = 0;
85
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
86
+ return diff === 0;
87
+ }
88
+
89
+ /** The secret the app already signs its session cookie with. Undefined means "cannot sign": callers fall back. */
90
+ export function resetSecret(): string | undefined {
91
+ return process.env.BETTER_AUTH_SECRET || process.env.NEXTAUTH_SECRET || undefined;
92
+ }
93
+
94
+ /** The session cookie a reset is bound to: the __Secure- one when present, else the plain one. */
95
+ export function boundSessionCookie(get: (name: string) => string | undefined, plainName: string, secureName: string): string | undefined {
96
+ return get(secureName) || get(plainName) || undefined;
97
+ }
98
+
99
+ /** Normalise to exactly what the route will honour, so what is signed is what is verified. */
100
+ export function normaliseResetParams(raw: { callbackUrl?: string | null; reason?: string | null; error?: string | null }): ResetParams {
101
+ const out: ResetParams = { callbackUrl: safeCallbackPath(raw.callbackUrl ?? '') };
102
+ if (raw.reason && RESET_REASONS.includes(raw.reason)) out.reason = raw.reason;
103
+ if (raw.error && RESET_ERRORS.includes(raw.error)) out.error = raw.error;
104
+ return out;
105
+ }
106
+
107
+ /** What the signature covers. Exported for the key-separation row, which signs it with the RAW secret. */
108
+ export function resetPayload(p: ResetParams, exp: number, binding: string): string {
109
+ return ['v2', p.callbackUrl, p.reason ?? '', p.error ?? '', String(exp), binding].join(FIELD_SEPARATOR);
110
+ }
111
+
112
+ /** The login URL for these params, in the exact shape the middleware has always emitted. */
113
+ export function loginLocation(p: ResetParams): string {
114
+ let loc = `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(p.callbackUrl)}`;
115
+ if (p.reason) loc += `&reason=${p.reason}`;
116
+ if (p.error) loc += `&error=${p.error}`;
117
+ return loc;
118
+ }
119
+
120
+ /**
121
+ * Path + query for the reset route, bound to `sessionCookieValue` (the session cookie on the request being ended).
122
+ * `nowMs` is injectable for tests.
123
+ */
124
+ export async function signedResetHref(
125
+ route: string,
126
+ p: ResetParams,
127
+ secret: string,
128
+ sessionCookieValue: string | undefined,
129
+ nowMs: number = Date.now()
130
+ ): Promise<string> {
131
+ const exp = Math.floor(nowMs / 1000) + RESET_LINK_TTL_SECONDS;
132
+ const q = new URLSearchParams({ callbackUrl: p.callbackUrl });
133
+ if (p.reason) q.set('reason', p.reason);
134
+ if (p.error) q.set('error', p.error);
135
+ q.set('exp', String(exp));
136
+ q.set('sig', await sign(secret, resetPayload(p, exp, await sessionBinding(sessionCookieValue))));
137
+ return `${route}?${q.toString()}`;
138
+ }
139
+
140
+ /**
141
+ * Read a reset request's query. `signed` is true only for a valid, unexpired signature over the normalised params AND
142
+ * this request's own session cookie (`sessionCookieValue`). With no session cookie there is nothing to clear: false.
143
+ * The params are returned either way: an unsigned request still gets a sensible login redirect, just no clearing.
144
+ */
145
+ export async function readResetRequest(
146
+ search: URLSearchParams,
147
+ secret: string | undefined,
148
+ sessionCookieValue: string | undefined,
149
+ nowMs: number = Date.now()
150
+ ): Promise<{ params: ResetParams; signed: boolean }> {
151
+ const params = normaliseResetParams({ callbackUrl: search.get('callbackUrl'), reason: search.get('reason'), error: search.get('error') });
152
+ const sig = search.get('sig');
153
+ const exp = Number(search.get('exp'));
154
+ if (!secret || !sig || !sessionCookieValue || !Number.isInteger(exp) || exp < Math.floor(nowMs / 1000)) return { params, signed: false };
155
+ // The signature covers the NORMALISED params and THIS request's session, so a tampered value, or the token arriving
156
+ // with a different session cookie (another user, or the same user after signing in again), fails here.
157
+ const want = await sign(secret, resetPayload(params, exp, await sessionBinding(sessionCookieValue)));
158
+ return { params, signed: sameString(sig, want) };
159
+ }
@@ -1,151 +1,151 @@
1
- /**
2
- * PAY-2217: no session token reaches browser JS from Better Auth's own HTTP endpoints.
3
- *
4
- * WHAT WAS WRONG. PAY-1481 removed token material from the package's own GET /api/auth/session. Better Auth's native
5
- * endpoints are a separate surface served by the catch-all mount, and their default bodies carry the session
6
- * credential identifier:
7
- * GET /get-session -> { session: { id, token, userId, ... }, user }
8
- * GET /list-sessions -> [ { id, token, ... } ] (EVERY session of the user, not just this one)
9
- * POST /sign-in/* -> { token, user, ... }
10
- * POST /sign-up/* -> { token, user }
11
- * GET /magic-link/verify (no callbackURL) -> { token, user } (NightHawk G4 on PR 1190, measured on a real instance)
12
- * and any plugin endpoint (passkey verify, two-factor verify, genericOAuth) answers a session object the same way.
13
- * `token` is the Redis key of the session. The cookie is token.HMAC and Path B verifies the signature, so a bare
14
- * token is not a replayable cookie (gate rows B1-B4 measure that), but it is a credential identifier readable by any
15
- * script on the page, for this session and for every other session the user has. It breaks the PAY-1481 rule.
16
- *
17
- * EVERY 2xx JSON BODY IS STRIPPED, not a named list of paths. A list protects the endpoints someone remembered and
18
- * silently misses the next plugin; the strip is a function of the response, so a new endpoint is covered the day it is
19
- * mounted. A 2xx JSON body that legitimately needs one of these names to reach the browser would have to be
20
- * allowlisted here on purpose (none is known: no client in this package reads one).
21
- *
22
- * WHERE THE FIX LIVES, AND WHY NOT customSession. Server-side code in this package reads session.session.token from
23
- * auth.api.getSession() (viability, refresh, status, the OAuth exchange). A customSession plugin or an after-hook
24
- * runs for those calls too and would strip the token from them. So the strip is applied at the HTTP BOUNDARY only:
25
- * getBetterAuthHandler() returns handlers wrapped by wrapBetterAuthHandler(), and auth.api stays untouched.
26
- *
27
- * WHAT THE BROWSER GETS INSTEAD.
28
- * - list-sessions rows carry `isCurrent` (computed here against the caller's cookie-resolved session) and `id`.
29
- * - POST /revoke-session-by-id { id }: revokes ONE of the CALLER's sessions by its id. An id that is not one of the
30
- * caller's own sessions answers 404 (never 403: do not confirm that another user's session id exists).
31
- * - Better Auth's native POST /revoke-session { token } still exists and still requires a cookie-resolved session
32
- * that owns the token, but no browser ever holds a token to send it.
33
- */
34
-
35
- /** Credential-bearing key names, compared case- and separator-insensitively. */
36
- const STRIP_KEYS = new Set(['token', 'sessiontoken', 'accesstoken', 'refreshtoken', 'idtoken']);
37
-
38
- const norm = (k: string): string => k.toLowerCase().replace(/[_\-\s]/g, '');
39
-
40
- /** Deep copy of `value` with every credential-named key removed, at any depth, in objects and arrays. */
41
- export function stripSessionCredentials(value: unknown): unknown {
42
- if (Array.isArray(value)) return value.map(stripSessionCredentials);
43
- if (value !== null && typeof value === 'object') {
44
- const out: Record<string, unknown> = {};
45
- for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
46
- if (STRIP_KEYS.has(norm(k))) continue;
47
- out[k] = stripSessionCredentials(v);
48
- }
49
- return out;
50
- }
51
- return value;
52
- }
53
-
54
- /** "/api/auth/get-session" -> "/get-session". Anything not under an /auth/ segment is returned as-is. */
55
- export function authSubPath(pathname: string): string {
56
- const i = pathname.indexOf('/api/auth/');
57
- if (i >= 0) return pathname.slice(i + '/api/auth'.length).replace(/\/+$/, '') || '/';
58
- return pathname.replace(/\/+$/, '') || '/';
59
- }
60
-
61
- function isJson(res: Response): boolean {
62
- return (res.headers.get('content-type') || '').toLowerCase().includes('application/json');
63
- }
64
-
65
- /** A Response with a new JSON body and the original status and headers (content-length recomputed by the runtime). */
66
- function withJsonBody(res: Response, body: unknown): Response {
67
- const headers = new Headers(res.headers);
68
- headers.delete('content-length');
69
- return new Response(JSON.stringify(body), { status: res.status, statusText: res.statusText, headers });
70
- }
71
-
72
- interface AuthApi {
73
- getSession: (opts: { headers: Headers }) => Promise<any>;
74
- listSessions: (opts: { headers: Headers }) => Promise<any>;
75
- revokeSession: (opts: { headers: Headers; body: { token: string }; asResponse?: boolean }) => Promise<any>;
76
- }
77
-
78
- /** Sanitise one already-produced Better Auth response. Pure function of (request, response, current token). */
79
- export async function sanitizeBetterAuthResponse(
80
- req: Request,
81
- res: Response,
82
- currentToken: () => Promise<string | undefined>,
83
- ): Promise<Response> {
84
- const sub = authSubPath(new URL(req.url).pathname);
85
- if (!res.ok || !isJson(res)) return res;
86
-
87
- let body: unknown;
88
- try {
89
- body = await res.clone().json();
90
- } catch {
91
- return res; // unreadable body: nothing to strip, and do not turn it into a different failure
92
- }
93
-
94
- if (sub === '/list-sessions' && Array.isArray(body)) {
95
- const cur = await currentToken().catch(() => undefined);
96
- body = body.map((row: any) =>
97
- row && typeof row === 'object' ? { ...row, isCurrent: !!cur && row.token === cur } : row,
98
- );
99
- }
100
- return withJsonBody(res, stripSessionCredentials(body));
101
- }
102
-
103
- const json = (status: number, body: unknown): Response =>
104
- new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
105
-
106
- /** POST /revoke-session-by-id { id }: revoke one of the caller's own sessions by id. */
107
- async function revokeById(req: Request, api: AuthApi): Promise<Response> {
108
- let id: unknown;
109
- try {
110
- id = (await req.json())?.id;
111
- } catch {
112
- return json(400, { code: 'INVALID_REQUEST', message: 'Body must be JSON { id }' });
113
- }
114
- if (typeof id !== 'string' || id.length === 0) {
115
- return json(400, { code: 'INVALID_REQUEST', message: 'id is required' });
116
- }
117
- const headers = new Headers(req.headers);
118
- const rows = await api.listSessions({ headers }).catch(() => null);
119
- if (rows === null) return json(401, { code: 'UNAUTHORIZED', message: 'No session' });
120
- // listSessions returns ONLY the caller's own sessions, so a foreign id cannot match: 404, not 403.
121
- const row = Array.isArray(rows) ? rows.find((r: any) => r && r.id === id && typeof r.token === 'string') : undefined;
122
- if (!row) return json(404, { code: 'SESSION_NOT_FOUND', message: 'No such session' });
123
- const res = await api.revokeSession({ headers, body: { token: row.token }, asResponse: true });
124
- // Pass Better Auth's own answer (and its Set-Cookie when the current session was the one revoked) through.
125
- return res instanceof Response ? res : json(200, { status: true });
126
- }
127
-
128
- type Handler = (req: Request) => Promise<Response>;
129
-
130
- /**
131
- * Wrap Better Auth's Next.js handlers so no response carries a session credential to the browser.
132
- * `auth` is the Better Auth instance; its `api` is used server-side only (to find the caller's current session and to
133
- * revoke by id) and is not modified.
134
- */
135
- export function wrapBetterAuthHandler(
136
- auth: { api: AuthApi },
137
- handlers: { GET: Handler; POST: Handler },
138
- ): { GET: Handler; POST: Handler } {
139
- const currentTokenFor = (req: Request) => async (): Promise<string | undefined> => {
140
- const s = await auth.api.getSession({ headers: new Headers(req.headers) });
141
- return s?.session?.token;
142
- };
143
- return {
144
- GET: async (req) => sanitizeBetterAuthResponse(req, await handlers.GET(req), currentTokenFor(req)),
145
- POST: async (req) => {
146
- const sub = authSubPath(new URL(req.url).pathname);
147
- if (sub === '/revoke-session-by-id') return revokeById(req, auth.api);
148
- return sanitizeBetterAuthResponse(req, await handlers.POST(req), currentTokenFor(req));
149
- },
150
- };
151
- }
1
+ /**
2
+ * PAY-2217: no session token reaches browser JS from Better Auth's own HTTP endpoints.
3
+ *
4
+ * WHAT WAS WRONG. PAY-1481 removed token material from the package's own GET /api/auth/session. Better Auth's native
5
+ * endpoints are a separate surface served by the catch-all mount, and their default bodies carry the session
6
+ * credential identifier:
7
+ * GET /get-session -> { session: { id, token, userId, ... }, user }
8
+ * GET /list-sessions -> [ { id, token, ... } ] (EVERY session of the user, not just this one)
9
+ * POST /sign-in/* -> { token, user, ... }
10
+ * POST /sign-up/* -> { token, user }
11
+ * GET /magic-link/verify (no callbackURL) -> { token, user } (NightHawk G4 on PR 1190, measured on a real instance)
12
+ * and any plugin endpoint (passkey verify, two-factor verify, genericOAuth) answers a session object the same way.
13
+ * `token` is the Redis key of the session. The cookie is token.HMAC and Path B verifies the signature, so a bare
14
+ * token is not a replayable cookie (gate rows B1-B4 measure that), but it is a credential identifier readable by any
15
+ * script on the page, for this session and for every other session the user has. It breaks the PAY-1481 rule.
16
+ *
17
+ * EVERY 2xx JSON BODY IS STRIPPED, not a named list of paths. A list protects the endpoints someone remembered and
18
+ * silently misses the next plugin; the strip is a function of the response, so a new endpoint is covered the day it is
19
+ * mounted. A 2xx JSON body that legitimately needs one of these names to reach the browser would have to be
20
+ * allowlisted here on purpose (none is known: no client in this package reads one).
21
+ *
22
+ * WHERE THE FIX LIVES, AND WHY NOT customSession. Server-side code in this package reads session.session.token from
23
+ * auth.api.getSession() (viability, refresh, status, the OAuth exchange). A customSession plugin or an after-hook
24
+ * runs for those calls too and would strip the token from them. So the strip is applied at the HTTP BOUNDARY only:
25
+ * getBetterAuthHandler() returns handlers wrapped by wrapBetterAuthHandler(), and auth.api stays untouched.
26
+ *
27
+ * WHAT THE BROWSER GETS INSTEAD.
28
+ * - list-sessions rows carry `isCurrent` (computed here against the caller's cookie-resolved session) and `id`.
29
+ * - POST /revoke-session-by-id { id }: revokes ONE of the CALLER's sessions by its id. An id that is not one of the
30
+ * caller's own sessions answers 404 (never 403: do not confirm that another user's session id exists).
31
+ * - Better Auth's native POST /revoke-session { token } still exists and still requires a cookie-resolved session
32
+ * that owns the token, but no browser ever holds a token to send it.
33
+ */
34
+
35
+ /** Credential-bearing key names, compared case- and separator-insensitively. */
36
+ const STRIP_KEYS = new Set(['token', 'sessiontoken', 'accesstoken', 'refreshtoken', 'idtoken']);
37
+
38
+ const norm = (k: string): string => k.toLowerCase().replace(/[_\-\s]/g, '');
39
+
40
+ /** Deep copy of `value` with every credential-named key removed, at any depth, in objects and arrays. */
41
+ export function stripSessionCredentials(value: unknown): unknown {
42
+ if (Array.isArray(value)) return value.map(stripSessionCredentials);
43
+ if (value !== null && typeof value === 'object') {
44
+ const out: Record<string, unknown> = {};
45
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
46
+ if (STRIP_KEYS.has(norm(k))) continue;
47
+ out[k] = stripSessionCredentials(v);
48
+ }
49
+ return out;
50
+ }
51
+ return value;
52
+ }
53
+
54
+ /** "/api/auth/get-session" -> "/get-session". Anything not under an /auth/ segment is returned as-is. */
55
+ export function authSubPath(pathname: string): string {
56
+ const i = pathname.indexOf('/api/auth/');
57
+ if (i >= 0) return pathname.slice(i + '/api/auth'.length).replace(/\/+$/, '') || '/';
58
+ return pathname.replace(/\/+$/, '') || '/';
59
+ }
60
+
61
+ function isJson(res: Response): boolean {
62
+ return (res.headers.get('content-type') || '').toLowerCase().includes('application/json');
63
+ }
64
+
65
+ /** A Response with a new JSON body and the original status and headers (content-length recomputed by the runtime). */
66
+ function withJsonBody(res: Response, body: unknown): Response {
67
+ const headers = new Headers(res.headers);
68
+ headers.delete('content-length');
69
+ return new Response(JSON.stringify(body), { status: res.status, statusText: res.statusText, headers });
70
+ }
71
+
72
+ interface AuthApi {
73
+ getSession: (opts: { headers: Headers }) => Promise<any>;
74
+ listSessions: (opts: { headers: Headers }) => Promise<any>;
75
+ revokeSession: (opts: { headers: Headers; body: { token: string }; asResponse?: boolean }) => Promise<any>;
76
+ }
77
+
78
+ /** Sanitise one already-produced Better Auth response. Pure function of (request, response, current token). */
79
+ export async function sanitizeBetterAuthResponse(
80
+ req: Request,
81
+ res: Response,
82
+ currentToken: () => Promise<string | undefined>,
83
+ ): Promise<Response> {
84
+ const sub = authSubPath(new URL(req.url).pathname);
85
+ if (!res.ok || !isJson(res)) return res;
86
+
87
+ let body: unknown;
88
+ try {
89
+ body = await res.clone().json();
90
+ } catch {
91
+ return res; // unreadable body: nothing to strip, and do not turn it into a different failure
92
+ }
93
+
94
+ if (sub === '/list-sessions' && Array.isArray(body)) {
95
+ const cur = await currentToken().catch(() => undefined);
96
+ body = body.map((row: any) =>
97
+ row && typeof row === 'object' ? { ...row, isCurrent: !!cur && row.token === cur } : row,
98
+ );
99
+ }
100
+ return withJsonBody(res, stripSessionCredentials(body));
101
+ }
102
+
103
+ const json = (status: number, body: unknown): Response =>
104
+ new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
105
+
106
+ /** POST /revoke-session-by-id { id }: revoke one of the caller's own sessions by id. */
107
+ async function revokeById(req: Request, api: AuthApi): Promise<Response> {
108
+ let id: unknown;
109
+ try {
110
+ id = (await req.json())?.id;
111
+ } catch {
112
+ return json(400, { code: 'INVALID_REQUEST', message: 'Body must be JSON { id }' });
113
+ }
114
+ if (typeof id !== 'string' || id.length === 0) {
115
+ return json(400, { code: 'INVALID_REQUEST', message: 'id is required' });
116
+ }
117
+ const headers = new Headers(req.headers);
118
+ const rows = await api.listSessions({ headers }).catch(() => null);
119
+ if (rows === null) return json(401, { code: 'UNAUTHORIZED', message: 'No session' });
120
+ // listSessions returns ONLY the caller's own sessions, so a foreign id cannot match: 404, not 403.
121
+ const row = Array.isArray(rows) ? rows.find((r: any) => r && r.id === id && typeof r.token === 'string') : undefined;
122
+ if (!row) return json(404, { code: 'SESSION_NOT_FOUND', message: 'No such session' });
123
+ const res = await api.revokeSession({ headers, body: { token: row.token }, asResponse: true });
124
+ // Pass Better Auth's own answer (and its Set-Cookie when the current session was the one revoked) through.
125
+ return res instanceof Response ? res : json(200, { status: true });
126
+ }
127
+
128
+ type Handler = (req: Request) => Promise<Response>;
129
+
130
+ /**
131
+ * Wrap Better Auth's Next.js handlers so no response carries a session credential to the browser.
132
+ * `auth` is the Better Auth instance; its `api` is used server-side only (to find the caller's current session and to
133
+ * revoke by id) and is not modified.
134
+ */
135
+ export function wrapBetterAuthHandler(
136
+ auth: { api: AuthApi },
137
+ handlers: { GET: Handler; POST: Handler },
138
+ ): { GET: Handler; POST: Handler } {
139
+ const currentTokenFor = (req: Request) => async (): Promise<string | undefined> => {
140
+ const s = await auth.api.getSession({ headers: new Headers(req.headers) });
141
+ return s?.session?.token;
142
+ };
143
+ return {
144
+ GET: async (req) => sanitizeBetterAuthResponse(req, await handlers.GET(req), currentTokenFor(req)),
145
+ POST: async (req) => {
146
+ const sub = authSubPath(new URL(req.url).pathname);
147
+ if (sub === '/revoke-session-by-id') return revokeById(req, auth.api);
148
+ return sanitizeBetterAuthResponse(req, await handlers.POST(req), currentTokenFor(req));
149
+ },
150
+ };
151
+ }