@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,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
+ }