@payez/next-mvp 4.8.1 → 4.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (314) hide show
  1. package/POST-INSTALL.md +408 -408
  2. package/README.md +859 -859
  3. package/dist/api-handlers/admin/audit.js +13 -21
  4. package/dist/api-handlers/admin/sessions.js +59 -45
  5. package/dist/api-handlers/admin/stats.js +1 -1
  6. package/dist/api-handlers/admin/users.js +15 -23
  7. package/dist/api-handlers/admin/vibe-data.d.ts +5 -0
  8. package/dist/api-handlers/admin/vibe-data.js +5 -0
  9. package/dist/components/account/MobileNavDrawer.js +18 -18
  10. package/dist/components/account/UserAvatarMenu.js +1 -1
  11. package/dist/components/admin/AlertSettingsTab.js +40 -40
  12. package/dist/components/admin/LoggingSettingsTab.js +28 -28
  13. package/dist/lib/session-store.js +21 -21
  14. package/dist/lib/startup-init.js +19 -19
  15. package/dist/pages/login/page.js +28 -28
  16. package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
  17. package/dist/vibe/client.d.ts +1 -1
  18. package/dist/vibe/client.js +2 -1
  19. package/dist/vibe/filters.d.ts +36 -0
  20. package/dist/vibe/filters.js +47 -0
  21. package/dist/vibe/generic.js +2 -1
  22. package/dist/vibe/sessions.js +28 -3
  23. package/package.json +2 -2
  24. package/scripts/check-publish-eol.mjs +33 -15
  25. package/src/api/auth-handler.ts +550 -550
  26. package/src/api/index.ts +17 -17
  27. package/src/api-handlers/account/change-password.ts +170 -170
  28. package/src/api-handlers/account/masked-info.ts +46 -46
  29. package/src/api-handlers/account/profile.ts +99 -99
  30. package/src/api-handlers/account/recovery/forward.ts +117 -117
  31. package/src/api-handlers/account/recovery/initiate.ts +59 -59
  32. package/src/api-handlers/account/recovery/send-code.ts +21 -21
  33. package/src/api-handlers/account/recovery/verify-code.ts +20 -20
  34. package/src/api-handlers/account/reset-password.ts +87 -87
  35. package/src/api-handlers/account/send-code.ts +70 -70
  36. package/src/api-handlers/account/update-phone.ts +84 -84
  37. package/src/api-handlers/account/validate-password.ts +139 -139
  38. package/src/api-handlers/account/verify-email.ts +134 -134
  39. package/src/api-handlers/account/verify-sms.ts +134 -134
  40. package/src/api-handlers/admin/analytics.ts +452 -452
  41. package/src/api-handlers/admin/audit.ts +223 -232
  42. package/src/api-handlers/admin/index.ts +63 -63
  43. package/src/api-handlers/admin/redis-sessions.ts +259 -259
  44. package/src/api-handlers/admin/sessions.ts +341 -327
  45. package/src/api-handlers/admin/site-logs.ts +388 -388
  46. package/src/api-handlers/admin/stats.ts +260 -260
  47. package/src/api-handlers/admin/users.ts +240 -251
  48. package/src/api-handlers/admin/vibe-data.ts +338 -333
  49. package/src/api-handlers/anon/preferences.ts +123 -123
  50. package/src/api-handlers/auth/credential-login-email-code.ts +92 -92
  51. package/src/api-handlers/auth/credential-login.ts +948 -948
  52. package/src/api-handlers/auth/jwks.ts +31 -31
  53. package/src/api-handlers/auth/login.ts +252 -252
  54. package/src/api-handlers/auth/passkey-signin.ts +116 -116
  55. package/src/api-handlers/auth/refresh.ts +829 -829
  56. package/src/api-handlers/auth/signout.ts +201 -201
  57. package/src/api-handlers/auth/status.ts +23 -23
  58. package/src/api-handlers/auth/update-session.ts +144 -144
  59. package/src/api-handlers/auth/validate.ts +45 -45
  60. package/src/api-handlers/auth/verify-code.ts +116 -116
  61. package/src/api-handlers/mfa/backup-code-verify.ts +170 -170
  62. package/src/api-handlers/mfa/backup-codes.ts +42 -42
  63. package/src/api-handlers/mfa/methods.ts +47 -47
  64. package/src/api-handlers/mfa/totp-confirm.ts +43 -43
  65. package/src/api-handlers/mfa/totp-enroll.ts +43 -43
  66. package/src/api-handlers/mfa/totp-verify.ts +172 -172
  67. package/src/api-handlers/oauth/resume.ts +182 -182
  68. package/src/api-handlers/onboarding/send-email-code.ts +18 -18
  69. package/src/api-handlers/onboarding/set-password.ts +21 -21
  70. package/src/api-handlers/onboarding/verify-code.ts +20 -20
  71. package/src/api-handlers/onboarding/verify-welcome-email.ts +17 -17
  72. package/src/api-handlers/passkeys/list.ts +32 -32
  73. package/src/api-handlers/passkeys/manage.ts +55 -55
  74. package/src/api-handlers/passkeys/register-options.ts +43 -43
  75. package/src/api-handlers/passkeys/register-verify.ts +42 -42
  76. package/src/api-handlers/session/refresh-viability.ts +56 -56
  77. package/src/api-handlers/session/viability.ts +238 -238
  78. package/src/api-handlers/test/force-expire.ts +167 -167
  79. package/src/auth/auth-decision.ts +292 -292
  80. package/src/auth/better-auth.ts +1688 -1688
  81. package/src/auth/idp-oauth.ts +310 -310
  82. package/src/auth/password-reset-bridge.ts +98 -98
  83. package/src/auth/revocation-fields.ts +16 -16
  84. package/src/auth/route-config.ts +236 -236
  85. package/src/auth/safe-callback.ts +199 -199
  86. package/src/auth/session-cookie-clear.ts +30 -30
  87. package/src/auth/session-reset-link.ts +159 -159
  88. package/src/auth/session-response-sanitizer.ts +151 -151
  89. package/src/auth/types/auth-types.ts +555 -555
  90. package/src/auth/types/index.ts +7 -7
  91. package/src/auth/unauthenticated-routes.ts +2 -2
  92. package/src/auth/utils/idp-client.ts +364 -364
  93. package/src/auth/utils/index.ts +6 -6
  94. package/src/auth/utils/token-utils.ts +243 -243
  95. package/src/client/AuthContext.tsx +192 -192
  96. package/src/client/better-auth-client.ts +70 -70
  97. package/src/client/fetch-with-auth.ts +47 -47
  98. package/src/client/fetchWithSession.ts +21 -21
  99. package/src/client/index.ts +13 -13
  100. package/src/client/useAnonSession.ts +131 -131
  101. package/src/components/SessionSync.tsx +138 -138
  102. package/src/components/SignalRHealthCheck.tsx +131 -131
  103. package/src/components/account/MobileNavDrawer.tsx +305 -305
  104. package/src/components/account/UserAvatarMenu.tsx +277 -277
  105. package/src/components/account/index.ts +13 -13
  106. package/src/components/admin/AlertSettingsTab.tsx +728 -728
  107. package/src/components/admin/AnalyticsTab.tsx +703 -703
  108. package/src/components/admin/DataBrowserTab.tsx +508 -508
  109. package/src/components/admin/LoggingSettingsTab.tsx +665 -665
  110. package/src/components/admin/SessionsTab.tsx +421 -421
  111. package/src/components/admin/StatsTab.tsx +379 -379
  112. package/src/components/admin/VibeAdminContext.tsx +87 -87
  113. package/src/components/admin/VibeAdminLayout.tsx +187 -187
  114. package/src/components/admin/index.ts +59 -59
  115. package/src/components/auth/CredentialSignInForm.tsx +240 -240
  116. package/src/components/auth/FederatedAuthSection.tsx +129 -129
  117. package/src/components/auth/ModeAwareLoginPage.tsx +146 -146
  118. package/src/components/auth/ModeAwareSignupPage.tsx +200 -200
  119. package/src/components/auth/PasswordResetBridge.tsx +30 -30
  120. package/src/components/auth/TraditionalAuthSection.tsx +103 -103
  121. package/src/components/recovery/CompleteStep.tsx +36 -36
  122. package/src/components/recovery/InitiateRecoveryStep.tsx +68 -68
  123. package/src/components/recovery/RequestedStep.tsx +30 -30
  124. package/src/components/recovery/SelectMethodStep.tsx +73 -73
  125. package/src/components/recovery/SetPasswordStep.tsx +118 -118
  126. package/src/components/recovery/VerifyCodeStep.tsx +90 -90
  127. package/src/components/reserved/ReservedRecoveryWarning.tsx +160 -160
  128. package/src/components/reserved/ReservedStatusBox.tsx +118 -118
  129. package/src/components/ui/BetaBadge.tsx +58 -58
  130. package/src/components/ui/Footer.tsx +93 -93
  131. package/src/config/env.ts +55 -55
  132. package/src/config/logger.ts +62 -62
  133. package/src/config/logging-config.ts +82 -82
  134. package/src/config/unauthenticated-routes.ts +19 -19
  135. package/src/config/vibe-log-transport.ts +264 -264
  136. package/src/edge/internal-api-url.ts +65 -65
  137. package/src/edge/middleware.ts +42 -42
  138. package/src/hooks/useAuth.ts +117 -117
  139. package/src/hooks/useAuthSettings.ts +109 -109
  140. package/src/hooks/useAvailableProviders.ts +143 -143
  141. package/src/hooks/usePasswordValidation.ts +127 -127
  142. package/src/hooks/useProfile.ts +82 -82
  143. package/src/hooks/usePublicAuthSettings.ts +171 -171
  144. package/src/hooks/useSessionExpiration.ts +101 -101
  145. package/src/hooks/useViabilitySession.ts +337 -337
  146. package/src/index.ts +64 -64
  147. package/src/lib/anon-session.ts +213 -213
  148. package/src/lib/api-handler.ts +643 -643
  149. package/src/lib/app-slug.ts +228 -228
  150. package/src/lib/authorize-resume.ts +172 -172
  151. package/src/lib/bearer-fetch.ts +31 -31
  152. package/src/lib/client-key.ts +88 -88
  153. package/src/lib/code-step-copy.ts +83 -83
  154. package/src/lib/credential-sign-in-client.ts +101 -101
  155. package/src/lib/demo-mode.ts +18 -18
  156. package/src/lib/device-id.ts +67 -67
  157. package/src/lib/ensure-fresh-access-token.ts +509 -509
  158. package/src/lib/geolocation.ts +265 -265
  159. package/src/lib/idp-client-config.ts +1005 -1005
  160. package/src/lib/idp-fetch.ts +169 -169
  161. package/src/lib/idp-url-guard.ts +37 -37
  162. package/src/lib/internal-api.ts +171 -171
  163. package/src/lib/jwt-decode-client.ts +45 -45
  164. package/src/lib/jwt-decode.ts +82 -82
  165. package/src/lib/lock-window.ts +49 -49
  166. package/src/lib/login-error-message.ts +41 -41
  167. package/src/lib/never-in-response.ts +64 -64
  168. package/src/lib/nextauth-secret.ts +168 -168
  169. package/src/lib/onboarding-proxy.ts +57 -57
  170. package/src/lib/password-rules.ts +50 -50
  171. package/src/lib/rate-limit-service.ts +9 -9
  172. package/src/lib/redis.ts +105 -105
  173. package/src/lib/refresh-token-validator.ts +64 -64
  174. package/src/lib/roles.ts +177 -177
  175. package/src/lib/safe-error-response.ts +149 -149
  176. package/src/lib/secret-validation.ts +8 -8
  177. package/src/lib/session-cookie-shape.ts +185 -185
  178. package/src/lib/session-store.ts +1092 -1092
  179. package/src/lib/session.ts +34 -34
  180. package/src/lib/single-flight.ts +64 -64
  181. package/src/lib/site-logger.ts +245 -245
  182. package/src/lib/standardized-client-api.ts +901 -901
  183. package/src/lib/startup-init.ts +276 -276
  184. package/src/lib/test-aware-get-token.ts +41 -41
  185. package/src/lib/token-expiry.ts +40 -40
  186. package/src/lib/token-lifecycle.ts +546 -546
  187. package/src/lib/token-log-id.ts +69 -69
  188. package/src/lib/twofa-rejection.ts +91 -91
  189. package/src/lib/types/api-responses.ts +336 -336
  190. package/src/lib/user-agent-parser.ts +252 -252
  191. package/src/logging/api/admin-analytics.ts +51 -51
  192. package/src/logging/api/audit-log.ts +53 -53
  193. package/src/logging/components/AdminAnalyticsLayout.tsx +49 -49
  194. package/src/logging/components/AuditLogViewer.tsx +125 -125
  195. package/src/logging/components/ErrorMetricsCard.tsx +98 -98
  196. package/src/logging/components/HealthMetricsCard.tsx +70 -70
  197. package/src/logging/hooks/useAdminAnalytics.ts +22 -22
  198. package/src/logging/hooks/useAuditLog.ts +24 -24
  199. package/src/logging/hooks/useErrorMetrics.ts +40 -40
  200. package/src/logging/hooks/useHealthMetrics.ts +44 -44
  201. package/src/logging/index.ts +18 -18
  202. package/src/logging/types/analytics.ts +81 -81
  203. package/src/logging/types/audit.ts +31 -31
  204. package/src/logging/types/index.ts +3 -3
  205. package/src/middleware/create-middleware.ts +1141 -1141
  206. package/src/middleware/rbac-check.ts +279 -279
  207. package/src/middleware/twofa-presets.ts +224 -224
  208. package/src/models/DecodedAccessToken.ts +16 -16
  209. package/src/models/SessionModel.ts +331 -331
  210. package/src/pages/admin-login/page.tsx +111 -111
  211. package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -527
  212. package/src/pages/admin-page-permissions/index.ts +7 -7
  213. package/src/pages/admin-roles/RolesAdminPage.tsx +533 -533
  214. package/src/pages/admin-roles/index.ts +9 -9
  215. package/src/pages/admin-roles/modals.tsx +469 -469
  216. package/src/pages/client-admin/ClientSiteAdminPage.tsx +382 -382
  217. package/src/pages/client-admin/index.ts +33 -33
  218. package/src/pages/coming-soon/page.tsx +83 -83
  219. package/src/pages/login/page.tsx +338 -338
  220. package/src/pages/onboarding/index.ts +11 -11
  221. package/src/pages/onboarding/page.tsx +212 -212
  222. package/src/pages/profile/EnhancedProfilePage.tsx +514 -514
  223. package/src/pages/profile/index.ts +9 -9
  224. package/src/pages/profile/page.tsx +166 -166
  225. package/src/pages/recovery/page.tsx +267 -267
  226. package/src/pages/roles/MyRolesPage.tsx +211 -211
  227. package/src/pages/roles/components.tsx +294 -294
  228. package/src/pages/roles/index.ts +17 -17
  229. package/src/pages/security/EnhancedSecurityPage.tsx +660 -660
  230. package/src/pages/security/index.ts +12 -12
  231. package/src/pages/security/page.tsx +507 -507
  232. package/src/pages/service-unavailable/page.tsx +63 -63
  233. package/src/pages/settings/EnhancedSettingsPage.tsx +642 -642
  234. package/src/pages/settings/index.ts +9 -9
  235. package/src/pages/settings/page.tsx +47 -47
  236. package/src/pages/showcase/ShowcasePage.tsx +532 -532
  237. package/src/pages/showcase/index.ts +13 -13
  238. package/src/pages/test-env/EmergencyLogoutPage.tsx +192 -192
  239. package/src/pages/test-env/JwtInspectPage.tsx +449 -449
  240. package/src/pages/test-env/RefreshTokenPage.tsx +178 -178
  241. package/src/pages/test-env/TestEnvPage.tsx +130 -130
  242. package/src/pages/test-env/guarded.tsx +81 -81
  243. package/src/pages/test-env/index.ts +77 -77
  244. package/src/pages/test-env/redact-credentials.ts +96 -96
  245. package/src/pages/verify-code/page.tsx +746 -746
  246. package/src/routes/account/index.ts +31 -31
  247. package/src/routes/account/masked-info.ts +36 -36
  248. package/src/routes/account/send-code.ts +39 -39
  249. package/src/routes/account/update-phone.ts +13 -13
  250. package/src/routes/account/verify-email.ts +40 -40
  251. package/src/routes/account/verify-sms.ts +40 -40
  252. package/src/routes/auth/credential-login-email-code.ts +6 -6
  253. package/src/routes/auth/credential-login.ts +6 -6
  254. package/src/routes/auth/index.ts +22 -22
  255. package/src/routes/auth/logout.ts +111 -111
  256. package/src/routes/auth/nextauth.ts +45 -45
  257. package/src/routes/auth/passkey-signin.ts +13 -13
  258. package/src/routes/auth/refresh.ts +77 -77
  259. package/src/routes/auth/session-reset.ts +37 -37
  260. package/src/routes/auth/session.ts +261 -261
  261. package/src/routes/auth/settings.ts +181 -181
  262. package/src/routes/auth/viability.ts +519 -519
  263. package/src/routes/index.ts +17 -17
  264. package/src/routes/mfa/backup-code-verify.ts +1 -1
  265. package/src/routes/mfa/backup-codes.ts +10 -10
  266. package/src/routes/mfa/methods.ts +22 -22
  267. package/src/routes/mfa/totp-confirm.ts +10 -10
  268. package/src/routes/mfa/totp-enroll.ts +10 -10
  269. package/src/routes/mfa/totp-verify.ts +14 -14
  270. package/src/routes/onboarding/index.ts +15 -15
  271. package/src/routes/onboarding/send-email-code.ts +2 -2
  272. package/src/routes/onboarding/set-password.ts +2 -2
  273. package/src/routes/onboarding/verify-code.ts +2 -2
  274. package/src/routes/onboarding/verify-welcome-email.ts +2 -2
  275. package/src/routes/passkeys/list.ts +10 -10
  276. package/src/routes/passkeys/manage.ts +23 -23
  277. package/src/routes/passkeys/register-options.ts +10 -10
  278. package/src/routes/passkeys/register-verify.ts +10 -10
  279. package/src/routes/session/index.ts +7 -7
  280. package/src/routes/session/refresh-viability.ts +17 -17
  281. package/src/server/auth-guard.ts +203 -203
  282. package/src/server/auth.ts +486 -486
  283. package/src/server/decode-session.ts +309 -309
  284. package/src/server/slim-middleware.ts +117 -117
  285. package/src/server/verified-session-token.ts +103 -103
  286. package/src/server/with-auth.ts +93 -93
  287. package/src/services/signalrActivityService.ts +258 -258
  288. package/src/stores/authStore.ts +1900 -1900
  289. package/src/templates/instrumentation.ts +60 -60
  290. package/src/theme/ThemeProvider.tsx +39 -39
  291. package/src/theme/default.ts +33 -33
  292. package/src/theme/index.ts +31 -31
  293. package/src/theme/types.ts +69 -69
  294. package/src/theme/useTheme.ts +57 -57
  295. package/src/theme/utils.ts +40 -40
  296. package/src/types/api.ts +12 -12
  297. package/src/types/auth.d.ts +23 -23
  298. package/src/types/auth.ts +48 -48
  299. package/src/types/logging.ts +11 -11
  300. package/src/types/recovery.ts +54 -54
  301. package/src/utils/api.ts +401 -401
  302. package/src/utils/circuitBreaker.ts +40 -40
  303. package/src/utils/error-message.ts +108 -108
  304. package/src/utils/layout/reservedSpace.ts +124 -124
  305. package/src/utils/logout.ts +30 -30
  306. package/src/vibe/client.ts +878 -877
  307. package/src/vibe/enterprise-auth.ts +208 -208
  308. package/src/vibe/errors.ts +209 -209
  309. package/src/vibe/filters.ts +52 -0
  310. package/src/vibe/generic.ts +340 -339
  311. package/src/vibe/hooks/index.ts +367 -367
  312. package/src/vibe/index.ts +133 -133
  313. package/src/vibe/sessions.ts +925 -899
  314. package/src/vibe/types.ts +577 -577
@@ -1,520 +1,520 @@
1
- /**
2
- * Ready-to-Use Session Viability Route
3
- *
4
- * Checks if the current session is viable (valid and not expired).
5
- * Used by client-side code to determine if a refresh is needed.
6
- *
7
- * @example
8
- * ```typescript
9
- * // app/api/session/viability/route.ts
10
- * export { GET } from '@payez/next-mvp/routes/auth/viability';
11
- * ```
12
- *
13
- * @version 2.0.0
14
- * @since auth-ready-v2
15
- */
16
-
17
- import { revocationFields } from '../../auth/revocation-fields';
18
- import { NextRequest, NextResponse } from 'next/server';
19
- import { resolveSessionState } from '../../server/auth';
20
- import {
21
- getSession as getRedisSession,
22
- getBetterAuthSession as getBetterAuthRedisSession,
23
- } from '../../lib/session-store';
24
- import { getIDPClientConfig } from '../../lib/idp-client-config';
25
- import { logExceptionWithCorrelation } from '../../lib/safe-error-response';
26
- import { tokenLogId } from '../../lib/token-log-id';
27
- import { getRevocationStatus, warnNonNumericUserId, resolveNumericIdpUserId, type RevocationStatus } from '../../vibe/sessions';
28
-
29
- /**
30
- * Get tenant-wide 2FA requirement from cached client config (from broker handshake)
31
- */
32
- async function getTenantRequiresTwoFactor(): Promise<boolean> {
33
- try {
34
- const config = await getIDPClientConfig();
35
- return config.authSettings?.require2FA ?? true; // Default to true for security
36
- } catch {
37
- console.warn('[VIABILITY] Could not get client config, defaulting tenantRequiresTwoFactor to true');
38
- return true;
39
- }
40
- }
41
-
42
- /**
43
- * Which 2FA methods the TENANT permits — cards 141688 + 141741.
44
- *
45
- * WHY THIS IS HERE: this endpoint answered "is 2FA required, and is it done?"
46
- * and said nothing about WITH WHAT. Both the middleware gate and the
47
- * verify-code page call this route, and NEITHER had any method data — so
48
- * nothing in the system asked whether the user CAN complete the challenge it
49
- * is about to demand. A user whose tenant allows only `totp`, with no
50
- * authenticator enrolled, is sent to a page that offers email and sms.
51
- *
52
- * ⚠ THIS IS NOT A COMPLETION SIGNAL AND MUST NEVER BE READ AS ONE.
53
- * It says what the tenant PERMITS. It says nothing about what the user has
54
- * enrolled, and nothing about whether they have verified. The invariant
55
- * (QAPert, acceptance J2/J5) is: METHOD-PRESENCE IS NOT VERIFIED. A user with
56
- * one method is still challenged and still gated until they actually verify.
57
- * `userHasCompletedTenantTwoFactorRequirements` remains the only field that
58
- * answers "are they through".
59
- *
60
- * STILL MISSING, deliberately not guessed here: the ENROLLED set. That lives
61
- * behind /api/mfa/methods and needs a call this route does not currently make.
62
- * Allowed-only closes the "offered a method the tenant forbids" half; it does
63
- * NOT close "has nothing enrolled and no way to enrol". Both halves are named
64
- * in the cards so neither gets lost.
65
- *
66
- * NULL MEANS UNKNOWN — IT DOES NOT MEAN "NONE", AND IT DOES NOT MEAN
67
- * "email and sms". If the client config cannot be read we do not know what the
68
- * tenant permits, and every available guess is wrong in a way that matters:
69
- *
70
- * - guessing ['email','sms'] fails OPEN. On a totp-only tenant that offers
71
- * the user two methods the tenant forbids — which is defect 141688 itself,
72
- * re-created by its own fix during a config outage.
73
- * - guessing [] fails CLOSED but locks the user out of a challenge they may
74
- * be perfectly able to complete.
75
- *
76
- * So we report the truth (we don't know) and let the consumer decide, rather
77
- * than encoding a guess that reads like a measurement. Same reasoning as the
78
- * nullable require_2fa fix: "absent" has to be expressible, or callers cannot
79
- * tell a real value from a default.
80
- *
81
- * No local default. 257255 UPDATE: allowed2FAMethods is now OPTIONAL on
82
- * AuthSettings - the config mapper no longer substitutes ['email','sms'] when
83
- * the document carries no policy, because that made "no policy" and "exactly
84
- * these methods" indistinguishable. So absent now correctly reaches this
85
- * function as undefined and is reported as UNKNOWN (null), which is what this
86
- * function's own contract already said it wanted. Before the change it would
87
- * have returned the mapper's fabrication AS IF IT WERE THE TENANT'S VALUE.
88
- */
89
- async function getTenantAllowed2FAMethods(): Promise<string[] | null> {
90
- try {
91
- const config = await getIDPClientConfig();
92
- // `?? null` is the whole point: undefined means the document carried no
93
- // policy, and this function reports UNKNOWN rather than guessing.
94
- return config.authSettings.allowed2FAMethods ?? null;
95
- } catch {
96
- console.warn('[VIABILITY] Could not get client config — reporting allowed 2FA methods as UNKNOWN (null), not guessing');
97
- return null;
98
- }
99
- }
100
-
101
- /**
102
- * Resolve the client id reported to the caller.
103
- *
104
- * Was: `session?.idpClientId || process.env.IDP_CLIENT_ID || ''`. Two defects,
105
- * both of the "report a confident value where the truth is unknown" family that
106
- * card 221076 was about:
107
- *
108
- * 1. `|| ''` made "we do not know this session's client" indistinguishable from
109
- * "the client id is blank". Callers cannot tell absence from a known-empty
110
- * value, so nothing can react to the former. Absence is now `null`, which is
111
- * representable — and still falsy, so existing `if (!clientId)` guards behave
112
- * exactly as before.
113
- *
114
- * 2. The env fallback SILENTLY substitutes the deployment's default client for a
115
- * session whose own client is unknown. On a multi-tenant IDP that is a
116
- * cross-tenant wrong answer, not a default. It is kept for single-tenant
117
- * deployments that rely on it, but it now says so in the log rather than
118
- * passing the substitution off as the session's own identity.
119
- */
120
- function resolveClientId(sessionClientId: string | undefined): string | null {
121
- if (sessionClientId) return sessionClientId;
122
-
123
- const envClientId = process.env.IDP_CLIENT_ID;
124
- if (envClientId) {
125
- console.warn(
126
- '[VIABILITY_ROUTE] session carries no idpClientId - substituting IDP_CLIENT_ID ' +
127
- 'from the environment. On a multi-tenant deployment this is a DIFFERENT client ' +
128
- 'than the session belongs to.'
129
- );
130
- return envClientId;
131
- }
132
-
133
- return null;
134
- }
135
-
136
- /**
137
- * GET /api/session/viability - Check if session is viable
138
- *
139
- * Returns:
140
- * - viable: boolean - Whether the session can be used
141
- * - needsRefresh: boolean - Whether a refresh is recommended
142
- * - expiresIn: number - Seconds until token expires
143
- */
144
- export async function GET(req: NextRequest) {
145
- try {
146
- // PAY-2216: ask for the session's STATE, not a session-or-null. A null used to cover a store outage, a deleted
147
- // session and no cookie alike, so this route answered 'No session found' to all three and the middleware could
148
- // never clear a dead cookie (it must not clear on an outage). See SessionState in server/auth.
149
- const sessionState = await resolveSessionState(req);
150
-
151
- if (sessionState.state === 'unknown') {
152
- // The store did not answer. NOT a statement about the session: never stale, never 'No session found'.
153
- // Same shape as this route's catch (PAY-1882 step 4): 'unknown' revocation -> the middleware's fail-closed
154
- // path, a non-OK answer -> service_error, which never clears cookies.
155
- console.warn('[VIABILITY] Session store unavailable - session state unknown');
156
- return NextResponse.json({
157
- viable: false,
158
- needsRefresh: false,
159
- authenticated: false,
160
- revocationStatus: 'unknown' as RevocationStatus,
161
- unavailable: true, // PAY-2339: a reader of this JSON gets the hold signal (the response is non-OK, so the middleware holds as service_error)
162
- error: 'Session store unavailable',
163
- code: 'STORE_UNAVAILABLE'
164
- }, { status: 500 });
165
- }
166
-
167
- if (sessionState.state === 'missing') {
168
- // A VERIFIED cookie and a store that answered "not here". The existing stale shape, so the middleware's
169
- // stale_session arm (PAY-2216 / 4.5.4) clears it.
170
- console.warn('[VIABILITY] Stale cookie detected - verified cookie, session not in Redis');
171
- return NextResponse.json({
172
- viable: false,
173
- needsRefresh: false,
174
- authenticated: false,
175
- sessionTokenId: tokenLogId(sessionState.sessionToken), // PAY-1481: a correlation id, never the token
176
- reason: 'Stale session - cookie exists but session not found in Redis'
177
- });
178
- }
179
-
180
- if (sessionState.state === 'none') {
181
- return NextResponse.json({
182
- viable: false,
183
- needsRefresh: false,
184
- authenticated: false,
185
- reason: 'No session found'
186
- });
187
- }
188
-
189
- const baSession = sessionState.session;
190
-
191
- const token = baSession as any;
192
- const sessionToken = baSession.session?.token;
193
-
194
- // Try the canonical session-store first; fall back to the Better-Auth-keyed
195
- // Redis record (`ba:{appSlug}:{token}`) when the canonical lookup misses.
196
- // Without the fallback, any consumer that wrote a BA session via the OAuth
197
- // callback path or dev-login and didn't separately populate the canonical
198
- // store would land here with a "Stale session" verdict, even though the BA
199
- // stack just resolved the cookie cleanly. Keeps the two viability impls
200
- // (this route + `api-handlers/session/viability.ts`) in lockstep.
201
- let session = sessionToken ? await getRedisSession(sessionToken) : null;
202
- if (sessionToken && !session) {
203
- session = await getBetterAuthRedisSession(sessionToken);
204
- if (session) {
205
- console.log('[VIABILITY] Found session in Better Auth store (fallback)');
206
- }
207
- }
208
-
209
- // CRITICAL: Detect stale cookie state (cookie exists but neither store has the session)
210
- if (sessionToken && !session) {
211
- console.warn('[VIABILITY] Stale cookie detected - session not in Redis');
212
- return NextResponse.json({
213
- viable: false,
214
- needsRefresh: false,
215
- authenticated: false,
216
- sessionTokenId: tokenLogId(sessionToken), // PAY-1481: a correlation id, never the token; middleware only needs presence to clear the stale cookie
217
- reason: 'Stale session - cookie exists but session not found in Redis'
218
- });
219
- }
220
-
221
- // Cards 141688/141741 — the IDP's routing decision, relayed verbatim.
222
- // See the SessionData.nextStep doc. This is the field that tells the gate
223
- // whether the user CAN complete a challenge; without it both consumers were
224
- // sending zero-method users to a page they had no way to act on.
225
- //
226
- // Declared once, here, above every return: `session` is settled by this
227
- // point, and all three authenticated responses must carry the SAME value.
228
- // Deriving it per-branch would let the paths drift apart silently.
229
- //
230
- // NO `?? null` HERE, DELIBERATELY — and this is the whole safety argument:
231
- //
232
- // undefined = we never captured it. Every session minted BEFORE this
233
- // change is in that state, and there are live ones in Redis
234
- // right now. JSON.stringify omits the key, so the consumer
235
- // sees no field and knows it is uninformed.
236
- // null = the IDP affirmatively said "nothing outstanding".
237
- //
238
- // Collapsing the first into the second with `?? null` would tell the gate
239
- // "fully authenticated, nothing to do" about every pre-existing session —
240
- // including a zero-method user who must be sent to enrol. That is a
241
- // fail-open on the entire existing session population, produced by a
242
- // default that reads like a measurement. Same mistake as the nullable
243
- // require_2fa case: absent has to stay expressible.
244
- const nextStep = session?.nextStep;
245
-
246
- /**
247
- * 266007 — port the sibling's CRITICAL override (api-handlers/session/
248
- * viability.ts:82-86) into this, the impl consumers actually MOUNT.
249
- * An OAuth session whose callback set mfaVerified === false because no
250
- * refresh_token was issued ALWAYS needs 2FA completion, whatever the
251
- * tenant config says — completing 2FA is how it obtains one.
252
- *
253
- * STRICT === false, deliberately: an ABSENT mfaVerified (every session
254
- * minted before that field existed) is not a signal, and treating it as
255
- * one would force-rechallenge the entire existing session population.
256
- *
257
- * *** R807 (BAPert, card 362550) - WHY THE `=== false` TEST ALONE NO LONGER
258
- * CARRIES THAT INTENT, AND WHAT RESTORES IT. ***
259
- *
260
- * The intent above is right, but by the time a session reaches here the
261
- * distinction it depends on IS ALREADY GONE: every construction site
262
- * (session-store, decode-session, token-lifecycle x2, SessionModel) collapses
263
- * an absent mfaVerified to `false` via `?? false`. So `=== false` had stopped
264
- * meaning "the callback affirmatively said false" and had come to mean
265
- * "affirmatively false OR never knew" - i.e. the exact mass re-challenge this
266
- * comment says it is preventing. The guard read as strict while behaving as
267
- * permissive, because the collapse happens upstream and out of sight.
268
- *
269
- * R807 keeps `mfaVerified` fail-closed at all five sites (removing `?? false`
270
- * would be an AUTH BYPASS - a session that never proved MFA read as having
271
- * proved it) and gives THIS question its own signal instead. So the test is
272
- * now two parts:
273
- *
274
- * mfaSignalPresent === true -> the producer really did emit a value, so the
275
- * `false` below is a MEASUREMENT. Fire.
276
- * absent / false -> LEGACY session (minted before the signal
277
- * existed) or a path with no IDP tokens at all.
278
- * NOT a signal. Do NOT force a re-challenge.
279
- *
280
- * That absent-means-do-not-fire read is the migration behaviour, and it is
281
- * what preserves this comment's original guarantee verbatim through rollout:
282
- * on the day this ships, EVERY existing session has no signal and none of them
283
- * is re-challenged.
284
- *
285
- * *** AND IT IS SAFE ONLY BECAUSE IT IS CONFINED HERE. *** mfaSignalPresent is
286
- * permissive by construction - it can only ever SUPPRESS this override. It must
287
- * never reach a path that grants access; gate those on `mfaVerified`, which is
288
- * still fail-closed everywhere. R807 is void if this signal escapes into an
289
- * auth decision.
290
- *
291
- * Declared once here, like nextStep above, so the three authenticated
292
- * responses cannot drift apart. Feeds the legacy `requires2FA` field the
293
- * middleware actually gates on; `tenantRequiresTwoFactor` stays pure —
294
- * it names TENANT policy, and this override is not tenant state.
295
- */
296
- const oauthSessionNeedsTwoFactor =
297
- session?.mfaSignalPresent === true &&
298
- session?.mfaVerified === false &&
299
- !(session.idpRefreshToken || (session as any)?.refreshToken);
300
-
301
- // --- Session revocation status (PAY-1882 step 4 producer half) ---
302
- /*
303
- * Resolve the four-state revocation status for this user (BAPert 66320).
304
- * Read it HERE, above every authenticated return, so all three responses
305
- * carry the SAME value - the same discipline as nextStep above.
306
- *
307
- * The middleware fails CLOSED by default on 'unknown' (a failed Vibe read)
308
- * and always on 'revoked'; 'active' and 'not_configured' stay viable. A
309
- * consumer that does NOT read revocationStatus is unaffected - absent is not
310
- * revoked - so shipping this changes nothing for them until they opt in.
311
- */
312
- let revocationStatus: RevocationStatus = 'not_configured';
313
- if (session) {
314
- // BAPert 66480 condition 2 / ruling (ii) 66700: the numeric IdP id comes from
315
- // the token's own `sub` claim FIRST (session.userId can be an email).
316
- const revocationUserId = resolveNumericIdpUserId(session, (session as any).idpAccessToken);
317
- if (revocationUserId > 0) {
318
- try {
319
- revocationStatus = await getRevocationStatus(revocationUserId, sessionToken);
320
- } catch {
321
- // getRevocationStatus does not throw by contract, but never let a
322
- // revocation probe take down the viability answer: fail closed.
323
- revocationStatus = 'unknown';
324
- }
325
- } else {
326
- // N1 (NightHawk 66392): a session exists but carries no usable numeric
327
- // IdP id, so the check could not run -> 'unknown' (could not determine),
328
- // NOT 'not_configured' (no revocation source).
329
- // BAPert 66480 condition 1: emit ONE WARN (shape only) so a non-numeric
330
- // id surfaces as a log line, not an unexplained sign-out.
331
- revocationStatus = warnNonNumericUserId(sessionToken, session.userId);
332
- }
333
- }
334
-
335
- // Check access token expiry
336
- const now = Math.floor(Date.now() / 1000);
337
- // 240654 addendum 2: fall back to the Redis session's own expiry.
338
- //
339
- // The first two reads come off the Better Auth session OBJECT. A Path-B
340
- // synthetic session — the shape server/auth.ts builds for a legacy JWS
341
- // session — carries NEITHER: it is `{session:{token}, user:{...}}` with no
342
- // accessTokenExpires and no exp. So every legacy session fell into the
343
- // "no expiry info" branch below and answered needsRefresh:true on EVERY
344
- // viability read from the moment of login, while the token was in fact
345
- // fresh (15-minute TTL measured). Benign for the viable/authenticated
346
- // verdict, but it recommends a refresh constantly, which is pure perf
347
- // noise and multiplies exposure to the empty-clientId refresh path (223542).
348
- //
349
- // lib/session-store puts the real value on the session as
350
- // idpAccessTokenExpires, in MILLISECONDS (better-auth.ts:784 sets
351
- // `Date.now() + expires_in * 1000`). The other two sources are in SECONDS.
352
- // Mixing units here would be a silent disaster, so note that the
353
- // normalisation below (`> 1e12 ? /1000 : as-is`) already discriminates
354
- // them by magnitude: a seconds timestamp is ~1.7e9, a milliseconds one
355
- // ~1.7e12. This fallback is therefore safe in either unit, and that
356
- // existing normaliser is load-bearing for it -- do not remove it.
357
- const accessTokenExpires =
358
- (token as any).accessTokenExpires ||
359
- token.exp ||
360
- (session as any)?.idpAccessTokenExpires;
361
-
362
- if (!accessTokenExpires) {
363
- // No expiry info, assume viable but recommend refresh
364
- const tenantRequiresTwoFactor = await getTenantRequiresTwoFactor();
365
- // Cards 141688/141741 — see getTenantAllowed2FAMethods. NOT a completion signal.
366
- const tenantAllowed2FAMethods = await getTenantAllowed2FAMethods();
367
-
368
- // CRITICAL: Check if MFA has expired (2FA TTL enforcement)
369
- const mfaExpiresAt = session?.mfaExpiresAt || 0;
370
- const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
371
- // Check mfaVerified (normalized name) with fallback to twoFactorComplete for compatibility
372
- const mfaVerifiedInSession = session?.mfaVerified ?? false;
373
-
374
- // User has completed 2FA requirements if: they verified AND it hasn't expired
375
- const userHasCompletedTenantTwoFactorRequirements = mfaVerifiedInSession && !mfaExpired;
376
-
377
- // userStillNeedsTwoFactor = inverse of completed (matches session callback logic)
378
- const userStillNeedsTwoFactor = !userHasCompletedTenantTwoFactorRequirements;
379
-
380
- return NextResponse.json({
381
- viable: true,
382
- needsRefresh: true,
383
- authenticated: true,
384
- sessionTokenId: sessionToken ? tokenLogId(sessionToken) : null,
385
- // Session revocation (PAY-1882 step 4): the four-state status, plus the
386
- // boolean the middleware's existing force-invalid branch already reads.
387
- revocationStatus,
388
- ...revocationFields(revocationStatus), // PAY-2339: forceInvalid only for 'revoked'; unavailable:true for 'unknown'
389
- // Clear names for middleware decision-making
390
- tenantRequiresTwoFactor,
391
- userHasCompletedTenantTwoFactorRequirements,
392
- userStillNeedsTwoFactor,
393
- tenantAllowed2FAMethods,
394
- nextStep,
395
- // Legacy field names for backwards compatibility
396
- requires2FA: tenantRequiresTwoFactor || oauthSessionNeedsTwoFactor,
397
- twoFactorComplete: userHasCompletedTenantTwoFactorRequirements,
398
- accessTokenExpired: false,
399
- reason: 'No expiry information'
400
- });
401
- }
402
-
403
- // Convert to seconds if needed
404
- const expiryTime = accessTokenExpires > 1000000000000
405
- ? Math.floor(accessTokenExpires / 1000)
406
- : accessTokenExpires;
407
-
408
- const expiresIn = expiryTime - now;
409
- const isExpired = expiresIn <= 0;
410
- const needsRefresh = expiresIn <= 300; // 5 minutes buffer
411
-
412
- // Check if we have refresh capability (check normalized field name first)
413
- const hasRefreshToken = !!(session?.idpRefreshToken || (session as any)?.refreshToken || (token as any).refreshToken);
414
-
415
- // CLEAR NAMING: Tenant-wide 2FA requirement from client config
416
- const tenantRequiresTwoFactor = await getTenantRequiresTwoFactor();
417
- // Cards 141688/141741 — see getTenantAllowed2FAMethods. NOT a completion signal.
418
- const tenantAllowed2FAMethods = await getTenantAllowed2FAMethods();
419
-
420
- // CRITICAL: Check if MFA has expired (2FA TTL enforcement)
421
- // The session may have mfaVerified=true from days ago, but if mfaExpiresAt
422
- // has passed, we must treat 2FA as incomplete to force re-verification.
423
- const mfaExpiresAt = session?.mfaExpiresAt || 0;
424
- const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
425
- // Check mfaVerified (normalized name) with fallback to twoFactorComplete for compatibility
426
- const mfaVerifiedInSession = session?.mfaVerified ?? false;
427
-
428
- // DEBUG: Log what we're reading from the session
429
- console.log('[VIABILITY] Session 2FA state:', {
430
- sessionToken: tokenLogId(sessionToken),
431
- 'session.mfaVerified': session?.mfaVerified,
432
- 'session.twoFactorComplete': (session as any)?.twoFactorComplete,
433
- mfaVerifiedInSession,
434
- mfaExpiresAt,
435
- mfaExpired,
436
- hasRefreshToken,
437
- 'session.idpRefreshToken': !!session?.idpRefreshToken,
438
- 'session.refreshToken': !!(session as any)?.refreshToken,
439
- });
440
-
441
- // CLEAR NAMING: User has completed 2FA requirements if: they verified AND it hasn't expired
442
- const userHasCompletedTenantTwoFactorRequirements = mfaVerifiedInSession && !mfaExpired;
443
-
444
- // userStillNeedsTwoFactor = inverse of completed (matches session callback logic)
445
- const userStillNeedsTwoFactor = !userHasCompletedTenantTwoFactorRequirements;
446
-
447
- if (mfaExpired && mfaVerifiedInSession) {
448
- console.warn('[VIABILITY] MFA expired - forcing 2FA re-verification');
449
- }
450
-
451
- if (isExpired) {
452
- return NextResponse.json({
453
- viable: false,
454
- needsRefresh: hasRefreshToken,
455
- expiresIn: 0,
456
- hasRefreshToken,
457
- authenticated: true,
458
- sessionTokenId: sessionToken ? tokenLogId(sessionToken) : null,
459
- // Clear names
460
- tenantRequiresTwoFactor,
461
- userHasCompletedTenantTwoFactorRequirements,
462
- userStillNeedsTwoFactor,
463
- tenantAllowed2FAMethods,
464
- nextStep,
465
- // Legacy names for backwards compatibility
466
- requires2FA: tenantRequiresTwoFactor || oauthSessionNeedsTwoFactor,
467
- twoFactorComplete: userHasCompletedTenantTwoFactorRequirements,
468
- accessTokenExpired: true,
469
- reason: 'Token expired',
470
- // Session revocation (PAY-1882 step 4) - same value on every return.
471
- revocationStatus,
472
- ...revocationFields(revocationStatus), // PAY-2339: forceInvalid only for 'revoked'; unavailable:true for 'unknown'
473
- // RBAC fields
474
- roles: session?.roles || [],
475
- clientId: resolveClientId(session?.idpClientId),
476
- });
477
- }
478
-
479
- return NextResponse.json({
480
- viable: true,
481
- needsRefresh,
482
- expiresIn,
483
- hasRefreshToken,
484
- authenticated: true,
485
- sessionTokenId: sessionToken ? tokenLogId(sessionToken) : null,
486
- // Session revocation (PAY-1882 step 4) - same value on every return.
487
- revocationStatus,
488
- ...revocationFields(revocationStatus), // PAY-2339: forceInvalid only for 'revoked'; unavailable:true for 'unknown'
489
- // Clear names
490
- tenantRequiresTwoFactor,
491
- userHasCompletedTenantTwoFactorRequirements,
492
- userStillNeedsTwoFactor,
493
- tenantAllowed2FAMethods,
494
- nextStep,
495
- // Legacy names for backwards compatibility
496
- requires2FA: tenantRequiresTwoFactor || oauthSessionNeedsTwoFactor,
497
- twoFactorComplete: userHasCompletedTenantTwoFactorRequirements,
498
- accessTokenExpired: false,
499
- expiresAt: new Date(expiryTime * 1000).toISOString(),
500
- // RBAC fields
501
- roles: session?.roles || [],
502
- clientId: resolveClientId(session?.idpClientId),
503
- });
504
- } catch (error) {
505
- // PAY-1438: pre-auth probe - the exception text stays in the log, keyed by correlationId.
506
- const correlationId = logExceptionWithCorrelation('[VIABILITY_ROUTE] Error checking session viability:', error);
507
- return NextResponse.json({
508
- viable: false,
509
- needsRefresh: false,
510
- authenticated: false,
511
- // PAY-1882 step 4: an exception here means the status could not be read.
512
- // Report 'unknown' (not a bare 500 with no revocation signal) so the
513
- // middleware's fail-closed default applies rather than reading it as viable.
514
- revocationStatus: 'unknown' as RevocationStatus,
515
- error: 'Failed to check session',
516
- code: 'INTERNAL_ERROR',
517
- correlationId
518
- }, { status: 500 });
519
- }
1
+ /**
2
+ * Ready-to-Use Session Viability Route
3
+ *
4
+ * Checks if the current session is viable (valid and not expired).
5
+ * Used by client-side code to determine if a refresh is needed.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * // app/api/session/viability/route.ts
10
+ * export { GET } from '@payez/next-mvp/routes/auth/viability';
11
+ * ```
12
+ *
13
+ * @version 2.0.0
14
+ * @since auth-ready-v2
15
+ */
16
+
17
+ import { revocationFields } from '../../auth/revocation-fields';
18
+ import { NextRequest, NextResponse } from 'next/server';
19
+ import { resolveSessionState } from '../../server/auth';
20
+ import {
21
+ getSession as getRedisSession,
22
+ getBetterAuthSession as getBetterAuthRedisSession,
23
+ } from '../../lib/session-store';
24
+ import { getIDPClientConfig } from '../../lib/idp-client-config';
25
+ import { logExceptionWithCorrelation } from '../../lib/safe-error-response';
26
+ import { tokenLogId } from '../../lib/token-log-id';
27
+ import { getRevocationStatus, warnNonNumericUserId, resolveNumericIdpUserId, type RevocationStatus } from '../../vibe/sessions';
28
+
29
+ /**
30
+ * Get tenant-wide 2FA requirement from cached client config (from broker handshake)
31
+ */
32
+ async function getTenantRequiresTwoFactor(): Promise<boolean> {
33
+ try {
34
+ const config = await getIDPClientConfig();
35
+ return config.authSettings?.require2FA ?? true; // Default to true for security
36
+ } catch {
37
+ console.warn('[VIABILITY] Could not get client config, defaulting tenantRequiresTwoFactor to true');
38
+ return true;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Which 2FA methods the TENANT permits — cards 141688 + 141741.
44
+ *
45
+ * WHY THIS IS HERE: this endpoint answered "is 2FA required, and is it done?"
46
+ * and said nothing about WITH WHAT. Both the middleware gate and the
47
+ * verify-code page call this route, and NEITHER had any method data — so
48
+ * nothing in the system asked whether the user CAN complete the challenge it
49
+ * is about to demand. A user whose tenant allows only `totp`, with no
50
+ * authenticator enrolled, is sent to a page that offers email and sms.
51
+ *
52
+ * ⚠ THIS IS NOT A COMPLETION SIGNAL AND MUST NEVER BE READ AS ONE.
53
+ * It says what the tenant PERMITS. It says nothing about what the user has
54
+ * enrolled, and nothing about whether they have verified. The invariant
55
+ * (QAPert, acceptance J2/J5) is: METHOD-PRESENCE IS NOT VERIFIED. A user with
56
+ * one method is still challenged and still gated until they actually verify.
57
+ * `userHasCompletedTenantTwoFactorRequirements` remains the only field that
58
+ * answers "are they through".
59
+ *
60
+ * STILL MISSING, deliberately not guessed here: the ENROLLED set. That lives
61
+ * behind /api/mfa/methods and needs a call this route does not currently make.
62
+ * Allowed-only closes the "offered a method the tenant forbids" half; it does
63
+ * NOT close "has nothing enrolled and no way to enrol". Both halves are named
64
+ * in the cards so neither gets lost.
65
+ *
66
+ * NULL MEANS UNKNOWN — IT DOES NOT MEAN "NONE", AND IT DOES NOT MEAN
67
+ * "email and sms". If the client config cannot be read we do not know what the
68
+ * tenant permits, and every available guess is wrong in a way that matters:
69
+ *
70
+ * - guessing ['email','sms'] fails OPEN. On a totp-only tenant that offers
71
+ * the user two methods the tenant forbids — which is defect 141688 itself,
72
+ * re-created by its own fix during a config outage.
73
+ * - guessing [] fails CLOSED but locks the user out of a challenge they may
74
+ * be perfectly able to complete.
75
+ *
76
+ * So we report the truth (we don't know) and let the consumer decide, rather
77
+ * than encoding a guess that reads like a measurement. Same reasoning as the
78
+ * nullable require_2fa fix: "absent" has to be expressible, or callers cannot
79
+ * tell a real value from a default.
80
+ *
81
+ * No local default. 257255 UPDATE: allowed2FAMethods is now OPTIONAL on
82
+ * AuthSettings - the config mapper no longer substitutes ['email','sms'] when
83
+ * the document carries no policy, because that made "no policy" and "exactly
84
+ * these methods" indistinguishable. So absent now correctly reaches this
85
+ * function as undefined and is reported as UNKNOWN (null), which is what this
86
+ * function's own contract already said it wanted. Before the change it would
87
+ * have returned the mapper's fabrication AS IF IT WERE THE TENANT'S VALUE.
88
+ */
89
+ async function getTenantAllowed2FAMethods(): Promise<string[] | null> {
90
+ try {
91
+ const config = await getIDPClientConfig();
92
+ // `?? null` is the whole point: undefined means the document carried no
93
+ // policy, and this function reports UNKNOWN rather than guessing.
94
+ return config.authSettings.allowed2FAMethods ?? null;
95
+ } catch {
96
+ console.warn('[VIABILITY] Could not get client config — reporting allowed 2FA methods as UNKNOWN (null), not guessing');
97
+ return null;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Resolve the client id reported to the caller.
103
+ *
104
+ * Was: `session?.idpClientId || process.env.IDP_CLIENT_ID || ''`. Two defects,
105
+ * both of the "report a confident value where the truth is unknown" family that
106
+ * card 221076 was about:
107
+ *
108
+ * 1. `|| ''` made "we do not know this session's client" indistinguishable from
109
+ * "the client id is blank". Callers cannot tell absence from a known-empty
110
+ * value, so nothing can react to the former. Absence is now `null`, which is
111
+ * representable — and still falsy, so existing `if (!clientId)` guards behave
112
+ * exactly as before.
113
+ *
114
+ * 2. The env fallback SILENTLY substitutes the deployment's default client for a
115
+ * session whose own client is unknown. On a multi-tenant IDP that is a
116
+ * cross-tenant wrong answer, not a default. It is kept for single-tenant
117
+ * deployments that rely on it, but it now says so in the log rather than
118
+ * passing the substitution off as the session's own identity.
119
+ */
120
+ function resolveClientId(sessionClientId: string | undefined): string | null {
121
+ if (sessionClientId) return sessionClientId;
122
+
123
+ const envClientId = process.env.IDP_CLIENT_ID;
124
+ if (envClientId) {
125
+ console.warn(
126
+ '[VIABILITY_ROUTE] session carries no idpClientId - substituting IDP_CLIENT_ID ' +
127
+ 'from the environment. On a multi-tenant deployment this is a DIFFERENT client ' +
128
+ 'than the session belongs to.'
129
+ );
130
+ return envClientId;
131
+ }
132
+
133
+ return null;
134
+ }
135
+
136
+ /**
137
+ * GET /api/session/viability - Check if session is viable
138
+ *
139
+ * Returns:
140
+ * - viable: boolean - Whether the session can be used
141
+ * - needsRefresh: boolean - Whether a refresh is recommended
142
+ * - expiresIn: number - Seconds until token expires
143
+ */
144
+ export async function GET(req: NextRequest) {
145
+ try {
146
+ // PAY-2216: ask for the session's STATE, not a session-or-null. A null used to cover a store outage, a deleted
147
+ // session and no cookie alike, so this route answered 'No session found' to all three and the middleware could
148
+ // never clear a dead cookie (it must not clear on an outage). See SessionState in server/auth.
149
+ const sessionState = await resolveSessionState(req);
150
+
151
+ if (sessionState.state === 'unknown') {
152
+ // The store did not answer. NOT a statement about the session: never stale, never 'No session found'.
153
+ // Same shape as this route's catch (PAY-1882 step 4): 'unknown' revocation -> the middleware's fail-closed
154
+ // path, a non-OK answer -> service_error, which never clears cookies.
155
+ console.warn('[VIABILITY] Session store unavailable - session state unknown');
156
+ return NextResponse.json({
157
+ viable: false,
158
+ needsRefresh: false,
159
+ authenticated: false,
160
+ revocationStatus: 'unknown' as RevocationStatus,
161
+ unavailable: true, // PAY-2339: a reader of this JSON gets the hold signal (the response is non-OK, so the middleware holds as service_error)
162
+ error: 'Session store unavailable',
163
+ code: 'STORE_UNAVAILABLE'
164
+ }, { status: 500 });
165
+ }
166
+
167
+ if (sessionState.state === 'missing') {
168
+ // A VERIFIED cookie and a store that answered "not here". The existing stale shape, so the middleware's
169
+ // stale_session arm (PAY-2216 / 4.5.4) clears it.
170
+ console.warn('[VIABILITY] Stale cookie detected - verified cookie, session not in Redis');
171
+ return NextResponse.json({
172
+ viable: false,
173
+ needsRefresh: false,
174
+ authenticated: false,
175
+ sessionTokenId: tokenLogId(sessionState.sessionToken), // PAY-1481: a correlation id, never the token
176
+ reason: 'Stale session - cookie exists but session not found in Redis'
177
+ });
178
+ }
179
+
180
+ if (sessionState.state === 'none') {
181
+ return NextResponse.json({
182
+ viable: false,
183
+ needsRefresh: false,
184
+ authenticated: false,
185
+ reason: 'No session found'
186
+ });
187
+ }
188
+
189
+ const baSession = sessionState.session;
190
+
191
+ const token = baSession as any;
192
+ const sessionToken = baSession.session?.token;
193
+
194
+ // Try the canonical session-store first; fall back to the Better-Auth-keyed
195
+ // Redis record (`ba:{appSlug}:{token}`) when the canonical lookup misses.
196
+ // Without the fallback, any consumer that wrote a BA session via the OAuth
197
+ // callback path or dev-login and didn't separately populate the canonical
198
+ // store would land here with a "Stale session" verdict, even though the BA
199
+ // stack just resolved the cookie cleanly. Keeps the two viability impls
200
+ // (this route + `api-handlers/session/viability.ts`) in lockstep.
201
+ let session = sessionToken ? await getRedisSession(sessionToken) : null;
202
+ if (sessionToken && !session) {
203
+ session = await getBetterAuthRedisSession(sessionToken);
204
+ if (session) {
205
+ console.log('[VIABILITY] Found session in Better Auth store (fallback)');
206
+ }
207
+ }
208
+
209
+ // CRITICAL: Detect stale cookie state (cookie exists but neither store has the session)
210
+ if (sessionToken && !session) {
211
+ console.warn('[VIABILITY] Stale cookie detected - session not in Redis');
212
+ return NextResponse.json({
213
+ viable: false,
214
+ needsRefresh: false,
215
+ authenticated: false,
216
+ sessionTokenId: tokenLogId(sessionToken), // PAY-1481: a correlation id, never the token; middleware only needs presence to clear the stale cookie
217
+ reason: 'Stale session - cookie exists but session not found in Redis'
218
+ });
219
+ }
220
+
221
+ // Cards 141688/141741 — the IDP's routing decision, relayed verbatim.
222
+ // See the SessionData.nextStep doc. This is the field that tells the gate
223
+ // whether the user CAN complete a challenge; without it both consumers were
224
+ // sending zero-method users to a page they had no way to act on.
225
+ //
226
+ // Declared once, here, above every return: `session` is settled by this
227
+ // point, and all three authenticated responses must carry the SAME value.
228
+ // Deriving it per-branch would let the paths drift apart silently.
229
+ //
230
+ // NO `?? null` HERE, DELIBERATELY — and this is the whole safety argument:
231
+ //
232
+ // undefined = we never captured it. Every session minted BEFORE this
233
+ // change is in that state, and there are live ones in Redis
234
+ // right now. JSON.stringify omits the key, so the consumer
235
+ // sees no field and knows it is uninformed.
236
+ // null = the IDP affirmatively said "nothing outstanding".
237
+ //
238
+ // Collapsing the first into the second with `?? null` would tell the gate
239
+ // "fully authenticated, nothing to do" about every pre-existing session —
240
+ // including a zero-method user who must be sent to enrol. That is a
241
+ // fail-open on the entire existing session population, produced by a
242
+ // default that reads like a measurement. Same mistake as the nullable
243
+ // require_2fa case: absent has to stay expressible.
244
+ const nextStep = session?.nextStep;
245
+
246
+ /**
247
+ * 266007 — port the sibling's CRITICAL override (api-handlers/session/
248
+ * viability.ts:82-86) into this, the impl consumers actually MOUNT.
249
+ * An OAuth session whose callback set mfaVerified === false because no
250
+ * refresh_token was issued ALWAYS needs 2FA completion, whatever the
251
+ * tenant config says — completing 2FA is how it obtains one.
252
+ *
253
+ * STRICT === false, deliberately: an ABSENT mfaVerified (every session
254
+ * minted before that field existed) is not a signal, and treating it as
255
+ * one would force-rechallenge the entire existing session population.
256
+ *
257
+ * *** R807 (BAPert, card 362550) - WHY THE `=== false` TEST ALONE NO LONGER
258
+ * CARRIES THAT INTENT, AND WHAT RESTORES IT. ***
259
+ *
260
+ * The intent above is right, but by the time a session reaches here the
261
+ * distinction it depends on IS ALREADY GONE: every construction site
262
+ * (session-store, decode-session, token-lifecycle x2, SessionModel) collapses
263
+ * an absent mfaVerified to `false` via `?? false`. So `=== false` had stopped
264
+ * meaning "the callback affirmatively said false" and had come to mean
265
+ * "affirmatively false OR never knew" - i.e. the exact mass re-challenge this
266
+ * comment says it is preventing. The guard read as strict while behaving as
267
+ * permissive, because the collapse happens upstream and out of sight.
268
+ *
269
+ * R807 keeps `mfaVerified` fail-closed at all five sites (removing `?? false`
270
+ * would be an AUTH BYPASS - a session that never proved MFA read as having
271
+ * proved it) and gives THIS question its own signal instead. So the test is
272
+ * now two parts:
273
+ *
274
+ * mfaSignalPresent === true -> the producer really did emit a value, so the
275
+ * `false` below is a MEASUREMENT. Fire.
276
+ * absent / false -> LEGACY session (minted before the signal
277
+ * existed) or a path with no IDP tokens at all.
278
+ * NOT a signal. Do NOT force a re-challenge.
279
+ *
280
+ * That absent-means-do-not-fire read is the migration behaviour, and it is
281
+ * what preserves this comment's original guarantee verbatim through rollout:
282
+ * on the day this ships, EVERY existing session has no signal and none of them
283
+ * is re-challenged.
284
+ *
285
+ * *** AND IT IS SAFE ONLY BECAUSE IT IS CONFINED HERE. *** mfaSignalPresent is
286
+ * permissive by construction - it can only ever SUPPRESS this override. It must
287
+ * never reach a path that grants access; gate those on `mfaVerified`, which is
288
+ * still fail-closed everywhere. R807 is void if this signal escapes into an
289
+ * auth decision.
290
+ *
291
+ * Declared once here, like nextStep above, so the three authenticated
292
+ * responses cannot drift apart. Feeds the legacy `requires2FA` field the
293
+ * middleware actually gates on; `tenantRequiresTwoFactor` stays pure —
294
+ * it names TENANT policy, and this override is not tenant state.
295
+ */
296
+ const oauthSessionNeedsTwoFactor =
297
+ session?.mfaSignalPresent === true &&
298
+ session?.mfaVerified === false &&
299
+ !(session.idpRefreshToken || (session as any)?.refreshToken);
300
+
301
+ // --- Session revocation status (PAY-1882 step 4 producer half) ---
302
+ /*
303
+ * Resolve the four-state revocation status for this user (BAPert 66320).
304
+ * Read it HERE, above every authenticated return, so all three responses
305
+ * carry the SAME value - the same discipline as nextStep above.
306
+ *
307
+ * The middleware fails CLOSED by default on 'unknown' (a failed Vibe read)
308
+ * and always on 'revoked'; 'active' and 'not_configured' stay viable. A
309
+ * consumer that does NOT read revocationStatus is unaffected - absent is not
310
+ * revoked - so shipping this changes nothing for them until they opt in.
311
+ */
312
+ let revocationStatus: RevocationStatus = 'not_configured';
313
+ if (session) {
314
+ // BAPert 66480 condition 2 / ruling (ii) 66700: the numeric IdP id comes from
315
+ // the token's own `sub` claim FIRST (session.userId can be an email).
316
+ const revocationUserId = resolveNumericIdpUserId(session, (session as any).idpAccessToken);
317
+ if (revocationUserId > 0) {
318
+ try {
319
+ revocationStatus = await getRevocationStatus(revocationUserId, sessionToken);
320
+ } catch {
321
+ // getRevocationStatus does not throw by contract, but never let a
322
+ // revocation probe take down the viability answer: fail closed.
323
+ revocationStatus = 'unknown';
324
+ }
325
+ } else {
326
+ // N1 (NightHawk 66392): a session exists but carries no usable numeric
327
+ // IdP id, so the check could not run -> 'unknown' (could not determine),
328
+ // NOT 'not_configured' (no revocation source).
329
+ // BAPert 66480 condition 1: emit ONE WARN (shape only) so a non-numeric
330
+ // id surfaces as a log line, not an unexplained sign-out.
331
+ revocationStatus = warnNonNumericUserId(sessionToken, session.userId);
332
+ }
333
+ }
334
+
335
+ // Check access token expiry
336
+ const now = Math.floor(Date.now() / 1000);
337
+ // 240654 addendum 2: fall back to the Redis session's own expiry.
338
+ //
339
+ // The first two reads come off the Better Auth session OBJECT. A Path-B
340
+ // synthetic session — the shape server/auth.ts builds for a legacy JWS
341
+ // session — carries NEITHER: it is `{session:{token}, user:{...}}` with no
342
+ // accessTokenExpires and no exp. So every legacy session fell into the
343
+ // "no expiry info" branch below and answered needsRefresh:true on EVERY
344
+ // viability read from the moment of login, while the token was in fact
345
+ // fresh (15-minute TTL measured). Benign for the viable/authenticated
346
+ // verdict, but it recommends a refresh constantly, which is pure perf
347
+ // noise and multiplies exposure to the empty-clientId refresh path (223542).
348
+ //
349
+ // lib/session-store puts the real value on the session as
350
+ // idpAccessTokenExpires, in MILLISECONDS (better-auth.ts:784 sets
351
+ // `Date.now() + expires_in * 1000`). The other two sources are in SECONDS.
352
+ // Mixing units here would be a silent disaster, so note that the
353
+ // normalisation below (`> 1e12 ? /1000 : as-is`) already discriminates
354
+ // them by magnitude: a seconds timestamp is ~1.7e9, a milliseconds one
355
+ // ~1.7e12. This fallback is therefore safe in either unit, and that
356
+ // existing normaliser is load-bearing for it -- do not remove it.
357
+ const accessTokenExpires =
358
+ (token as any).accessTokenExpires ||
359
+ token.exp ||
360
+ (session as any)?.idpAccessTokenExpires;
361
+
362
+ if (!accessTokenExpires) {
363
+ // No expiry info, assume viable but recommend refresh
364
+ const tenantRequiresTwoFactor = await getTenantRequiresTwoFactor();
365
+ // Cards 141688/141741 — see getTenantAllowed2FAMethods. NOT a completion signal.
366
+ const tenantAllowed2FAMethods = await getTenantAllowed2FAMethods();
367
+
368
+ // CRITICAL: Check if MFA has expired (2FA TTL enforcement)
369
+ const mfaExpiresAt = session?.mfaExpiresAt || 0;
370
+ const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
371
+ // Check mfaVerified (normalized name) with fallback to twoFactorComplete for compatibility
372
+ const mfaVerifiedInSession = session?.mfaVerified ?? false;
373
+
374
+ // User has completed 2FA requirements if: they verified AND it hasn't expired
375
+ const userHasCompletedTenantTwoFactorRequirements = mfaVerifiedInSession && !mfaExpired;
376
+
377
+ // userStillNeedsTwoFactor = inverse of completed (matches session callback logic)
378
+ const userStillNeedsTwoFactor = !userHasCompletedTenantTwoFactorRequirements;
379
+
380
+ return NextResponse.json({
381
+ viable: true,
382
+ needsRefresh: true,
383
+ authenticated: true,
384
+ sessionTokenId: sessionToken ? tokenLogId(sessionToken) : null,
385
+ // Session revocation (PAY-1882 step 4): the four-state status, plus the
386
+ // boolean the middleware's existing force-invalid branch already reads.
387
+ revocationStatus,
388
+ ...revocationFields(revocationStatus), // PAY-2339: forceInvalid only for 'revoked'; unavailable:true for 'unknown'
389
+ // Clear names for middleware decision-making
390
+ tenantRequiresTwoFactor,
391
+ userHasCompletedTenantTwoFactorRequirements,
392
+ userStillNeedsTwoFactor,
393
+ tenantAllowed2FAMethods,
394
+ nextStep,
395
+ // Legacy field names for backwards compatibility
396
+ requires2FA: tenantRequiresTwoFactor || oauthSessionNeedsTwoFactor,
397
+ twoFactorComplete: userHasCompletedTenantTwoFactorRequirements,
398
+ accessTokenExpired: false,
399
+ reason: 'No expiry information'
400
+ });
401
+ }
402
+
403
+ // Convert to seconds if needed
404
+ const expiryTime = accessTokenExpires > 1000000000000
405
+ ? Math.floor(accessTokenExpires / 1000)
406
+ : accessTokenExpires;
407
+
408
+ const expiresIn = expiryTime - now;
409
+ const isExpired = expiresIn <= 0;
410
+ const needsRefresh = expiresIn <= 300; // 5 minutes buffer
411
+
412
+ // Check if we have refresh capability (check normalized field name first)
413
+ const hasRefreshToken = !!(session?.idpRefreshToken || (session as any)?.refreshToken || (token as any).refreshToken);
414
+
415
+ // CLEAR NAMING: Tenant-wide 2FA requirement from client config
416
+ const tenantRequiresTwoFactor = await getTenantRequiresTwoFactor();
417
+ // Cards 141688/141741 — see getTenantAllowed2FAMethods. NOT a completion signal.
418
+ const tenantAllowed2FAMethods = await getTenantAllowed2FAMethods();
419
+
420
+ // CRITICAL: Check if MFA has expired (2FA TTL enforcement)
421
+ // The session may have mfaVerified=true from days ago, but if mfaExpiresAt
422
+ // has passed, we must treat 2FA as incomplete to force re-verification.
423
+ const mfaExpiresAt = session?.mfaExpiresAt || 0;
424
+ const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
425
+ // Check mfaVerified (normalized name) with fallback to twoFactorComplete for compatibility
426
+ const mfaVerifiedInSession = session?.mfaVerified ?? false;
427
+
428
+ // DEBUG: Log what we're reading from the session
429
+ console.log('[VIABILITY] Session 2FA state:', {
430
+ sessionToken: tokenLogId(sessionToken),
431
+ 'session.mfaVerified': session?.mfaVerified,
432
+ 'session.twoFactorComplete': (session as any)?.twoFactorComplete,
433
+ mfaVerifiedInSession,
434
+ mfaExpiresAt,
435
+ mfaExpired,
436
+ hasRefreshToken,
437
+ 'session.idpRefreshToken': !!session?.idpRefreshToken,
438
+ 'session.refreshToken': !!(session as any)?.refreshToken,
439
+ });
440
+
441
+ // CLEAR NAMING: User has completed 2FA requirements if: they verified AND it hasn't expired
442
+ const userHasCompletedTenantTwoFactorRequirements = mfaVerifiedInSession && !mfaExpired;
443
+
444
+ // userStillNeedsTwoFactor = inverse of completed (matches session callback logic)
445
+ const userStillNeedsTwoFactor = !userHasCompletedTenantTwoFactorRequirements;
446
+
447
+ if (mfaExpired && mfaVerifiedInSession) {
448
+ console.warn('[VIABILITY] MFA expired - forcing 2FA re-verification');
449
+ }
450
+
451
+ if (isExpired) {
452
+ return NextResponse.json({
453
+ viable: false,
454
+ needsRefresh: hasRefreshToken,
455
+ expiresIn: 0,
456
+ hasRefreshToken,
457
+ authenticated: true,
458
+ sessionTokenId: sessionToken ? tokenLogId(sessionToken) : null,
459
+ // Clear names
460
+ tenantRequiresTwoFactor,
461
+ userHasCompletedTenantTwoFactorRequirements,
462
+ userStillNeedsTwoFactor,
463
+ tenantAllowed2FAMethods,
464
+ nextStep,
465
+ // Legacy names for backwards compatibility
466
+ requires2FA: tenantRequiresTwoFactor || oauthSessionNeedsTwoFactor,
467
+ twoFactorComplete: userHasCompletedTenantTwoFactorRequirements,
468
+ accessTokenExpired: true,
469
+ reason: 'Token expired',
470
+ // Session revocation (PAY-1882 step 4) - same value on every return.
471
+ revocationStatus,
472
+ ...revocationFields(revocationStatus), // PAY-2339: forceInvalid only for 'revoked'; unavailable:true for 'unknown'
473
+ // RBAC fields
474
+ roles: session?.roles || [],
475
+ clientId: resolveClientId(session?.idpClientId),
476
+ });
477
+ }
478
+
479
+ return NextResponse.json({
480
+ viable: true,
481
+ needsRefresh,
482
+ expiresIn,
483
+ hasRefreshToken,
484
+ authenticated: true,
485
+ sessionTokenId: sessionToken ? tokenLogId(sessionToken) : null,
486
+ // Session revocation (PAY-1882 step 4) - same value on every return.
487
+ revocationStatus,
488
+ ...revocationFields(revocationStatus), // PAY-2339: forceInvalid only for 'revoked'; unavailable:true for 'unknown'
489
+ // Clear names
490
+ tenantRequiresTwoFactor,
491
+ userHasCompletedTenantTwoFactorRequirements,
492
+ userStillNeedsTwoFactor,
493
+ tenantAllowed2FAMethods,
494
+ nextStep,
495
+ // Legacy names for backwards compatibility
496
+ requires2FA: tenantRequiresTwoFactor || oauthSessionNeedsTwoFactor,
497
+ twoFactorComplete: userHasCompletedTenantTwoFactorRequirements,
498
+ accessTokenExpired: false,
499
+ expiresAt: new Date(expiryTime * 1000).toISOString(),
500
+ // RBAC fields
501
+ roles: session?.roles || [],
502
+ clientId: resolveClientId(session?.idpClientId),
503
+ });
504
+ } catch (error) {
505
+ // PAY-1438: pre-auth probe - the exception text stays in the log, keyed by correlationId.
506
+ const correlationId = logExceptionWithCorrelation('[VIABILITY_ROUTE] Error checking session viability:', error);
507
+ return NextResponse.json({
508
+ viable: false,
509
+ needsRefresh: false,
510
+ authenticated: false,
511
+ // PAY-1882 step 4: an exception here means the status could not be read.
512
+ // Report 'unknown' (not a bare 500 with no revocation signal) so the
513
+ // middleware's fail-closed default applies rather than reading it as viable.
514
+ revocationStatus: 'unknown' as RevocationStatus,
515
+ error: 'Failed to check session',
516
+ code: 'INTERNAL_ERROR',
517
+ correlationId
518
+ }, { status: 500 });
519
+ }
520
520
  }