@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,486 +1,486 @@
1
- /**
2
- * Server-side auth utilities for Better Auth.
3
- *
4
- * All server-side auth flows go through the Better Auth instance returned by
5
- * getAuthInstance(); use getSession(req) for the request-scoped session.
6
- */
7
-
8
- import 'server-only';
9
- import { getBetterAuthInstance } from '../auth/better-auth';
10
- import {
11
- getSession as getRedisSession,
12
- getBetterAuthSession as getBetterAuthRedisSession,
13
- describeSessionKeyPresence,
14
- type SessionData,
15
- } from '../lib/session-store';
16
- import { tokenLogId } from '../lib/token-log-id';
17
- import { resolveVerifiedSessionTokenFromRequest } from './verified-session-token';
18
-
19
- export type IdpTokenResult =
20
- | { success: true; accessToken: string; sessionData: SessionData }
21
- | { success: false; error: 'NO_SESSION'; terminal: true }
22
- // Card 187871: a Better Auth session can exist with NO idpAccessToken yet
23
- // because 2FA is required and not yet completed - the IDP never issued the
24
- // full token. That is a RECOVERABLE state (route to 2FA), not a dead
25
- // session. Only collapse it to NO_TOKEN/terminal when the session itself
26
- // already claims 2FA is done (sessionData.mfaVerified === true) and there is
27
- // still no token - that combination is genuinely broken.
28
- | { success: false; error: 'NO_TOKEN'; terminal: true }
29
- | { success: false; error: 'NO_TOKEN_PENDING_2FA'; terminal: false; sessionData: SessionData };
30
-
31
- function buildSessionDataFromAuthSession(session: any): SessionData | null {
32
- const user = session?.user;
33
- if (!user?.id && !user?.email) {
34
- return null;
35
- }
36
-
37
- const expiresAt = session?.session?.expiresAt
38
- ? new Date(session.session.expiresAt).getTime()
39
- : Date.now() + 24 * 60 * 60 * 1000;
40
-
41
- return {
42
- userId: user.userId || user.id || '',
43
- email: user.email || '',
44
- name: user.name || undefined,
45
- image: user.image || undefined,
46
- roles: Array.isArray(user.roles) ? user.roles : [],
47
- idpAccessToken: user.idpAccessToken,
48
- idpRefreshToken: user.idpRefreshToken,
49
- idpAccessTokenExpires: user.idpAccessTokenExpires || expiresAt,
50
- mfaVerified: user.mfaVerified ?? user.twoFactorSessionVerified ?? false,
51
- oauthProvider: user.oauthProvider,
52
- idpClientId: user.idpClientId,
53
- merchantId: user.merchantId,
54
- };
55
- }
56
-
57
- function attachSessionData(session: any, sessionData: SessionData | null, sessionToken?: string) {
58
- if (!sessionData) {
59
- return session;
60
- }
61
-
62
- const enrichedSessionData = {
63
- ...sessionData,
64
- ...(sessionToken ? { sessionToken } : {}),
65
- };
66
-
67
- (session as any).sessionData = enrichedSessionData;
68
-
69
- if (session?.user) {
70
- const user = session.user as any;
71
- user.userId = enrichedSessionData.userId || user.userId;
72
- user.email = enrichedSessionData.email || user.email;
73
- user.name = enrichedSessionData.name || user.name;
74
- user.image = enrichedSessionData.image || user.image;
75
- user.roles = enrichedSessionData.roles || user.roles || [];
76
- user.idpAccessToken = enrichedSessionData.idpAccessToken;
77
- user.idpRefreshToken = enrichedSessionData.idpRefreshToken;
78
- user.idpAccessTokenExpires = enrichedSessionData.idpAccessTokenExpires;
79
- user.mfaVerified = enrichedSessionData.mfaVerified;
80
- user.twoFactorSessionVerified =
81
- enrichedSessionData.mfaVerified ?? user.twoFactorSessionVerified;
82
- user.oauthProvider = enrichedSessionData.oauthProvider || user.oauthProvider;
83
- user.idpClientId = enrichedSessionData.idpClientId || user.idpClientId;
84
- user.merchantId = enrichedSessionData.merchantId || user.merchantId;
85
- }
86
-
87
- return session;
88
- }
89
-
90
- /**
91
- * Get the initialized Better Auth instance (singleton).
92
- *
93
- * 244549: the single-flight memoisation lives in lib/single-flight.ts rather
94
- * than inline here, and that placement is deliberate rather than tidiness.
95
- * The bug being fixed -- a REJECTED init promise cached for the life of the
96
- * process, so recovery was restart-only -- went unnoticed precisely because
97
- * this module cannot be loaded in a unit test: importing it pulls in
98
- * better-auth and the whole IDP config stack. Extracted into a zero-import
99
- * module, the retry semantics are covered by
100
- * test/single-flight-retry.test.cjs, which imports dist/ and therefore
101
- * exercises the artefact that actually ships.
102
- *
103
- * PAY-1623 (package half, BAPert 62449): THIS FUNNEL NO LONGER BUILDS ITS OWN
104
- * INSTANCE. It used to call `createBetterAuthInstance(config)` with no opts, so
105
- * it carried NONE of the app-supplied plugins registered by the host app at
106
- * startup (`configureBetterAuth()` -> `state.configuredOpts`). `samlAcs()` and
107
- * `passkeySignIn()` arrive only that way, so `getAuthInstance()` returned an
108
- * instance on which `auth.api.samlAcs` was `undefined` — the exact package twin
109
- * of the portal ACS crash `t.api.samlAcs is not a function` (Jon's walk,
110
- * 2026-09-21; Portal PR 517 fixed the portal by importing the CONFIGURED
111
- * instance, `getBetterAuthInstance`, which is why this file must now resolve the
112
- * same one). It also silently created a SECOND, divergent instance beside the
113
- * one every /api/auth/* route uses — two instances, two secrets, one process.
114
- *
115
- * It now DELEGATES to that configured funnel, which is the single source of
116
- * truth for the process's Better Auth instance:
117
- * - it carries `state.configuredOpts` (better-auth.ts:930), so the app
118
- * plugins are present;
119
- * - its init is already single-flighted with the retry semantics described in
120
- * lib/single-flight.ts (see getBetterAuthInstance, card 244549/338215), so
121
- * this function keeps the same no-latch recovery contract;
122
- * - there is now ONE instance, so the double-init / divergent-secret hazard
123
- * this path used to create is gone by construction.
124
- */
125
- export async function getAuthInstance() {
126
- return getBetterAuthInstance();
127
- }
128
-
129
-
130
- /* ----------------------------------------------------------------------------------------------------------------
131
- * PAY-2136 DIAGNOSTICS (BAPert 70873, Nextpert-Scout 70872/70875) - LOG ONLY, NO BEHAVIOUR CHANGE.
132
- *
133
- * A getFreshIdpToken NO_SESSION used to carry no origin: three different returns produced the same
134
- * `{error:'NO_SESSION'}`, and getSession() swallowed every Redis error into a bare `catch {}`. These helpers name
135
- * the origin and make a swallowed read audible. Hard rules: every helper is wrapped so it can NEVER throw or change
136
- * a return value; nothing here ever logs a token, a cookie VALUE or a Redis value - only a tokenLogId() fingerprint,
137
- * cookie NAMES, booleans, and an error name/message with any long opaque run redacted.
138
- * -------------------------------------------------------------------------------------------------------------- */
139
-
140
- /** An error name/message, first 80 chars, with any 24+ char opaque run (a token, a key) replaced. */
141
- function scrubForLog(v: unknown): string {
142
- const raw = v instanceof Error ? v.message : String(v ?? '');
143
- return raw.replace(/[A-Za-z0-9_\-.+/=]{24,}/g, '[redacted]').slice(0, 80);
144
- }
145
-
146
- /** `catch {}` sites on the session read path: still swallowed, now audible. */
147
- function logSessionReadSwallowed(path: 'primary' | 'A' | 'B', err: unknown): void {
148
- try {
149
- const name = scrubForLog(err instanceof Error ? err.name : typeof err);
150
- console.warn(`[server-auth] SESSION_READ_SWALLOWED path=${path} err=${name} msg=${scrubForLog(err)}`);
151
- } catch {
152
- /* a diagnostic must never change the outcome */
153
- }
154
- }
155
-
156
- /** Origin a: getSession(request) produced no token. Cookie NAMES only, never a value. */
157
- function logNoSessionOriginA(request: Request | undefined): void {
158
- try {
159
- const header = request?.headers?.get('cookie') ?? '';
160
- // Only segments of the form name=value contribute a NAME. A segment with no '=' is not a cookie pair, and
161
- // logging it whole could emit a bare token (Nextpert-Scout 70901 finding 1).
162
- const cookieNames = header
163
- .split(';')
164
- // Structural, alphabet-independent (Nextpert-Scout 70945): base64 padding can only sit at the END of a bare
165
- // value, so a bare padded token splits as name=<token>, value='' or '='. A real pair has a non-empty value that
166
- // is not only '=' characters. (The old includes('=') let 74% of base64url tokens through as a "name".)
167
- .filter((c) => {
168
- const i = c.indexOf('=');
169
- if (i < 0) return false;
170
- const v = c.slice(i + 1);
171
- return v !== '' && !/^=+$/.test(v);
172
- })
173
- .map((c) => c.split('=')[0].trim())
174
- .filter((n) => n !== '')
175
- // A bare token that merely ENDS in base64 padding ('=') still passes the filter above and its "name" would be
176
- // the token itself (Nextpert-Scout 70913). Real cookie names carry a separator ('.', '-', '_'); a long name with
177
- // none is opaque, so it is logged as a placeholder, never as itself.
178
- .map((n) => (n.length >= 24 && !/[._-]/.test(n) ? '[opaque-name]' : n))
179
- .slice(0, 20);
180
- console.warn('[server-auth] NO_SESSION origin=a', { hasCookieHeader: header !== '', cookieNames });
181
- } catch {
182
- /* a diagnostic must never change the outcome */
183
- }
184
- }
185
-
186
- /** Origins b and c: a token WAS resolved. Log which of its three keys exist (presence only), fire-and-forget. */
187
- function logNoSessionWithKeys(origin: 'b' | 'c', sessionToken: string): void {
188
- try {
189
- void describeSessionKeyPresence(sessionToken).then(
190
- (keys) => {
191
- try {
192
- console.warn(`[server-auth] NO_SESSION origin=${origin}`, { token: tokenLogId(sessionToken), ...keys });
193
- } catch {
194
- /* never throws */
195
- }
196
- },
197
- () => {
198
- /* never throws */
199
- }
200
- );
201
- } catch {
202
- /* a diagnostic must never change the outcome */
203
- }
204
- }
205
-
206
- /**
207
- * Get the current session from a request.
208
- *
209
- * Source-of-truth contract: **Redis is canonical for liveness.** Better Auth's
210
- * cookie+cache layer is treated as a SESSION POINTER (it owns the signed-cookie
211
- * secret and the canonical cookie parse) but does NOT decide whether a session
212
- * is alive. If Better Auth's primary path returns null or a partial session
213
- * (cookie cache miss, secondary storage eviction, token rotation), we fall
214
- * back to manually extracting the session-token claim from the cookie and
215
- * querying the canonical Redis store directly.
216
- *
217
- * This closes the asymmetric early-exit that caused contradictory answers
218
- * within milliseconds in production traces:
219
- * GET /api/session/viability → 200 (Redis: alive)
220
- * GET /api/session/idp-token → 200 (Redis: alive)
221
- * getFreshIdpToken → NO_SESSION (Better Auth cache miss)
222
- *
223
- * Returns the session object or null if not authenticated per Redis.
224
- *
225
- * PAY-2216: this is now a thin wrapper over {@link resolveSessionState} and returns the session ONLY for `live`.
226
- * Its signature and every answer it gives are unchanged; callers that must tell a dead session from an
227
- * unreachable store call resolveSessionState directly.
228
- */
229
- export async function getSession(request?: Request): Promise<any> {
230
- const state = await resolveSessionState(request);
231
- return state.state === 'live' ? state.session : null;
232
- }
233
-
234
- /**
235
- * PAY-2216: what the session store said about this request's cookie. `getSession()` collapses all three
236
- * non-live answers to `null`, and that collapse is the defect this type exists to undo.
237
- *
238
- * MEASURED (PAY-2216, NextPert 2026-10-11, portal fe547fa4 / next-mvp 4.5.4 / ioredis 5.3.2, real server):
239
- * a Redis OUTAGE and a really DELETED key both came out of getSession() as `null`, so viability answered the
240
- * identical `200 No session found` for both. Clearing on that shape would log every live user out on an
241
- * outage (the PAY-2147 class), so nothing cleared at all and a dead cookie lingered.
242
- *
243
- * live - the session resolved. `session` is exactly what getSession() returns.
244
- * missing - a cookie whose signature VERIFIED (HMAC or JWS, resolveVerifiedSessionTokenFromRequest) and BOTH
245
- * store reads (canonical `{slug}:sess:{t}` and Better Auth's `ba:{slug}:{t}`) COMPLETED and found
246
- * nothing. The store answered; the session is gone. Clearing the cookie is correct. This includes
247
- * a Redis that restarted EMPTY: those sessions really are gone (BAPert 72093, gate row 6).
248
- * unknown - a store read THREW (connection refused, commandTimeout, max retries). The store did not answer,
249
- * so nothing is known about the session. NEVER clear on this. Callers answer 5xx.
250
- * none - no session cookie, or a cookie whose signature does not verify, with the store not failing.
251
- * Nothing to clear on our authority: a forged cookie never yields `missing`.
252
- *
253
- * The rule the whole type exists for: `missing` REQUIRES a verified cookie AND reads that completed. Weaken
254
- * either and an outage or a forged cookie becomes a clear.
255
- */
256
- export type SessionState =
257
- | { state: 'live'; session: any }
258
- | { state: 'missing'; sessionToken: string }
259
- | { state: 'unknown' }
260
- | { state: 'none' };
261
-
262
- export async function resolveSessionState(request?: Request): Promise<SessionState> {
263
- const auth = await getAuthInstance();
264
- if (!request) return { state: 'none' };
265
-
266
- // Path A — Better Auth primary path (cookie cache → secondary storage).
267
- // Fast happy-path when both Better Auth and Redis agree.
268
- //
269
- // PAY-2216: secondaryStorage.get RETHROWS a Redis failure on purpose (170033), so a throw here means "the store
270
- // could not be asked". It is remembered: if Path B then has no verified token to read with, the honest answer is
271
- // `unknown`, not `none`.
272
- let betterAuthSession: any = null;
273
- let primaryThrew = false;
274
- try {
275
- betterAuthSession = await auth.api.getSession({ headers: request.headers });
276
- } catch (err) {
277
- primaryThrew = true;
278
- logSessionReadSwallowed('primary', err); // fall through to canonical Redis path
279
- }
280
-
281
- if (betterAuthSession?.session?.token && betterAuthSession?.user) {
282
- const sessionToken = betterAuthSession.session.token as string;
283
- let sessionData: SessionData | null = null;
284
- try {
285
- sessionData = await getRedisSession(sessionToken);
286
- if (!sessionData) sessionData = await getBetterAuthRedisSession(sessionToken);
287
- } catch (err) {
288
- logSessionReadSwallowed('A', err); // Redis unavailable
289
- }
290
- if (!sessionData) sessionData = buildSessionDataFromAuthSession(betterAuthSession);
291
- return { state: 'live', session: attachSessionData(betterAuthSession, sessionData, sessionToken) };
292
- }
293
-
294
- // Path B — Better Auth said null/incomplete. Fall back to a VERIFIED cookie
295
- // decode + Redis-canonical liveness check. This catches cases where the
296
- // canonical app session at `{slug}:{token}` is still alive but Better
297
- // Auth's `ba:{slug}:{token}` secondary record was evicted or the
298
- // in-memory cookie cache returned stale-null.
299
- //
300
- // PAY-1476: this path used to take the token with NO signature check, on the
301
- // theory that "Redis lookup is the validation". It is not: a leaked token has
302
- // a live record, so the token string alone authenticated, with any garbage in
303
- // the signature position, and a forged JWS too. The token now comes only from
304
- // the shared verified resolver (HMAC or JWS signature checked first).
305
- const sessionToken = await resolveVerifiedSessionTokenFromRequest(request);
306
- // No verified token. If Better Auth's read THREW, the store was failing, so we cannot say the cookie is
307
- // unverifiable rather than unreadable: `unknown`. Otherwise there is nothing to act on: `none`.
308
- if (!sessionToken) return primaryThrew ? { state: 'unknown' } : { state: 'none' };
309
-
310
- // PAY-2216: both reads must COMPLETE for a miss to mean anything. Either one throwing is `unknown`, which
311
- // nobody may clear on. These two functions do a bare redis.get and do not swallow (verified at 4.5.4).
312
- let sessionData: SessionData | null = null;
313
- try {
314
- sessionData = await getRedisSession(sessionToken);
315
- if (!sessionData) sessionData = await getBetterAuthRedisSession(sessionToken);
316
- } catch (err) {
317
- logSessionReadSwallowed('B', err); // Redis unavailable
318
- return { state: 'unknown' };
319
- }
320
- if (!sessionData) return { state: 'missing', sessionToken };
321
-
322
- // Synthesize a minimal session shape compatible with downstream callers.
323
- // The `session.token` field is what `getFreshIdpToken` and other callers
324
- // read to drive `ensureFreshAccessToken` + Redis lookups, so it MUST be
325
- // populated here even though Better Auth didn't give us a full session.
326
- const synthetic = {
327
- session: { token: sessionToken },
328
- user: {
329
- id: sessionData.userId,
330
- email: sessionData.email,
331
- roles: sessionData.roles,
332
- },
333
- };
334
- return { state: 'live', session: attachSessionData(synthetic, sessionData, sessionToken) };
335
- }
336
-
337
- /**
338
- * Get normalized session data for the current request.
339
- *
340
- * This prefers the app's Redis session because it carries the canonical
341
- * IDP token, roles, and tenant-specific user identity used by app routes.
342
- */
343
- export async function getSessionData(request?: Request): Promise<SessionData | null> {
344
- const session = await getSession(request);
345
- const sessionData =
346
- ((session as any)?.sessionData as SessionData | undefined) ||
347
- buildSessionDataFromAuthSession(session);
348
-
349
- if (!sessionData) {
350
- return null;
351
- }
352
-
353
- const sessionToken = session?.session?.token as string | undefined;
354
- return sessionToken
355
- ? { ...sessionData, sessionToken }
356
- : sessionData;
357
- }
358
-
359
- /**
360
- * Get the current request's IDP access token without triggering a refresh.
361
- *
362
- * Use this for routes that only need the currently-issued bearer token and
363
- * should fail closed instead of performing token lifecycle work. For backend
364
- * proxy routes that forward the token to a downstream API, prefer
365
- * `getFreshIdpToken` — it preflights expiry and refreshes single-flight, so
366
- * the proxy never sends a credential it already knows is invalid.
367
- */
368
- export async function getIdpToken(request?: Request): Promise<IdpTokenResult> {
369
- const sessionData = await getSessionData(request);
370
- if (!sessionData) {
371
- return { success: false, error: 'NO_SESSION', terminal: true };
372
- }
373
-
374
- const accessToken = sessionData.idpAccessToken || (sessionData as any).accessToken;
375
- if (!accessToken) {
376
- // Card 187871: a live Better Auth session with no idpAccessToken and
377
- // mfaVerified !== true is a user mid-2FA, not a dead session - the IDP
378
- // withholds the full token until 2FA completes (idp-fetch.ts:81/113).
379
- // Only treat this as terminal once the session itself claims 2FA is
380
- // already done; a token missing AFTER that is genuinely broken.
381
- if (sessionData.mfaVerified !== true) {
382
- return { success: false, error: 'NO_TOKEN_PENDING_2FA', terminal: false, sessionData };
383
- }
384
- return { success: false, error: 'NO_TOKEN', terminal: true };
385
- }
386
-
387
- return {
388
- success: true,
389
- accessToken,
390
- sessionData,
391
- };
392
- }
393
-
394
- export type FreshIdpTokenResult =
395
- | { success: true; accessToken: string; sessionData: SessionData; refreshed: boolean }
396
- | {
397
- success: false;
398
- error: string;
399
- status: number;
400
- terminal?: boolean;
401
- discardToken?: boolean;
402
- retryable?: boolean;
403
- resolution?: string;
404
- };
405
-
406
- export interface FreshIdpTokenConfig {
407
- idpBaseUrl: string;
408
- clientId: string;
409
- refreshEndpoint?: string;
410
- /** Refresh if the access token is within this many ms of expiry. Default 60_000. */
411
- safetyWindowMs?: number;
412
- }
413
-
414
- /**
415
- * Get the current request's IDP access token, preflight-refreshing if it is
416
- * expired or within the safety window. Single-flight via Redis lock, so
417
- * concurrent calls on the same session share one IDP round-trip and one
418
- * single-use refresh-token consumption.
419
- *
420
- * Use this in proxy routes. The returned `accessToken` is safe to forward to
421
- * a downstream API without expecting a 401. If `success` is false, surface a
422
- * 401/redirect — there is no recoverable token for this session.
423
- */
424
- export async function getFreshIdpToken(
425
- request: Request | undefined,
426
- config: FreshIdpTokenConfig
427
- ): Promise<FreshIdpTokenResult> {
428
- const session = await getSession(request);
429
- const sessionToken = session?.session?.token as string | undefined;
430
- if (!sessionToken) {
431
- logNoSessionOriginA(request);
432
- return { success: false, error: 'NO_SESSION', status: 401, terminal: true };
433
- }
434
-
435
- const { ensureFreshAccessToken } = await import('../lib/ensure-fresh-access-token');
436
- const result = await ensureFreshAccessToken(
437
- sessionToken,
438
- {
439
- idpBaseUrl: config.idpBaseUrl,
440
- clientId: config.clientId,
441
- refreshEndpoint: config.refreshEndpoint,
442
- },
443
- {
444
- safetyWindowMs: config.safetyWindowMs,
445
- requestId: request?.headers?.get('x-request-id') ?? undefined,
446
- }
447
- );
448
-
449
- if (!result.ok) {
450
- if (result.code === 'NO_SESSION') logNoSessionWithKeys('b', sessionToken);
451
- return {
452
- success: false,
453
- error: result.code,
454
- status: result.status,
455
- terminal: result.terminal,
456
- discardToken: result.discardToken,
457
- retryable: result.retryable,
458
- resolution: result.resolution,
459
- };
460
- }
461
-
462
- const sessionData = await getRedisSession(sessionToken);
463
- if (!sessionData) {
464
- logNoSessionWithKeys('c', sessionToken);
465
- return { success: false, error: 'NO_SESSION', status: 401, terminal: true };
466
- }
467
-
468
- return {
469
- success: true,
470
- accessToken: result.accessToken,
471
- sessionData,
472
- refreshed: result.refreshed,
473
- };
474
- }
475
-
476
- /**
477
- * Get the current session, throwing if not authenticated.
478
- * Use in API handlers that require auth.
479
- */
480
- export async function requireSession(request: Request) {
481
- const session = await getSession(request);
482
- if (!session) {
483
- throw new Error('Unauthorized');
484
- }
485
- return session;
486
- }
1
+ /**
2
+ * Server-side auth utilities for Better Auth.
3
+ *
4
+ * All server-side auth flows go through the Better Auth instance returned by
5
+ * getAuthInstance(); use getSession(req) for the request-scoped session.
6
+ */
7
+
8
+ import 'server-only';
9
+ import { getBetterAuthInstance } from '../auth/better-auth';
10
+ import {
11
+ getSession as getRedisSession,
12
+ getBetterAuthSession as getBetterAuthRedisSession,
13
+ describeSessionKeyPresence,
14
+ type SessionData,
15
+ } from '../lib/session-store';
16
+ import { tokenLogId } from '../lib/token-log-id';
17
+ import { resolveVerifiedSessionTokenFromRequest } from './verified-session-token';
18
+
19
+ export type IdpTokenResult =
20
+ | { success: true; accessToken: string; sessionData: SessionData }
21
+ | { success: false; error: 'NO_SESSION'; terminal: true }
22
+ // Card 187871: a Better Auth session can exist with NO idpAccessToken yet
23
+ // because 2FA is required and not yet completed - the IDP never issued the
24
+ // full token. That is a RECOVERABLE state (route to 2FA), not a dead
25
+ // session. Only collapse it to NO_TOKEN/terminal when the session itself
26
+ // already claims 2FA is done (sessionData.mfaVerified === true) and there is
27
+ // still no token - that combination is genuinely broken.
28
+ | { success: false; error: 'NO_TOKEN'; terminal: true }
29
+ | { success: false; error: 'NO_TOKEN_PENDING_2FA'; terminal: false; sessionData: SessionData };
30
+
31
+ function buildSessionDataFromAuthSession(session: any): SessionData | null {
32
+ const user = session?.user;
33
+ if (!user?.id && !user?.email) {
34
+ return null;
35
+ }
36
+
37
+ const expiresAt = session?.session?.expiresAt
38
+ ? new Date(session.session.expiresAt).getTime()
39
+ : Date.now() + 24 * 60 * 60 * 1000;
40
+
41
+ return {
42
+ userId: user.userId || user.id || '',
43
+ email: user.email || '',
44
+ name: user.name || undefined,
45
+ image: user.image || undefined,
46
+ roles: Array.isArray(user.roles) ? user.roles : [],
47
+ idpAccessToken: user.idpAccessToken,
48
+ idpRefreshToken: user.idpRefreshToken,
49
+ idpAccessTokenExpires: user.idpAccessTokenExpires || expiresAt,
50
+ mfaVerified: user.mfaVerified ?? user.twoFactorSessionVerified ?? false,
51
+ oauthProvider: user.oauthProvider,
52
+ idpClientId: user.idpClientId,
53
+ merchantId: user.merchantId,
54
+ };
55
+ }
56
+
57
+ function attachSessionData(session: any, sessionData: SessionData | null, sessionToken?: string) {
58
+ if (!sessionData) {
59
+ return session;
60
+ }
61
+
62
+ const enrichedSessionData = {
63
+ ...sessionData,
64
+ ...(sessionToken ? { sessionToken } : {}),
65
+ };
66
+
67
+ (session as any).sessionData = enrichedSessionData;
68
+
69
+ if (session?.user) {
70
+ const user = session.user as any;
71
+ user.userId = enrichedSessionData.userId || user.userId;
72
+ user.email = enrichedSessionData.email || user.email;
73
+ user.name = enrichedSessionData.name || user.name;
74
+ user.image = enrichedSessionData.image || user.image;
75
+ user.roles = enrichedSessionData.roles || user.roles || [];
76
+ user.idpAccessToken = enrichedSessionData.idpAccessToken;
77
+ user.idpRefreshToken = enrichedSessionData.idpRefreshToken;
78
+ user.idpAccessTokenExpires = enrichedSessionData.idpAccessTokenExpires;
79
+ user.mfaVerified = enrichedSessionData.mfaVerified;
80
+ user.twoFactorSessionVerified =
81
+ enrichedSessionData.mfaVerified ?? user.twoFactorSessionVerified;
82
+ user.oauthProvider = enrichedSessionData.oauthProvider || user.oauthProvider;
83
+ user.idpClientId = enrichedSessionData.idpClientId || user.idpClientId;
84
+ user.merchantId = enrichedSessionData.merchantId || user.merchantId;
85
+ }
86
+
87
+ return session;
88
+ }
89
+
90
+ /**
91
+ * Get the initialized Better Auth instance (singleton).
92
+ *
93
+ * 244549: the single-flight memoisation lives in lib/single-flight.ts rather
94
+ * than inline here, and that placement is deliberate rather than tidiness.
95
+ * The bug being fixed -- a REJECTED init promise cached for the life of the
96
+ * process, so recovery was restart-only -- went unnoticed precisely because
97
+ * this module cannot be loaded in a unit test: importing it pulls in
98
+ * better-auth and the whole IDP config stack. Extracted into a zero-import
99
+ * module, the retry semantics are covered by
100
+ * test/single-flight-retry.test.cjs, which imports dist/ and therefore
101
+ * exercises the artefact that actually ships.
102
+ *
103
+ * PAY-1623 (package half, BAPert 62449): THIS FUNNEL NO LONGER BUILDS ITS OWN
104
+ * INSTANCE. It used to call `createBetterAuthInstance(config)` with no opts, so
105
+ * it carried NONE of the app-supplied plugins registered by the host app at
106
+ * startup (`configureBetterAuth()` -> `state.configuredOpts`). `samlAcs()` and
107
+ * `passkeySignIn()` arrive only that way, so `getAuthInstance()` returned an
108
+ * instance on which `auth.api.samlAcs` was `undefined` — the exact package twin
109
+ * of the portal ACS crash `t.api.samlAcs is not a function` (Jon's walk,
110
+ * 2026-09-21; Portal PR 517 fixed the portal by importing the CONFIGURED
111
+ * instance, `getBetterAuthInstance`, which is why this file must now resolve the
112
+ * same one). It also silently created a SECOND, divergent instance beside the
113
+ * one every /api/auth/* route uses — two instances, two secrets, one process.
114
+ *
115
+ * It now DELEGATES to that configured funnel, which is the single source of
116
+ * truth for the process's Better Auth instance:
117
+ * - it carries `state.configuredOpts` (better-auth.ts:930), so the app
118
+ * plugins are present;
119
+ * - its init is already single-flighted with the retry semantics described in
120
+ * lib/single-flight.ts (see getBetterAuthInstance, card 244549/338215), so
121
+ * this function keeps the same no-latch recovery contract;
122
+ * - there is now ONE instance, so the double-init / divergent-secret hazard
123
+ * this path used to create is gone by construction.
124
+ */
125
+ export async function getAuthInstance() {
126
+ return getBetterAuthInstance();
127
+ }
128
+
129
+
130
+ /* ----------------------------------------------------------------------------------------------------------------
131
+ * PAY-2136 DIAGNOSTICS (BAPert 70873, Nextpert-Scout 70872/70875) - LOG ONLY, NO BEHAVIOUR CHANGE.
132
+ *
133
+ * A getFreshIdpToken NO_SESSION used to carry no origin: three different returns produced the same
134
+ * `{error:'NO_SESSION'}`, and getSession() swallowed every Redis error into a bare `catch {}`. These helpers name
135
+ * the origin and make a swallowed read audible. Hard rules: every helper is wrapped so it can NEVER throw or change
136
+ * a return value; nothing here ever logs a token, a cookie VALUE or a Redis value - only a tokenLogId() fingerprint,
137
+ * cookie NAMES, booleans, and an error name/message with any long opaque run redacted.
138
+ * -------------------------------------------------------------------------------------------------------------- */
139
+
140
+ /** An error name/message, first 80 chars, with any 24+ char opaque run (a token, a key) replaced. */
141
+ function scrubForLog(v: unknown): string {
142
+ const raw = v instanceof Error ? v.message : String(v ?? '');
143
+ return raw.replace(/[A-Za-z0-9_\-.+/=]{24,}/g, '[redacted]').slice(0, 80);
144
+ }
145
+
146
+ /** `catch {}` sites on the session read path: still swallowed, now audible. */
147
+ function logSessionReadSwallowed(path: 'primary' | 'A' | 'B', err: unknown): void {
148
+ try {
149
+ const name = scrubForLog(err instanceof Error ? err.name : typeof err);
150
+ console.warn(`[server-auth] SESSION_READ_SWALLOWED path=${path} err=${name} msg=${scrubForLog(err)}`);
151
+ } catch {
152
+ /* a diagnostic must never change the outcome */
153
+ }
154
+ }
155
+
156
+ /** Origin a: getSession(request) produced no token. Cookie NAMES only, never a value. */
157
+ function logNoSessionOriginA(request: Request | undefined): void {
158
+ try {
159
+ const header = request?.headers?.get('cookie') ?? '';
160
+ // Only segments of the form name=value contribute a NAME. A segment with no '=' is not a cookie pair, and
161
+ // logging it whole could emit a bare token (Nextpert-Scout 70901 finding 1).
162
+ const cookieNames = header
163
+ .split(';')
164
+ // Structural, alphabet-independent (Nextpert-Scout 70945): base64 padding can only sit at the END of a bare
165
+ // value, so a bare padded token splits as name=<token>, value='' or '='. A real pair has a non-empty value that
166
+ // is not only '=' characters. (The old includes('=') let 74% of base64url tokens through as a "name".)
167
+ .filter((c) => {
168
+ const i = c.indexOf('=');
169
+ if (i < 0) return false;
170
+ const v = c.slice(i + 1);
171
+ return v !== '' && !/^=+$/.test(v);
172
+ })
173
+ .map((c) => c.split('=')[0].trim())
174
+ .filter((n) => n !== '')
175
+ // A bare token that merely ENDS in base64 padding ('=') still passes the filter above and its "name" would be
176
+ // the token itself (Nextpert-Scout 70913). Real cookie names carry a separator ('.', '-', '_'); a long name with
177
+ // none is opaque, so it is logged as a placeholder, never as itself.
178
+ .map((n) => (n.length >= 24 && !/[._-]/.test(n) ? '[opaque-name]' : n))
179
+ .slice(0, 20);
180
+ console.warn('[server-auth] NO_SESSION origin=a', { hasCookieHeader: header !== '', cookieNames });
181
+ } catch {
182
+ /* a diagnostic must never change the outcome */
183
+ }
184
+ }
185
+
186
+ /** Origins b and c: a token WAS resolved. Log which of its three keys exist (presence only), fire-and-forget. */
187
+ function logNoSessionWithKeys(origin: 'b' | 'c', sessionToken: string): void {
188
+ try {
189
+ void describeSessionKeyPresence(sessionToken).then(
190
+ (keys) => {
191
+ try {
192
+ console.warn(`[server-auth] NO_SESSION origin=${origin}`, { token: tokenLogId(sessionToken), ...keys });
193
+ } catch {
194
+ /* never throws */
195
+ }
196
+ },
197
+ () => {
198
+ /* never throws */
199
+ }
200
+ );
201
+ } catch {
202
+ /* a diagnostic must never change the outcome */
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Get the current session from a request.
208
+ *
209
+ * Source-of-truth contract: **Redis is canonical for liveness.** Better Auth's
210
+ * cookie+cache layer is treated as a SESSION POINTER (it owns the signed-cookie
211
+ * secret and the canonical cookie parse) but does NOT decide whether a session
212
+ * is alive. If Better Auth's primary path returns null or a partial session
213
+ * (cookie cache miss, secondary storage eviction, token rotation), we fall
214
+ * back to manually extracting the session-token claim from the cookie and
215
+ * querying the canonical Redis store directly.
216
+ *
217
+ * This closes the asymmetric early-exit that caused contradictory answers
218
+ * within milliseconds in production traces:
219
+ * GET /api/session/viability → 200 (Redis: alive)
220
+ * GET /api/session/idp-token → 200 (Redis: alive)
221
+ * getFreshIdpToken → NO_SESSION (Better Auth cache miss)
222
+ *
223
+ * Returns the session object or null if not authenticated per Redis.
224
+ *
225
+ * PAY-2216: this is now a thin wrapper over {@link resolveSessionState} and returns the session ONLY for `live`.
226
+ * Its signature and every answer it gives are unchanged; callers that must tell a dead session from an
227
+ * unreachable store call resolveSessionState directly.
228
+ */
229
+ export async function getSession(request?: Request): Promise<any> {
230
+ const state = await resolveSessionState(request);
231
+ return state.state === 'live' ? state.session : null;
232
+ }
233
+
234
+ /**
235
+ * PAY-2216: what the session store said about this request's cookie. `getSession()` collapses all three
236
+ * non-live answers to `null`, and that collapse is the defect this type exists to undo.
237
+ *
238
+ * MEASURED (PAY-2216, NextPert 2026-10-11, portal fe547fa4 / next-mvp 4.5.4 / ioredis 5.3.2, real server):
239
+ * a Redis OUTAGE and a really DELETED key both came out of getSession() as `null`, so viability answered the
240
+ * identical `200 No session found` for both. Clearing on that shape would log every live user out on an
241
+ * outage (the PAY-2147 class), so nothing cleared at all and a dead cookie lingered.
242
+ *
243
+ * live - the session resolved. `session` is exactly what getSession() returns.
244
+ * missing - a cookie whose signature VERIFIED (HMAC or JWS, resolveVerifiedSessionTokenFromRequest) and BOTH
245
+ * store reads (canonical `{slug}:sess:{t}` and Better Auth's `ba:{slug}:{t}`) COMPLETED and found
246
+ * nothing. The store answered; the session is gone. Clearing the cookie is correct. This includes
247
+ * a Redis that restarted EMPTY: those sessions really are gone (BAPert 72093, gate row 6).
248
+ * unknown - a store read THREW (connection refused, commandTimeout, max retries). The store did not answer,
249
+ * so nothing is known about the session. NEVER clear on this. Callers answer 5xx.
250
+ * none - no session cookie, or a cookie whose signature does not verify, with the store not failing.
251
+ * Nothing to clear on our authority: a forged cookie never yields `missing`.
252
+ *
253
+ * The rule the whole type exists for: `missing` REQUIRES a verified cookie AND reads that completed. Weaken
254
+ * either and an outage or a forged cookie becomes a clear.
255
+ */
256
+ export type SessionState =
257
+ | { state: 'live'; session: any }
258
+ | { state: 'missing'; sessionToken: string }
259
+ | { state: 'unknown' }
260
+ | { state: 'none' };
261
+
262
+ export async function resolveSessionState(request?: Request): Promise<SessionState> {
263
+ const auth = await getAuthInstance();
264
+ if (!request) return { state: 'none' };
265
+
266
+ // Path A — Better Auth primary path (cookie cache → secondary storage).
267
+ // Fast happy-path when both Better Auth and Redis agree.
268
+ //
269
+ // PAY-2216: secondaryStorage.get RETHROWS a Redis failure on purpose (170033), so a throw here means "the store
270
+ // could not be asked". It is remembered: if Path B then has no verified token to read with, the honest answer is
271
+ // `unknown`, not `none`.
272
+ let betterAuthSession: any = null;
273
+ let primaryThrew = false;
274
+ try {
275
+ betterAuthSession = await auth.api.getSession({ headers: request.headers });
276
+ } catch (err) {
277
+ primaryThrew = true;
278
+ logSessionReadSwallowed('primary', err); // fall through to canonical Redis path
279
+ }
280
+
281
+ if (betterAuthSession?.session?.token && betterAuthSession?.user) {
282
+ const sessionToken = betterAuthSession.session.token as string;
283
+ let sessionData: SessionData | null = null;
284
+ try {
285
+ sessionData = await getRedisSession(sessionToken);
286
+ if (!sessionData) sessionData = await getBetterAuthRedisSession(sessionToken);
287
+ } catch (err) {
288
+ logSessionReadSwallowed('A', err); // Redis unavailable
289
+ }
290
+ if (!sessionData) sessionData = buildSessionDataFromAuthSession(betterAuthSession);
291
+ return { state: 'live', session: attachSessionData(betterAuthSession, sessionData, sessionToken) };
292
+ }
293
+
294
+ // Path B — Better Auth said null/incomplete. Fall back to a VERIFIED cookie
295
+ // decode + Redis-canonical liveness check. This catches cases where the
296
+ // canonical app session at `{slug}:{token}` is still alive but Better
297
+ // Auth's `ba:{slug}:{token}` secondary record was evicted or the
298
+ // in-memory cookie cache returned stale-null.
299
+ //
300
+ // PAY-1476: this path used to take the token with NO signature check, on the
301
+ // theory that "Redis lookup is the validation". It is not: a leaked token has
302
+ // a live record, so the token string alone authenticated, with any garbage in
303
+ // the signature position, and a forged JWS too. The token now comes only from
304
+ // the shared verified resolver (HMAC or JWS signature checked first).
305
+ const sessionToken = await resolveVerifiedSessionTokenFromRequest(request);
306
+ // No verified token. If Better Auth's read THREW, the store was failing, so we cannot say the cookie is
307
+ // unverifiable rather than unreadable: `unknown`. Otherwise there is nothing to act on: `none`.
308
+ if (!sessionToken) return primaryThrew ? { state: 'unknown' } : { state: 'none' };
309
+
310
+ // PAY-2216: both reads must COMPLETE for a miss to mean anything. Either one throwing is `unknown`, which
311
+ // nobody may clear on. These two functions do a bare redis.get and do not swallow (verified at 4.5.4).
312
+ let sessionData: SessionData | null = null;
313
+ try {
314
+ sessionData = await getRedisSession(sessionToken);
315
+ if (!sessionData) sessionData = await getBetterAuthRedisSession(sessionToken);
316
+ } catch (err) {
317
+ logSessionReadSwallowed('B', err); // Redis unavailable
318
+ return { state: 'unknown' };
319
+ }
320
+ if (!sessionData) return { state: 'missing', sessionToken };
321
+
322
+ // Synthesize a minimal session shape compatible with downstream callers.
323
+ // The `session.token` field is what `getFreshIdpToken` and other callers
324
+ // read to drive `ensureFreshAccessToken` + Redis lookups, so it MUST be
325
+ // populated here even though Better Auth didn't give us a full session.
326
+ const synthetic = {
327
+ session: { token: sessionToken },
328
+ user: {
329
+ id: sessionData.userId,
330
+ email: sessionData.email,
331
+ roles: sessionData.roles,
332
+ },
333
+ };
334
+ return { state: 'live', session: attachSessionData(synthetic, sessionData, sessionToken) };
335
+ }
336
+
337
+ /**
338
+ * Get normalized session data for the current request.
339
+ *
340
+ * This prefers the app's Redis session because it carries the canonical
341
+ * IDP token, roles, and tenant-specific user identity used by app routes.
342
+ */
343
+ export async function getSessionData(request?: Request): Promise<SessionData | null> {
344
+ const session = await getSession(request);
345
+ const sessionData =
346
+ ((session as any)?.sessionData as SessionData | undefined) ||
347
+ buildSessionDataFromAuthSession(session);
348
+
349
+ if (!sessionData) {
350
+ return null;
351
+ }
352
+
353
+ const sessionToken = session?.session?.token as string | undefined;
354
+ return sessionToken
355
+ ? { ...sessionData, sessionToken }
356
+ : sessionData;
357
+ }
358
+
359
+ /**
360
+ * Get the current request's IDP access token without triggering a refresh.
361
+ *
362
+ * Use this for routes that only need the currently-issued bearer token and
363
+ * should fail closed instead of performing token lifecycle work. For backend
364
+ * proxy routes that forward the token to a downstream API, prefer
365
+ * `getFreshIdpToken` — it preflights expiry and refreshes single-flight, so
366
+ * the proxy never sends a credential it already knows is invalid.
367
+ */
368
+ export async function getIdpToken(request?: Request): Promise<IdpTokenResult> {
369
+ const sessionData = await getSessionData(request);
370
+ if (!sessionData) {
371
+ return { success: false, error: 'NO_SESSION', terminal: true };
372
+ }
373
+
374
+ const accessToken = sessionData.idpAccessToken || (sessionData as any).accessToken;
375
+ if (!accessToken) {
376
+ // Card 187871: a live Better Auth session with no idpAccessToken and
377
+ // mfaVerified !== true is a user mid-2FA, not a dead session - the IDP
378
+ // withholds the full token until 2FA completes (idp-fetch.ts:81/113).
379
+ // Only treat this as terminal once the session itself claims 2FA is
380
+ // already done; a token missing AFTER that is genuinely broken.
381
+ if (sessionData.mfaVerified !== true) {
382
+ return { success: false, error: 'NO_TOKEN_PENDING_2FA', terminal: false, sessionData };
383
+ }
384
+ return { success: false, error: 'NO_TOKEN', terminal: true };
385
+ }
386
+
387
+ return {
388
+ success: true,
389
+ accessToken,
390
+ sessionData,
391
+ };
392
+ }
393
+
394
+ export type FreshIdpTokenResult =
395
+ | { success: true; accessToken: string; sessionData: SessionData; refreshed: boolean }
396
+ | {
397
+ success: false;
398
+ error: string;
399
+ status: number;
400
+ terminal?: boolean;
401
+ discardToken?: boolean;
402
+ retryable?: boolean;
403
+ resolution?: string;
404
+ };
405
+
406
+ export interface FreshIdpTokenConfig {
407
+ idpBaseUrl: string;
408
+ clientId: string;
409
+ refreshEndpoint?: string;
410
+ /** Refresh if the access token is within this many ms of expiry. Default 60_000. */
411
+ safetyWindowMs?: number;
412
+ }
413
+
414
+ /**
415
+ * Get the current request's IDP access token, preflight-refreshing if it is
416
+ * expired or within the safety window. Single-flight via Redis lock, so
417
+ * concurrent calls on the same session share one IDP round-trip and one
418
+ * single-use refresh-token consumption.
419
+ *
420
+ * Use this in proxy routes. The returned `accessToken` is safe to forward to
421
+ * a downstream API without expecting a 401. If `success` is false, surface a
422
+ * 401/redirect — there is no recoverable token for this session.
423
+ */
424
+ export async function getFreshIdpToken(
425
+ request: Request | undefined,
426
+ config: FreshIdpTokenConfig
427
+ ): Promise<FreshIdpTokenResult> {
428
+ const session = await getSession(request);
429
+ const sessionToken = session?.session?.token as string | undefined;
430
+ if (!sessionToken) {
431
+ logNoSessionOriginA(request);
432
+ return { success: false, error: 'NO_SESSION', status: 401, terminal: true };
433
+ }
434
+
435
+ const { ensureFreshAccessToken } = await import('../lib/ensure-fresh-access-token');
436
+ const result = await ensureFreshAccessToken(
437
+ sessionToken,
438
+ {
439
+ idpBaseUrl: config.idpBaseUrl,
440
+ clientId: config.clientId,
441
+ refreshEndpoint: config.refreshEndpoint,
442
+ },
443
+ {
444
+ safetyWindowMs: config.safetyWindowMs,
445
+ requestId: request?.headers?.get('x-request-id') ?? undefined,
446
+ }
447
+ );
448
+
449
+ if (!result.ok) {
450
+ if (result.code === 'NO_SESSION') logNoSessionWithKeys('b', sessionToken);
451
+ return {
452
+ success: false,
453
+ error: result.code,
454
+ status: result.status,
455
+ terminal: result.terminal,
456
+ discardToken: result.discardToken,
457
+ retryable: result.retryable,
458
+ resolution: result.resolution,
459
+ };
460
+ }
461
+
462
+ const sessionData = await getRedisSession(sessionToken);
463
+ if (!sessionData) {
464
+ logNoSessionWithKeys('c', sessionToken);
465
+ return { success: false, error: 'NO_SESSION', status: 401, terminal: true };
466
+ }
467
+
468
+ return {
469
+ success: true,
470
+ accessToken: result.accessToken,
471
+ sessionData,
472
+ refreshed: result.refreshed,
473
+ };
474
+ }
475
+
476
+ /**
477
+ * Get the current session, throwing if not authenticated.
478
+ * Use in API handlers that require auth.
479
+ */
480
+ export async function requireSession(request: Request) {
481
+ const session = await getSession(request);
482
+ if (!session) {
483
+ throw new Error('Unauthorized');
484
+ }
485
+ return session;
486
+ }