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