@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,1141 +1,1141 @@
1
- /**
2
- * MVP Middleware - Authentication & Route Protection
3
- *
4
- * Creates a Next.js middleware handler that protects routes based on:
5
- * - Authentication status (session cookie → Redis viability check)
6
- * - 2FA completion status
7
- * - RBAC permissions (if enabled)
8
- *
9
- * Usage: Export the result from your app's middleware.ts:
10
- * export default createMvpMiddleware();
11
- *
12
- * With custom options:
13
- * export default createMvpMiddleware({
14
- * circuitBreaker: myCircuitBreaker,
15
- * logger: myLogger,
16
- * viabilityEndpoint: '/api/session/refresh-viability',
17
- * });
18
- */
19
-
20
- import { NextRequest, NextResponse } from 'next/server';
21
- import { isUnauthenticatedRoute, should2FABypass } from '../auth/route-config';
22
- import { resolveCallbackDestination, safeCallbackPath } from '../auth/safe-callback';
23
- import { getInternalApiUrl } from '../edge/internal-api-url';
24
- import { getSessionCookieName, getSecureSessionCookieName } from '../lib/app-slug';
25
- import { checkPagePermission, isRBACEnabled } from './rbac-check';
26
- import { tokenLogId } from '../lib/token-log-id';
27
- import { expireSessionCookies } from '../auth/session-cookie-clear';
28
- import {
29
- DEFAULT_SESSION_RESET_ROUTE,
30
- boundSessionCookie,
31
- normaliseResetParams,
32
- resetSecret,
33
- signedResetHref,
34
- } from '../auth/session-reset-link';
35
-
36
- // =============================================================================
37
- // TYPES
38
- // =============================================================================
39
-
40
- export interface AuthContext {
41
- pathname: string;
42
- isPublicRoute: boolean;
43
- isLoginPage: boolean;
44
- searchParams: URLSearchParams;
45
- sessionPointer: SessionPointer;
46
- sessionStatus: SessionStatus;
47
- circuitBreakerOpen: boolean;
48
- }
49
-
50
- export interface SessionPointer {
51
- exists: boolean;
52
- /** PAY-1481: tokenLogId of the session token (presence + log correlation), never the token itself. */
53
- sessionTokenId?: string | null;
54
- expired?: boolean;
55
- hasRefreshToken?: boolean;
56
- roles: string[];
57
- clientId: string;
58
- }
59
-
60
- /** PAY-2339: which check could not answer. A fixed, machine-readable value: consumers never parse message text. */
61
- export type UnavailableCheck = 'revocation' | 'viability';
62
-
63
- export interface SessionStatus {
64
- exists: boolean | null; // null = check failed/timeout
65
- forceInvalid: boolean | null; // null = check failed/timeout
66
- /** PAY-2339: the revocation backend could not answer and this app HOLDS (revocationFailMode 'hold'): no sign-out. */
67
- unavailable?: boolean;
68
- requires2FA: boolean;
69
- twoFactorComplete: boolean;
70
- }
71
-
72
- export type AuthAction =
73
- | { type: 'allow' }
74
- | { type: 'redirect'; location: string; reason: string; clearCookies?: boolean }
75
- | { type: 'service_error'; reason: string; check?: UnavailableCheck }
76
- | { type: 'refresh_needed' };
77
-
78
- // =============================================================================
79
- // CIRCUIT BREAKER INTERFACE
80
- // =============================================================================
81
-
82
- /**
83
- * Circuit breaker interface for middleware integration.
84
- * Implement this to provide custom circuit breaker behavior.
85
- */
86
- export interface CircuitBreakerProvider {
87
- /** Check if circuit breaker is currently open */
88
- isOpen(): boolean;
89
- /** Check if a refresh attempt is allowed (for HALF_OPEN state) */
90
- canAttemptRefresh(): boolean;
91
- /** Record a successful operation (closes the breaker) */
92
- recordSuccess(): void;
93
- /** Record a failed operation (may open the breaker) */
94
- recordFailure(error?: Error): void;
95
- /** Check if an error is a network error (vs HTTP error) */
96
- isNetworkError(error: any): boolean;
97
- }
98
-
99
- /**
100
- * Logger interface for middleware.
101
- * Implement this to provide custom logging.
102
- */
103
- export interface MiddlewareLogger {
104
- info(message: string, data?: Record<string, any>): void;
105
- warn(message: string, data?: Record<string, any>): void;
106
- error(message: string, data?: Record<string, any>): void;
107
- }
108
-
109
- /**
110
- * Configuration options for createMvpMiddleware
111
- */
112
- export interface MvpMiddlewareOptions {
113
- /** Custom circuit breaker implementation */
114
- circuitBreaker?: CircuitBreakerProvider;
115
- /** Custom logger implementation */
116
- logger?: MiddlewareLogger;
117
- /** Custom viability check endpoint (default: /api/session/viability) */
118
- viabilityEndpoint?: string;
119
- /** Custom refresh endpoint (default: /api/auth/refresh) */
120
- refreshEndpoint?: string;
121
- /** Hook called on successful refresh */
122
- onRefreshSuccess?: () => void;
123
- /** Hook called on refresh failure */
124
- onRefreshFailure?: (status: number, isNetworkError: boolean) => void;
125
- /** Additional paths to bypass middleware (beyond /api/auth/ and /api/session/) */
126
- bypassPaths?: string[];
127
- /** Paths exempt from RBAC checks (auth still enforced, just no page-permission check) */
128
- rbacExemptPaths?: string[];
129
- /**
130
- * PAY-2218 - send a page's session-ending redirect through the session-reset ROUTE instead of clearing the
131
- * cookies in the middleware response. Next strips the router's prefetch headers before middleware runs
132
- * (15.5.x and 16.x; 16.2.x restores them only with `skipProxyUrlNormalize: true`), so a middleware clear also
133
- * fires on a router PREFETCH and signs the user out of a page they never opened. The route sees those headers.
134
- *
135
- * Requires the app to mount the route: app/api/auth/session-reset/route.ts
136
- * export { GET } from '@payez/next-mvp/routes/auth/session-reset';
137
- * `true` uses that default path; a string names another mount point under /api/auth/ (middleware bypasses it).
138
- * Needs BETTER_AUTH_SECRET (or NEXTAUTH_SECRET) to sign the hand-off; without one the direct clear is kept and
139
- * an error is logged once. Default: off (the direct clear), because an unmounted route would 404 every sign-out.
140
- */
141
- sessionResetRoute?: boolean | string;
142
- /**
143
- * PAY-1882 step 4 / PAY-2339 - how this app treats a producer that reports the session's
144
- * revocation status as `'unknown'` (a Vibe read that FAILED: non-OK, timeout,
145
- * throw - the status could not be determined).
146
- *
147
- * 'hold' (DEFAULT since PAY-2339, Jon 2026-10-13, option C) - the member is NOT signed out. The request is
148
- * answered "temporarily unavailable": a page goes to /service-unavailable?callbackUrl=
149
- * (the session cookie is NOT touched, so "Try again" returns the member still signed in);
150
- * an /api request gets a 503 carrying `unavailable: true` and `check: 'revocation'`, a
151
- * fixed machine-readable value. Nothing protected is served while the answer is unknown.
152
- * Each held request logs one countable `revocation_unavailable` line.
153
- * THIS REPLACES THE OLD DEFAULT: the PAY-1880 ruling (Jon, 2026-09-27, option A) made
154
- * 'closed' the default and accepted that a Vibe outage signs an app's users out. That
155
- * sentence no longer applies to 'unknown' by default: a Vibe outage no longer signs members
156
- * out unless the app asks for 'closed'. 'revoked' is unchanged.
157
- * 'closed' - an unknown revocation status is NOT viable; the session is force-invalidated and the
158
- * user re-auths (TODAY'S sign-out, now opt-in by name). Consequence, accepted explicitly:
159
- * a Vibe outage signs this app's users out.
160
- * 'open' - an unknown status stays viable (availability over fail-closed). Per-app opt-out for a
161
- * deployment with no revocation source.
162
- *
163
- * `'not_configured'` is UNCHANGED under the default: it is a configuration fault, not an outage, so it still
164
- * force-invalidates exactly as before (a "try again" page would be false); `noRevocationSource` is the only way
165
- * to make it viable. Neither mode changes `'revoked'` (always force-invalid, unchanged) or an ABSENT
166
- * `revocationStatus` (an older producer - unchanged behaviour, so no consumer is signed out for not
167
- * implementing the check). A consumer that wrote 'closed' or 'open' by name keeps exactly what it asked for.
168
- */
169
- revocationFailMode?: 'hold' | 'closed' | 'open';
170
-
171
- /**
172
- * PAY-2339 (BAPert 74640): where a page request goes while a revocation answer is held. DEFAULT (absent): the
173
- * middleware answers inline with a minimal 503 HTML page ("Temporarily unavailable", a "Try again" link to the safe
174
- * callback, no Set-Cookie), so a consumer that never added a page cannot get a 404. Name a same-site path (e.g.
175
- * '/service-unavailable') to redirect there instead with `?callbackUrl=`; the consumer then mounts
176
- * `export { default } from '@payez/next-mvp/pages/service-unavailable'` (or its own page) at that route.
177
- */
178
- unavailablePage?: string;
179
-
180
- /**
181
- * PAY-2302 cut 1c (BAPert 73585): this app has NO revocation source - for example a development environment
182
- * provisioned without a vibe client id / HMAC key - and says so EXPLICITLY. Under it a viability answer of
183
- * `revocationStatus: 'not_configured'` is viable, and a loud warning is logged once at startup.
184
- *
185
- * It relaxes ONLY 'not_configured'. 'unknown' (a revocation read that FAILED) and 'revoked' stay closed whatever
186
- * `revocationFailMode` says, and the default (option absent) is unchanged: 'not_configured' fails closed, because
187
- * for an app that HAS a vibe client it means a misconfiguration (BAPert 66356). Do not use this to paper over a
188
- * misconfigured production app; remove it when the environment has a vibe client + HMAC key.
189
- */
190
- noRevocationSource?: boolean;
191
- }
192
-
193
- // =============================================================================
194
- // DEFAULT IMPLEMENTATIONS
195
- // =============================================================================
196
-
197
- /** Default no-op circuit breaker (always closed) */
198
- const defaultCircuitBreaker: CircuitBreakerProvider = {
199
- isOpen: () => false,
200
- canAttemptRefresh: () => true,
201
- recordSuccess: () => {},
202
- recordFailure: () => {},
203
- isNetworkError: (error: any) => {
204
- return error?.cause?.code === 'ECONNREFUSED' ||
205
- error?.code === 'ECONNREFUSED' ||
206
- error?.message?.includes('ECONNREFUSED') ||
207
- error?.message?.includes('fetch failed') ||
208
- error?.message?.includes('ENOTFOUND') ||
209
- error?.message?.includes('ETIMEDOUT') ||
210
- error?.message?.includes('ECONNRESET');
211
- }
212
- };
213
-
214
- /** Default console logger */
215
- const defaultLogger: MiddlewareLogger = {
216
- info: (msg, data) => console.log(`[MIDDLEWARE] ${msg}`, data || ''),
217
- warn: (msg, data) => console.warn(`[MIDDLEWARE] ${msg}`, data || ''),
218
- error: (msg, data) => console.error(`[MIDDLEWARE] ${msg}`, data || ''),
219
- };
220
-
221
- // =============================================================================
222
- // HELPERS
223
- // =============================================================================
224
-
225
- const LOGIN_PAGE = '/account-auth/login';
226
- const VERIFY_CODE_PAGE = '/account-auth/verify-code';
227
-
228
- // PAY-2315 (4.7.1): /login is a public alias of the login page. It used to take the 'public: allow' arm and never reached the
229
- // 'already_authenticated' redirect, so a signed-in user was shown the sign-in form. It is the login page for every arm now.
230
- // (safe-callback refuses /login as a destination, so this cannot redirect to itself.)
231
- const LOGIN_ALIAS = '/login';
232
-
233
- function isLoginPage(pathname: string): boolean {
234
- return pathname === LOGIN_PAGE || pathname === LOGIN_ALIAS;
235
- }
236
-
237
- /**
238
- * Ensures callback URLs never point to auth pages (prevents redirect loops) AND
239
- * never point off-origin (prevents open redirect).
240
- *
241
- * 375656: the previous body tested `pathname` FIRST and returned '/' early, so
242
- * the two call sites that pass an attacker-controlled `searchParams` are both
243
- * inside `if (onLoginPage)` branches - and `isLoginPage` is exact equality against
244
- * `LOGIN_PAGE`, which starts with `/account-auth/`. The early return therefore
245
- * fired before the parameter was ever read. That made this function SAFE, but
246
- * safe because of which argument the caller happened to pass, not because the
247
- * attacker-controlled value was ever checked: `existing` was returned raw on the
248
- * strength of a `startsWith` test that `https://evil.example.com` passes.
249
- * Nothing named that dependency, and reordering the arguments would have opened
250
- * it silently. Both inputs are now validated by the same parser-based guard.
251
- */
252
- function getSafeCallbackUrl(pathname: string, searchParams?: URLSearchParams): string {
253
- return resolveCallbackDestination(pathname, searchParams);
254
- }
255
-
256
- /**
257
- * PAY-2136 Rule A: a speculative request (router prefetch) must NEVER mutate cookies.
258
- * A prefetch of /account/security was answered 307 + Set-Cookie (session cookies expired), the browser
259
- * applied it, and the user's next real request (e.g. Approve) was signed out. Detected by any of:
260
- * `Next-Router-Prefetch: 1|2` (Next sets it on EVERY Link prefetch; 2 = runtime prefetch) or `Sec-Purpose`/`Purpose`
261
- * containing "prefetch".
262
- * NOT `RSC: 1` + `_rsc`: a real client-side navigation carries both too (Nextpert-Scout 70976), so that shape
263
- * would swallow in-app navigations and stop the middleware refreshing/clearing for them.
264
- */
265
- export function isPrefetchRequest(request: NextRequest): boolean {
266
- const h = request.headers;
267
- const nrp = h.get('next-router-prefetch');
268
- if (nrp === '1' || nrp === '2') return true;
269
- if (/prefetch/i.test(h.get('sec-purpose') ?? '') || /prefetch/i.test(h.get('purpose') ?? '')) return true;
270
- return false;
271
- }
272
-
273
- /**
274
- * Clear all session cookies on a response. The ONE site that writes the expiry Set-Cookie for the middleware,
275
- * so the prefetch guard lives here: on a prefetch the redirect is still returned (routing unchanged) but
276
- * carries no Set-Cookie; the next real navigation reaches the same decision and clears then.
277
- */
278
- function clearSessionCookies(response: NextResponse, request: NextRequest): void {
279
- if (isPrefetchRequest(request)) return;
280
- expireSessionCookies(response, Array.from(request.cookies, ([name]) => name));
281
- }
282
-
283
- /** Create redirect response, optionally clearing cookies */
284
- function redirectTo(
285
- request: NextRequest,
286
- location: string,
287
- clearCookies = false
288
- ): NextResponse {
289
- const response = NextResponse.redirect(new URL(location, request.url));
290
- if (clearCookies) {
291
- clearSessionCookies(response, request);
292
- }
293
- return response;
294
- }
295
-
296
- /** PAY-2218: where a page's clearing redirect goes when the app has mounted the session-reset route. */
297
- interface ResetConfig {
298
- route: string;
299
- secret: string;
300
- }
301
-
302
- /**
303
- * A redirect that must also END the session. With the reset route configured, a page request is sent through it
304
- * WITHOUT any Set-Cookie here: the route sees the flight headers this middleware cannot (Next strips them before
305
- * middleware), answers a prefetch 204 + no-store, and clears on the real navigation. `/api/` requests and apps
306
- * without the route keep the direct clear exactly as before.
307
- */
308
- async function clearingRedirect(request: NextRequest, location: string, reset?: ResetConfig): Promise<NextResponse> {
309
- if (!reset || request.nextUrl.pathname.startsWith('/api/')) {
310
- return redirectTo(request, location, true);
311
- }
312
- const target = new URL(location, request.url);
313
- const params = normaliseResetParams({
314
- callbackUrl: target.searchParams.get('callbackUrl'),
315
- reason: target.searchParams.get('reason'),
316
- error: target.searchParams.get('error'),
317
- });
318
- // Bound to THIS request's session cookie: the route clears only when its own request carries the same one.
319
- const bound = boundSessionCookie((n) => request.cookies.get(n)?.value, getSessionCookieName(), getSecureSessionCookieName());
320
- const response = NextResponse.redirect(new URL(await signedResetHref(reset.route, params, reset.secret, bound), request.url));
321
- response.headers.set('cache-control', 'no-store');
322
- return response;
323
- }
324
-
325
- /**
326
- * Terminal response for a FAILED token refresh.
327
- *
328
- * For `/api/` requests a redirect is the wrong terminator: fetch() follows the 307,
329
- * receives 200 + an HTML login page, and the caller parses garbage instead of
330
- * branching on an error. Those callers get a 401 JSON body they can branch on;
331
- * document requests keep the existing redirect exactly as before.
332
- *
333
- * Cookie-clearing is preserved on BOTH arms. That is load-bearing, not incidental:
334
- * on a permanent failure (401/403) stale cookies must be cleared or the next request
335
- * refreshes again and loops (see the CRITICAL note in handleRefresh). A JSON arm that
336
- * skipped clearing would convert a redirect loop into a silent refresh loop.
337
- *
338
- * Scoped deliberately to handleRefresh's failure exits. It is NOT folded into
339
- * redirectTo(), and NOT hoisted above the refresh decision: an `/api/` hatch placed
340
- * higher would intercept 'refresh_needed' and disable middleware token refresh across
341
- * the whole API surface (card 340024 C1).
342
- */
343
- async function refreshFailureResponse(
344
- request: NextRequest,
345
- location: string,
346
- errorCode: string,
347
- clearCookies = false,
348
- reset?: ResetConfig
349
- ): Promise<NextResponse> {
350
- if (request.nextUrl.pathname.startsWith('/api/')) {
351
- const response = NextResponse.json({ error: errorCode }, { status: 401 });
352
- if (clearCookies) {
353
- clearSessionCookies(response, request);
354
- }
355
- return response;
356
- }
357
- return clearCookies ? clearingRedirect(request, location, reset) : redirectTo(request, location);
358
- }
359
-
360
- /**
361
- * PAY-2147: the terminal answer when the middleware CANNOT TELL whether the session is good
362
- * (circuit breaker open, a half-open breaker refusing a refresh attempt, or the viability read failed).
363
- *
364
- * "I could not check" is not a verdict on the session, so this NEVER touches a cookie: a transient blip
365
- * in the IdP / viability path used to wipe the session of every user who made any request while the
366
- * breaker was open. Cookies are cleared only on a POSITIVE signal (force_invalidated, stale_session,
367
- * pre_2fa_token_expired, refresh 401/403, explicit sign-out) - never from here.
368
- *
369
- * - /api/* -> 503 JSON a caller can branch on (a redirect would be followed by fetch() into a 200 HTML page).
370
- * - pages -> /service-unavailable (public + allowDuringCircuitBreakerOpen, so it cannot loop), carrying the
371
- * requested path as `callbackUrl` so "Try again" returns the user to it still signed in. The path goes
372
- * through safeCallbackPath (URL-parser origin check): an off-origin or auth-surface value is dropped.
373
- */
374
- function serviceUnavailableResponse(request: NextRequest, check: UnavailableCheck = 'viability', unavailablePage?: string): NextResponse {
375
- const { pathname, search } = request.nextUrl;
376
- if (pathname.startsWith('/api/')) {
377
- // PAY-2339 P1/P12: one fixed machine-readable shape for every "could not check" answer. No protected data.
378
- return NextResponse.json({ error: 'ServiceUnavailable', unavailable: true, check }, { status: 503 });
379
- }
380
- const callback = safeCallbackPath(pathname + search);
381
- // PAY-2339 (BAPert 74640, P13): a HELD revocation answer needs no page in the consumer. Unless the app names its own
382
- // page (`unavailablePage`), the middleware answers inline: a minimal 503 HTML, a "try again" link to the safe
383
- // callback, and no Set-Cookie (nothing here touches the session). No redirect, so no 404 is possible.
384
- // `unavailablePage` is accepted only as a PLAIN path ([A-Za-z0-9_-/]; no query, fragment, %-encoding, dot segment or
385
- // scheme), so the self-check below compares like with like and a mis-set value falls back to the inline page (Scout 74668).
386
- // PAY-2345: the viability-FAILURE arm (500 / timeout / breaker) takes the same path, so an app with no page of its own
387
- // gets the inline 503 instead of a 404 during an outage. The two arms differ only in `check` (JSON body for /api/*). If the
388
- // request is already FOR the named page (an app that did not register it as public), answer inline: redirecting to
389
- // it again would loop with a longer callbackUrl each time (QAPert 74651).
390
- const named = unavailablePage && /^\/[A-Za-z0-9_\-\/]*$/.test(unavailablePage) && !unavailablePage.includes('//') ? unavailablePage : null;
391
- if (!named || pathname.replace(/\/+$/, '') === named.replace(/\/+$/, '')) {
392
- const href = callback.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
393
- return new NextResponse(
394
- '<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Temporarily unavailable</title>' +
395
- '<main role="status" style="font-family:system-ui,sans-serif;max-width:28rem;margin:20vh auto;padding:0 1rem;text-align:center">' +
396
- '<h1 style="font-size:1.25rem">Temporarily unavailable</h1>' +
397
- '<p>' + (check === 'revocation' ? 'We could not check your session just now. You are still signed in.' : 'We could not reach the sign-in service just now. Please try again.') + '</p>' +
398
- '<p><a href="' + href + '">Try again</a></p></main>',
399
- { status: 503, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'retry-after': '30' } },
400
- );
401
- }
402
- const target = new URL(named || '/service-unavailable', request.url);
403
- if (callback !== '/') target.searchParams.set('callbackUrl', callback);
404
- return NextResponse.redirect(target);
405
- }
406
-
407
- // =============================================================================
408
- // AUTH DECISION ENGINE
409
- // =============================================================================
410
-
411
- /**
412
- * Pure function that determines what action to take based on auth state.
413
- * No side effects - just examines context and returns a decision.
414
- */
415
- function makeAuthDecision(ctx: AuthContext): AuthAction {
416
- const { pathname, isPublicRoute, isLoginPage: onLoginPage, searchParams, sessionPointer, sessionStatus, circuitBreakerOpen } = ctx;
417
- const safeCallback = getSafeCallbackUrl(pathname);
418
-
419
- // --- PAY-884: a provisional (pre-2FA) session whose token has expired can never finish 2FA ---
420
- // The IdP issues no refresh token until 2FA completes, and it validates token lifetime, so
421
- // once the provisional access token expires neither refresh nor code verification can
422
- // succeed. Sending the user to the verify-code page (or to error=SessionExpired, which the
423
- // login page deliberately shows as NO message) strands them mid-2FA with no explanation.
424
- // Say what happened instead, and clear the dead session so the next sign-in starts clean.
425
- // Scoped to the verify-code page and protected pages: /api/ keeps its 401-JSON carve-out
426
- // (307290), the login page stays reachable, and public pages are left to the existing arms.
427
- if (
428
- isExpiredProvisionalSession(sessionPointer, sessionStatus) &&
429
- !pathname.startsWith('/api/') &&
430
- !onLoginPage &&
431
- (pathname === VERIFY_CODE_PAGE || !isPublicRoute)
432
- ) {
433
- const callback = pathname === VERIFY_CODE_PAGE ? getSafeCallbackUrl(pathname, searchParams) : safeCallback;
434
- return {
435
- type: 'redirect',
436
- location: `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(callback)}&error=${TWO_FACTOR_TIMEOUT_ERROR}`,
437
- reason: 'pre_2fa_token_expired',
438
- clearCookies: true,
439
- };
440
- }
441
-
442
- // --- Public routes: Allow, but enforce 2FA for authenticated users ---
443
- if (isPublicRoute && !onLoginPage) {
444
- if (needsTwoFactorRedirect(pathname, sessionPointer, sessionStatus)) {
445
- return redirect2FA(safeCallback);
446
- }
447
- return { type: 'allow' };
448
- }
449
-
450
- // --- Circuit breaker open: Service degraded ---
451
- // PAY-2147: the breaker opening says the IdP/viability path is unreachable, NOT that the session is bad.
452
- // Answer "service unavailable" (503 for /api, /service-unavailable for pages) and leave every cookie alone.
453
- if (circuitBreakerOpen) {
454
- if (onLoginPage) return { type: 'allow' };
455
- return { type: 'service_error', reason: 'circuit_breaker_open' };
456
- }
457
-
458
- // --- No session cookie: Need to login ---
459
- if (!sessionPointer.exists) {
460
- if (onLoginPage) return { type: 'allow' };
461
- if (pathname === '/') {
462
- const unauthUrl = process.env.UNAUTHENTICATED_REDIRECT_URL || '/account-auth/login';
463
- return { type: 'redirect', location: unauthUrl, reason: 'unauthenticated_root' };
464
- }
465
- if (pathname.startsWith('/api/')) {
466
- return { type: 'allow' }; // Let API return proper 401 JSON
467
- }
468
- return {
469
- type: 'redirect',
470
- location: `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}`,
471
- reason: 'no_session'
472
- };
473
- }
474
-
475
- // --- Service error: Can't verify session ---
476
- if (sessionStatus.exists === null || sessionStatus.forceInvalid === null) {
477
- if (onLoginPage) return { type: 'allow' }; // the login page must stay reachable while the check is down
478
- return { type: 'service_error', reason: 'viability_check_failed' };
479
- }
480
-
481
- // --- PAY-2339: the revocation backend could not answer and this app HOLDS (the default) ---
482
- // Not a verdict on the session: no cookie is touched, nothing protected is served, and the answer says why in a
483
- // fixed machine-readable form. The login page stays reachable.
484
- if (sessionStatus.unavailable) {
485
- if (onLoginPage) return { type: 'allow' };
486
- return { type: 'service_error', reason: 'revocation_unknown', check: 'revocation' };
487
- }
488
-
489
- // --- Session force-invalidated (admin action, password change, etc.) ---
490
- if (sessionStatus.forceInvalid) {
491
- if (onLoginPage) return { type: 'allow' };
492
- if (pathname.startsWith('/api/')) return { type: 'allow' };
493
- return {
494
- type: 'redirect',
495
- location: `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&reason=invalidated`,
496
- reason: 'force_invalidated',
497
- clearCookies: true
498
- };
499
- }
500
-
501
- // --- Stale session (cookie exists but Redis entry gone) ---
502
- if (!sessionStatus.exists) {
503
- if (onLoginPage) return { type: 'allow' };
504
- if (pathname.startsWith('/api/')) return { type: 'allow' };
505
- return {
506
- type: 'redirect',
507
- location: `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&reason=stale`,
508
- reason: 'stale_session',
509
- clearCookies: true
510
- };
511
- }
512
-
513
- // --- 2FA required but not completed ---
514
- if (needsTwoFactorRedirect(pathname, sessionPointer, sessionStatus)) {
515
- if (onLoginPage) {
516
- const cb = getSafeCallbackUrl(pathname, searchParams);
517
- return { type: 'redirect', location: `${VERIFY_CODE_PAGE}?callbackUrl=${encodeURIComponent(cb)}`, reason: '2fa_from_login' };
518
- }
519
- return redirect2FA(safeCallback);
520
- }
521
-
522
- // --- Token expired: Try refresh ---
523
- if (sessionPointer.expired) {
524
- if (onLoginPage) return { type: 'allow' };
525
- if (sessionPointer.hasRefreshToken) {
526
- return { type: 'refresh_needed' };
527
- }
528
- // 307290: this is the TERMINAL arm of the expired branch - refresh is not
529
- // possible here, so the only outcomes are "redirect" or "allow". Every other
530
- // terminal branch in this function (:242 no_session, :259 force_invalidated,
531
- // :271 stale_session, :318 2FA-incomplete) already exempts /api/ so the route
532
- // can answer with a real 401 JSON; this one did not, and redirecting an API
533
- // request to an HTML login page makes the failure INVISIBLE - fetch() follows
534
- // the 307, gets 200 HTML, resolves successfully and parses garbage.
535
- // MUST stay BELOW the hasRefreshToken check above: placed any higher it would
536
- // intercept 'refresh_needed' and disable middleware token refresh for the
537
- // entire API surface.
538
- if (pathname.startsWith('/api/')) return { type: 'allow' }; // Let API return proper 401 JSON
539
- return {
540
- type: 'redirect',
541
- location: `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&error=SessionExpired`,
542
- reason: 'token_expired_no_refresh'
543
- };
544
- }
545
-
546
- // --- Authenticated user on login page: Send to their destination ---
547
- if (onLoginPage) {
548
- const dest = getSafeCallbackUrl(pathname, searchParams);
549
- return { type: 'redirect', location: dest, reason: 'already_authenticated' };
550
- }
551
-
552
- // --- All checks passed ---
553
- return { type: 'allow' };
554
- }
555
-
556
- /** Login error code for a pre-2FA session that expired before the code was verified (PAY-884). */
557
- export const TWO_FACTOR_TIMEOUT_ERROR = 'TwoFactorTimeout';
558
-
559
- /**
560
- * PAY-884: 2FA outstanding, access token expired, and no refresh token to renew it.
561
- * Only a viability answer that positively says the session exists qualifies (null = check failed).
562
- */
563
- function isExpiredProvisionalSession(session: SessionPointer, status: SessionStatus): boolean {
564
- return (
565
- session.exists &&
566
- status.exists === true &&
567
- status.forceInvalid === false &&
568
- status.requires2FA &&
569
- !status.twoFactorComplete &&
570
- session.expired === true &&
571
- !session.hasRefreshToken
572
- );
573
- }
574
-
575
- /** Check if user needs 2FA redirect */
576
- function needsTwoFactorRedirect(pathname: string, session: SessionPointer, status: SessionStatus): boolean {
577
- if (!session.exists || !status.exists) return false;
578
- if (!status.requires2FA || status.twoFactorComplete) return false;
579
- if (pathname === VERIFY_CODE_PAGE) return false;
580
- if (should2FABypass(pathname)) return false;
581
- if (pathname.startsWith('/api/')) return false; // APIs return JSON errors
582
- return true;
583
- }
584
-
585
- function redirect2FA(callbackUrl: string): AuthAction {
586
- return {
587
- type: 'redirect',
588
- location: `${VERIFY_CODE_PAGE}?callbackUrl=${encodeURIComponent(callbackUrl)}`,
589
- reason: '2fa_required'
590
- };
591
- }
592
-
593
- // =============================================================================
594
- // MIDDLEWARE FACTORY
595
- // =============================================================================
596
-
597
- /** PAY-2218: the stripped-flight-headers warning is logged once per process, not once per request. */
598
- let flightHeadersStrippedWarned = false;
599
-
600
- export function createMvpMiddleware(options: MvpMiddlewareOptions = {}) {
601
- // Resolve options with defaults
602
- const cb = options.circuitBreaker || defaultCircuitBreaker;
603
- const log = options.logger || defaultLogger;
604
- const viabilityEndpoint = options.viabilityEndpoint || '/api/session/viability';
605
- const refreshEndpoint = options.refreshEndpoint || '/api/auth/refresh';
606
- const bypassPaths = options.bypassPaths || [];
607
- const rbacExemptPaths = options.rbacExemptPaths || [];
608
- // PAY-2339: the default is 'hold' (Jon 2026-10-13, option C): an unknown revocation answer does not sign the member
609
- // out. PAY-1882 step 4 had 'closed' (Jon's PAY-1880 option A, BAPert 66316); it is now opt-in by name.
610
- const revocationFailMode = options.revocationFailMode || 'hold';
611
- // PAY-2302 cut 1c: strictly `=== true`, so a truthy string in a config file cannot switch revocation off.
612
- const noRevocationSource = options.noRevocationSource === true;
613
- if (noRevocationSource) {
614
- log.warn(
615
- '[next-mvp] noRevocationSource is ON: sessions of this app CANNOT be revoked server-side (no vibe client / HMAC key). ' +
616
- "revocationStatus 'not_configured' is treated as viable; 'unknown' and 'revoked' still sign the user out. " +
617
- 'Remove this option once the environment has a vibe client id and HMAC key.'
618
- );
619
- }
620
- const sessionResetRoute =
621
- options.sessionResetRoute === true ? DEFAULT_SESSION_RESET_ROUTE
622
- : typeof options.sessionResetRoute === 'string' && options.sessionResetRoute ? options.sessionResetRoute
623
- : undefined;
624
- let resetSecretMissingLogged = false;
625
- const resolveReset = (): ResetConfig | undefined => {
626
- if (!sessionResetRoute) return undefined;
627
- const secret = resetSecret();
628
- if (secret) return { route: sessionResetRoute, secret };
629
- if (!resetSecretMissingLogged) {
630
- resetSecretMissingLogged = true;
631
- log.error('sessionResetRoute is set but no BETTER_AUTH_SECRET/NEXTAUTH_SECRET is available to sign the hand-off; clearing in middleware instead', { sessionResetRoute });
632
- }
633
- return undefined;
634
- };
635
-
636
- return async function middleware(request: NextRequest): Promise<NextResponse> {
637
- const { pathname, searchParams } = request.nextUrl;
638
-
639
- // =========================================================================
640
- // CRITICAL: Auth routes MUST bypass middleware
641
- // =========================================================================
642
- // These routes are called internally by middleware itself (viability check,
643
- // token refresh). Without this bypass, we get infinite loops or 403s.
644
- // This has caused production outages. DO NOT REMOVE.
645
- // =========================================================================
646
- if (pathname.startsWith('/api/auth/') || pathname.startsWith('/api/session/')) {
647
- return NextResponse.next();
648
- }
649
-
650
- // Check custom bypass paths
651
- for (const bypassPath of bypassPaths) {
652
- if (pathname.startsWith(bypassPath)) {
653
- return NextResponse.next();
654
- }
655
- }
656
-
657
- // PAY-2218: a router request (it carries `next-url`) that arrives WITHOUT `rsc` had its flight headers stripped
658
- // by Next before middleware ran, so isPrefetchRequest() cannot see a router prefetch on this app. Say so once.
659
- if (!sessionResetRoute && !flightHeadersStrippedWarned && request.headers.has('next-url') && !request.headers.has('rsc')) {
660
- flightHeadersStrippedWarned = true;
661
- log.warn('Next strips router prefetch headers before middleware on this app, so a session-ending redirect can fire on a prefetch. Mount @payez/next-mvp/routes/auth/session-reset and set sessionResetRoute (PAY-2218).', { pathname });
662
- }
663
-
664
- const isPublic = isUnauthenticatedRoute(pathname);
665
- const isLogin = isLoginPage(pathname);
666
-
667
- // --- Optimization: Skip viability check for unauthenticated public browsing ---
668
- if (isPublic && !isLogin) {
669
- const hasCookie = request.cookies.get(getSessionCookieName())?.value ||
670
- request.cookies.get(getSecureSessionCookieName())?.value;
671
- if (!hasCookie) {
672
- return NextResponse.next();
673
- }
674
- // Has cookie - continue to viability check for 2FA enforcement
675
- }
676
-
677
- // --- Fetch session viability from internal API ---
678
- const { sessionStatus, sessionPointer } = await checkViability(
679
- request,
680
- viabilityEndpoint,
681
- log,
682
- revocationFailMode,
683
- noRevocationSource
684
- );
685
-
686
- // --- Stale session (cookie exists but session doesn't): OBSERVE ONLY, DO NOT DECIDE ---
687
- //
688
- // *** CARD 349607. THIS BLOCK USED TO `return` HERE, AND THAT RETURN WAS A BYPASS. ***
689
- //
690
- // makeAuthDecision() below already handles this exact condition at its
691
- // `if (!sessionStatus.exists)` branch, and it handles it with TWO guard
692
- // clauses that this early return did not have:
693
- //
694
- // if (onLoginPage) return { type: 'allow' }; // no redirect loop
695
- // if (pathname.startsWith('/api/'))return { type: 'allow' }; // no HTML 307 at fetch()
696
- //
697
- // Returning here skipped BOTH. The `/api/` case is the reported harm: a
698
- // stale-session XHR was answered with a 307 to an HTML login page, so the
699
- // failure is invisible to `fetch()` - the caller sees an opaque redirect
700
- // instead of an auth error, which is the same "correct logic preempted by
701
- // an earlier return" shape recorded on 307290.
702
- //
703
- // This was NOT a missing feature one level down - the decision engine's
704
- // branch is a strict superset of the behaviour this block implemented.
705
- // The early return was a DUPLICATE that had drifted from the original.
706
- // Adding a single `/api/` exemption here would have re-created the
707
- // duplication (and left the onLoginPage divergence in place), so the
708
- // duplicate is removed instead and the engine is left as the ONE decider.
709
- //
710
- // The warning is deliberately KEPT: it is the only operational signal that
711
- // stale sessions are occurring, and it costs nothing to log before
712
- // delegating. Do not re-introduce a `return` in this block.
713
- if (!isPublic && sessionStatus.exists === false && sessionPointer.sessionTokenId) {
714
- // PAY-1505: the KEY is `sessionTokenId`, not `sessionToken`. The value has
715
- // always been a tokenLogId fingerprint (viability.ts builds it as
716
- // `tokenLogId(sessionToken)`), but labelling it `sessionToken` told any
717
- // operator reading this log that the line carried the token itself. The
718
- // credential-in-logs source guard reads the key for exactly that reason:
719
- // a field named `sessionToken` must hold a safe value, and proving that
720
- // across an API boundary is not possible from the name alone.
721
- log.warn('Stale session detected (delegating to decision engine)', {
722
- sessionTokenId: sessionPointer.sessionTokenId,
723
- pathname
724
- });
725
- }
726
-
727
- // --- Run decision engine ---
728
- const decision = makeAuthDecision({
729
- pathname,
730
- isPublicRoute: isPublic,
731
- isLoginPage: isLogin,
732
- searchParams,
733
- sessionPointer,
734
- sessionStatus,
735
- circuitBreakerOpen: cb.isOpen(),
736
- });
737
-
738
- // --- Execute decision ---
739
- return executeDecision(request, decision, pathname, sessionPointer, sessionStatus, {
740
- circuitBreaker: cb,
741
- logger: log,
742
- refreshEndpoint,
743
- rbacExemptPaths,
744
- reset: resolveReset(),
745
- unavailablePage: options.unavailablePage,
746
- onRefreshSuccess: options.onRefreshSuccess,
747
- onRefreshFailure: options.onRefreshFailure,
748
- });
749
- };
750
- }
751
-
752
- /**
753
- * The revocation decision, as a pure function (PAY-1882 step 4, NightHawk 66392).
754
- *
755
- * Separate + exported so the B1 defect is testable with a REAL mutant: the bug was
756
- * that `data.forceInvalid === true` was read BEFORE revocationFailMode, so a
757
- * producer that folds 'unknown' into forceInvalid made the 'open' lever dead.
758
- *
759
- * RULE: when the producer supplies `revocationStatus`, it is AUTHORITATIVE - the
760
- * decision is made from it plus failMode, and the folded booleans are ignored for
761
- * 'unknown'/'not_configured'. The booleans are read ONLY when revocationStatus is
762
- * absent (idealresume.online's older boolean-only route).
763
- */
764
- export type RevocationOutcome = 'viable' | 'invalid' | 'hold';
765
-
766
- /**
767
- * PAY-2339: the three-way form of the revocation decision. `decideForceInvalid` below is kept as its boolean view.
768
- *
769
- * 'invalid' - sign the member out (cookies cleared, login page)
770
- * 'hold' - the backend could not answer and this app holds: no sign-out, answer "temporarily unavailable"
771
- * 'viable' - carry on
772
- */
773
- export function decideRevocationOutcome(
774
- data: { revocationStatus?: unknown; forceInvalid?: unknown; revoked?: unknown },
775
- revocationFailMode: 'hold' | 'closed' | 'open',
776
- noRevocationSource: boolean = false,
777
- ): RevocationOutcome {
778
- const status = data.revocationStatus;
779
- if (typeof status === 'string') {
780
- if (status === 'revoked') return 'invalid';
781
- if (status === 'active') return 'viable';
782
- // PAY-2302 cut 1c: an app that declared it has no revocation source. ONLY the exact 'not_configured' string.
783
- if (status === 'not_configured' && noRevocationSource === true) return 'viable';
784
- // PAY-2339: 'not_configured' is a configuration fault, not an outage. Under the default it stays signed out
785
- // exactly as before; only 'open' relaxes it (a Vibe-less app).
786
- if (status === 'not_configured') return revocationFailMode === 'open' ? 'viable' : 'invalid';
787
- // S1 (NightHawk 66409): ANY other string - 'unknown', a typo like 'Revoked', 'error', or '' - is treated as
788
- // 'unknown' (the fail mode applies), NEVER as 'active'. Only an explicit 'active' is viable.
789
- if (revocationFailMode === 'open') return 'viable';
790
- return revocationFailMode === 'closed' ? 'invalid' : 'hold';
791
- }
792
- // No revocationStatus: an older boolean producer. Absence of both stays viable.
793
- return data.forceInvalid === true || data.revoked === true ? 'invalid' : 'viable';
794
- }
795
-
796
- export function decideForceInvalid(
797
- data: { revocationStatus?: unknown; forceInvalid?: unknown; revoked?: unknown },
798
- revocationFailMode: 'hold' | 'closed' | 'open',
799
- noRevocationSource: boolean = false,
800
- ): boolean {
801
- return decideRevocationOutcome(data, revocationFailMode, noRevocationSource) === 'invalid';
802
- }
803
-
804
- /**
805
- * PAY-2147: does the request carry a session cookie? Used ONLY when the viability read itself FAILED.
806
- * Before this, a failed read left sessionPointer.exists=false, so the decision engine took its `no_session` arm
807
- * (pages -> the LOGIN page, /api -> straight through) and its `exists === null` -> service_error arm below it was
808
- * unreachable dead code: a user holding a perfectly good session was told to sign in during an outage. With a
809
- * cookie present and the check down we do not know the session is bad, so the engine must say "service
810
- * unavailable", not "sign in". No cookie -> unchanged (no_session).
811
- */
812
- function hasSessionCookie(request: NextRequest): boolean {
813
- return !!(request.cookies.get(getSessionCookieName())?.value || request.cookies.get(getSecureSessionCookieName())?.value);
814
- }
815
-
816
- /** Check session viability via internal API */
817
- async function checkViability(
818
- request: NextRequest,
819
- endpoint: string,
820
- log: MiddlewareLogger,
821
- revocationFailMode: 'hold' | 'closed' | 'open',
822
- noRevocationSource: boolean = false
823
- ): Promise<{
824
- sessionStatus: SessionStatus;
825
- sessionPointer: SessionPointer;
826
- }> {
827
- const sessionStatus: SessionStatus = {
828
- exists: false,
829
- forceInvalid: false,
830
- requires2FA: false,
831
- twoFactorComplete: false,
832
- };
833
- const sessionPointer: SessionPointer = {
834
- exists: false,
835
- sessionTokenId: undefined,
836
- expired: false,
837
- hasRefreshToken: false,
838
- roles: [],
839
- clientId: '',
840
- };
841
-
842
- try {
843
- const baseUrl = getInternalApiUrl(request);
844
- const response = await fetch(new URL(endpoint, baseUrl), {
845
- method: 'GET',
846
- headers: {
847
- 'Accept': 'application/json',
848
- 'Cache-Control': 'no-store',
849
- 'Cookie': request.headers.get('cookie') || ''
850
- },
851
- credentials: 'include',
852
- signal: AbortSignal.timeout(5000),
853
- });
854
-
855
- if (response.ok) {
856
- const data = await response.json();
857
- // Support both response formats (viability and refresh-viability)
858
- sessionStatus.exists = data.authenticated ?? (!!data.sessionTokenId && data.reason !== 'session_not_found');
859
- sessionStatus.requires2FA = data.requires2FA || false;
860
- sessionStatus.twoFactorComplete = data.twoFactorComplete || false;
861
- // --- Session revocation (PAY-1882 Step 4) ---
862
- // Two producer spellings are honoured, because both exist on the wire:
863
- // - boolean `forceInvalid` / `revoked` (idealresume.online's route today)
864
- // - the three-state `revocationStatus` the package's own viability
865
- // handlers now emit (PAY-1882 step 4).
866
- //
867
- // RULING (Jon, via BAPert 66316): 'revoked' ALWAYS fails closed. 'unknown'
868
- // (a FAILED Vibe read) fails closed by default and is the ONLY state that
869
- // the per-app `revocationFailMode` option can relax to viable.
870
- //
871
- // Four producer states (BAPert 66320), with the fail-closed ruling for
872
- // 'not_configured' added by BAPert 66356:
873
- // 'active' - Vibe read OK, not revoked -> viable
874
- // 'revoked' - Vibe says revoked -> force-invalid, ALWAYS
875
- // 'unknown' - read FAILED -> force-invalid if 'closed'
876
- // 'not_configured' - no revocation source -> force-invalid if 'closed'
877
- //
878
- // WHY 'not_configured' FAILS CLOSED WHEN 'closed': both live consumers of
879
- // this middleware (portal.keelauth.com / keelauth-portal, idealvibe.online)
880
- // DO set IDP_URL + VIBE_CLIENT_ID + VIBE_HMAC_KEY (BAPert 66356, measured in
881
- // their committed manifests + the graft pipeline's own secret check), so for
882
- // them 'not_configured' can only mean a MISCONFIGURATION - revocation
883
- // silently switched off is exactly the PAY-1880 fail-open bug. An app with
884
- // no revocation source that genuinely wants availability sets
885
- // revocationFailMode 'open'.
886
- //
887
- // A MISSING revocationStatus (an older producer) keeps today's behaviour,
888
- // i.e. NOT force-invalid: absence must never sign out a population that
889
- // never opted into the check.
890
- //
891
- // *** revocationStatus IS AUTHORITATIVE WHEN PRESENT (NightHawk 66392). ***
892
- // The package's own producers emit `forceInvalid: revocationStatus ===
893
- // 'revoked' || revocationStatus === 'unknown'`. If the boolean were read
894
- // FIRST, `forceInvalid: true` would short-circuit before revocationFailMode
895
- // was consulted, so `'open'` could never keep an 'unknown' session viable -
896
- // the opt-out lever would be DEAD. So: when a producer supplies a
897
- // revocations status, decide from it + failMode and ignore the folded
898
- // booleans for unknown/not_configured. The booleans are read ONLY when
899
- // revocationStatus is ABSENT (idealresume.online's older boolean route).
900
- const outcome = decideRevocationOutcome(data, revocationFailMode, noRevocationSource);
901
- sessionStatus.forceInvalid = outcome === 'invalid';
902
- if (outcome === 'hold') {
903
- sessionStatus.unavailable = true;
904
- // PAY-2339 P11: one countable line per held request, naming the check. No session value, no address.
905
- log.warn('revocation_unavailable', { check: 'revocation' });
906
- }
907
- // PAY-2216: a STALE session - the cookie resolved to a token, but no store holds the session - used to set
908
- // pointer.exists=false, so makeAuthDecision took no_session (login, cookie NOT cleared) and its stale_session
909
- // arm (clearCookies) was unreachable: a dead cookie lingered and re-ran viability on every request.
910
- // Stale is a POSITIVE signal and needs ALL of: this 2xx read, exists === false, a sessionTokenId (every
911
- // producer sets it only when a cookie resolved to a token: routes/auth/viability.ts, api-handlers/session/
912
- // viability.ts, refresh-viability.ts 'session_not_found'), and a session cookie on THIS request (plain or
913
- // __Secure-). A FAILED read never gets here (exists=null -> service_error, PAY-2147); a store error throws
914
- // inside every producer and answers 5xx or 'unknown' WITHOUT a sessionTokenId; no cookie -> no_session.
915
- const stale = sessionStatus.exists === false && !!data.sessionTokenId && hasSessionCookie(request);
916
- sessionPointer.exists = stale ? true : (sessionStatus.exists ?? false);
917
- sessionPointer.sessionTokenId = data.sessionTokenId ?? null;
918
- sessionPointer.expired = data.accessTokenExpired || false;
919
- sessionPointer.hasRefreshToken = data.hasRefreshToken ?? data.canRefresh ?? false;
920
- sessionPointer.roles = data.roles || [];
921
- sessionPointer.clientId = data.clientId || '';
922
- } else {
923
- log.error('Viability check failed', { status: response.status });
924
- sessionStatus.exists = null;
925
- sessionStatus.forceInvalid = null;
926
- sessionPointer.exists = hasSessionCookie(request); // PAY-2147: see hasSessionCookie
927
- }
928
- } catch (error) {
929
- log.error('Viability check error', { error: error instanceof Error ? error.message : String(error) });
930
- sessionStatus.exists = null;
931
- sessionStatus.forceInvalid = null;
932
- sessionPointer.exists = hasSessionCookie(request); // PAY-2147: see hasSessionCookie
933
- }
934
-
935
- return { sessionStatus, sessionPointer };
936
- }
937
-
938
- /** Options passed to executeDecision */
939
- interface ExecuteOptions {
940
- circuitBreaker: CircuitBreakerProvider;
941
- logger: MiddlewareLogger;
942
- refreshEndpoint: string;
943
- rbacExemptPaths: string[];
944
- /** PAY-2218: set when the app mounted the session-reset route AND a signing secret exists. */
945
- reset?: ResetConfig;
946
- /** PAY-2339: the app's named hold page; absent = the inline 503 HTML. */
947
- unavailablePage?: string;
948
- onRefreshSuccess?: () => void;
949
- onRefreshFailure?: (status: number, isNetworkError: boolean) => void;
950
- }
951
-
952
- /** Execute the auth decision */
953
- async function executeDecision(
954
- request: NextRequest,
955
- decision: AuthAction,
956
- pathname: string,
957
- sessionPointer: SessionPointer,
958
- sessionStatus: SessionStatus,
959
- opts: ExecuteOptions
960
- ): Promise<NextResponse> {
961
- const safeCallback = getSafeCallbackUrl(pathname);
962
-
963
- switch (decision.type) {
964
- case 'allow':
965
- return handleAllow(request, pathname, sessionPointer, sessionStatus, opts.rbacExemptPaths);
966
-
967
- case 'redirect':
968
- return decision.clearCookies
969
- ? clearingRedirect(request, decision.location, opts.reset)
970
- : redirectTo(request, decision.location);
971
-
972
- case 'service_error':
973
- return serviceUnavailableResponse(request, decision.check ?? 'viability', opts.unavailablePage);
974
-
975
- case 'refresh_needed':
976
- // PAY-2136: a speculative request must not spend (rotate) a single-use refresh token or, on a 401/403,
977
- // clear the session. Let it through; the real navigation refreshes.
978
- if (isPrefetchRequest(request)) return NextResponse.next();
979
- return handleRefresh(request, safeCallback, opts);
980
- }
981
- }
982
-
983
- /** Paths that must never be RBAC-checked (they are RBAC redirect targets) */
984
- const RBAC_EXEMPT_PATHS = ['/error', '/unauthorized', '/service-unavailable'];
985
-
986
- /** Handle 'allow' decision - run RBAC if enabled */
987
- async function handleAllow(
988
- request: NextRequest,
989
- pathname: string,
990
- sessionPointer: SessionPointer,
991
- sessionStatus: SessionStatus,
992
- rbacExemptPaths: string[] = []
993
- ): Promise<NextResponse> {
994
- const isPublic = isUnauthenticatedRoute(pathname);
995
-
996
- // PAY-2216: RBAC needs a session that viability CONFIRMED. A stale /api request (pointer.exists only says the
997
- // cookie is present, status.exists is false) is allowed through so the route handler answers its own 401 - not an
998
- // RBAC 401 here. TRUTHY, not `=== true` (NightHawk 71960): status.exists carries the producer's JSON value as-is,
999
- // and a consumer route may answer authenticated: 1 / "true"; `=== true` skipped RBAC for those healthy sessions
1000
- // (fail-OPEN). Truthy keeps 4.5.3's RBAC coverage exactly and still excludes stale (false) and a failed read (null).
1001
- if (isRBACEnabled() && !isPublic && sessionPointer.exists && sessionStatus.exists) {
1002
- // Skip RBAC for error/fallback pages (prevent redirect loops) and app-configured exempt paths
1003
- if (RBAC_EXEMPT_PATHS.some(p => pathname.startsWith(p)) ||
1004
- rbacExemptPaths.some(p => pathname.startsWith(p))) {
1005
- return NextResponse.next();
1006
- }
1007
-
1008
- if (!sessionPointer.clientId) {
1009
- console.error('[MIDDLEWARE] RBAC: No clientId — returning 401');
1010
- if (pathname.startsWith('/api/')) {
1011
- return NextResponse.json(
1012
- { error: 'Unauthorized — missing clientId for RBAC' },
1013
- { status: 401 }
1014
- );
1015
- }
1016
- return NextResponse.redirect(new URL('/unauthorized', request.url));
1017
- }
1018
-
1019
- try {
1020
- const result = await checkPagePermission(pathname, sessionPointer.roles, sessionPointer.clientId);
1021
-
1022
- if (!result.allowed) {
1023
- console.log('[MIDDLEWARE] RBAC denied:', { pathname, reason: result.reason });
1024
-
1025
- // In development, fail open - RBAC API may not be fully configured
1026
- if (process.env.NODE_ENV !== 'production') {
1027
- console.warn('[MIDDLEWARE] RBAC: Allowing in development despite denial:', result.reason);
1028
- return NextResponse.next();
1029
- }
1030
-
1031
- return NextResponse.redirect(new URL(result.redirect || '/unauthorized', request.url));
1032
- }
1033
-
1034
- if (result.requires_2fa && !sessionStatus.twoFactorComplete) {
1035
- return NextResponse.redirect(
1036
- new URL(`${VERIFY_CODE_PAGE}?callbackUrl=${encodeURIComponent(pathname)}`, request.url)
1037
- );
1038
- }
1039
- } catch (error) {
1040
- console.error('[MIDDLEWARE] RBAC error:', error);
1041
-
1042
- // In development, fail open
1043
- if (process.env.NODE_ENV !== 'production') {
1044
- console.warn('[MIDDLEWARE] RBAC: Allowing in development despite error');
1045
- return NextResponse.next();
1046
- }
1047
-
1048
- return NextResponse.redirect(new URL('/error?code=rbac_error', request.url));
1049
- }
1050
- }
1051
-
1052
- return NextResponse.next();
1053
- }
1054
-
1055
- /** Handle token refresh */
1056
- async function handleRefresh(
1057
- request: NextRequest,
1058
- safeCallback: string,
1059
- opts: ExecuteOptions
1060
- ): Promise<NextResponse> {
1061
- const { circuitBreaker: cb, logger: log, refreshEndpoint, onRefreshSuccess, onRefreshFailure } = opts;
1062
-
1063
- // Check if circuit breaker allows refresh attempt
1064
- if (!cb.canAttemptRefresh()) {
1065
- log.warn('Circuit breaker preventing refresh attempt');
1066
- // PAY-2147: a breaker that merely declines to TRY a refresh has learned nothing about the session; wiping the
1067
- // cookies here logged out every user on the first request after a blip (HALF_OPEN refuses all but one probe).
1068
- return serviceUnavailableResponse(request, 'viability', opts.unavailablePage);
1069
- }
1070
-
1071
- try {
1072
- const baseUrl = getInternalApiUrl(request);
1073
- const response = await fetch(new URL(refreshEndpoint, baseUrl), {
1074
- method: 'POST',
1075
- headers: {
1076
- 'Accept': 'application/json',
1077
- 'Content-Type': 'application/json',
1078
- 'Cookie': request.headers.get('cookie') || '',
1079
- 'x-session-token': request.cookies.get(getSessionCookieName())?.value ||
1080
- request.cookies.get(getSecureSessionCookieName())?.value || ''
1081
- },
1082
- credentials: 'include',
1083
- signal: AbortSignal.timeout(5000),
1084
- });
1085
-
1086
- if (response.ok) {
1087
- const data = await response.json();
1088
- if (data.refreshed || data.reason === 'already_fresh') {
1089
- cb.recordSuccess();
1090
- onRefreshSuccess?.();
1091
- log.info('Token refresh successful');
1092
- return NextResponse.next();
1093
- }
1094
- }
1095
-
1096
- // Refresh failed - check for permanent failures (401/403) vs transient (5xx)
1097
- // CRITICAL: Always clear cookies on 401/403 to prevent redirect loops
1098
- // CRITICAL: Only record circuit breaker failures for network errors, not HTTP errors
1099
- // HTTP 500 means the server responded - it's reachable. Only network failures should trip the breaker.
1100
- const isPermanentFailure = response.status === 401 || response.status === 403;
1101
- const errorParam = isPermanentFailure ? 'SessionExpired' : 'RefreshError';
1102
-
1103
- log.warn('Refresh failed', {
1104
- status: response.status,
1105
- isPermanentFailure,
1106
- clearingCookies: isPermanentFailure
1107
- });
1108
-
1109
- onRefreshFailure?.(response.status, false);
1110
-
1111
- return refreshFailureResponse(
1112
- request,
1113
- `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&error=${errorParam}`,
1114
- errorParam,
1115
- isPermanentFailure,
1116
- opts.reset
1117
- );
1118
- } catch (error: any) {
1119
- const isNetwork = cb.isNetworkError(error);
1120
- log.error('Refresh error', {
1121
- error: error instanceof Error ? error.message : String(error),
1122
- isNetworkError: isNetwork
1123
- });
1124
-
1125
- // Only record circuit breaker failure for actual network errors
1126
- if (isNetwork) {
1127
- cb.recordFailure(error);
1128
- }
1129
-
1130
- onRefreshFailure?.(0, isNetwork);
1131
-
1132
- // Network error - don't clear cookies, might be transient
1133
- return refreshFailureResponse(
1134
- request,
1135
- `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&error=RefreshError`,
1136
- 'RefreshError',
1137
- false
1138
- );
1139
- }
1140
- }
1141
-
1
+ /**
2
+ * MVP Middleware - Authentication & Route Protection
3
+ *
4
+ * Creates a Next.js middleware handler that protects routes based on:
5
+ * - Authentication status (session cookie → Redis viability check)
6
+ * - 2FA completion status
7
+ * - RBAC permissions (if enabled)
8
+ *
9
+ * Usage: Export the result from your app's middleware.ts:
10
+ * export default createMvpMiddleware();
11
+ *
12
+ * With custom options:
13
+ * export default createMvpMiddleware({
14
+ * circuitBreaker: myCircuitBreaker,
15
+ * logger: myLogger,
16
+ * viabilityEndpoint: '/api/session/refresh-viability',
17
+ * });
18
+ */
19
+
20
+ import { NextRequest, NextResponse } from 'next/server';
21
+ import { isUnauthenticatedRoute, should2FABypass } from '../auth/route-config';
22
+ import { resolveCallbackDestination, safeCallbackPath } from '../auth/safe-callback';
23
+ import { getInternalApiUrl } from '../edge/internal-api-url';
24
+ import { getSessionCookieName, getSecureSessionCookieName } from '../lib/app-slug';
25
+ import { checkPagePermission, isRBACEnabled } from './rbac-check';
26
+ import { tokenLogId } from '../lib/token-log-id';
27
+ import { expireSessionCookies } from '../auth/session-cookie-clear';
28
+ import {
29
+ DEFAULT_SESSION_RESET_ROUTE,
30
+ boundSessionCookie,
31
+ normaliseResetParams,
32
+ resetSecret,
33
+ signedResetHref,
34
+ } from '../auth/session-reset-link';
35
+
36
+ // =============================================================================
37
+ // TYPES
38
+ // =============================================================================
39
+
40
+ export interface AuthContext {
41
+ pathname: string;
42
+ isPublicRoute: boolean;
43
+ isLoginPage: boolean;
44
+ searchParams: URLSearchParams;
45
+ sessionPointer: SessionPointer;
46
+ sessionStatus: SessionStatus;
47
+ circuitBreakerOpen: boolean;
48
+ }
49
+
50
+ export interface SessionPointer {
51
+ exists: boolean;
52
+ /** PAY-1481: tokenLogId of the session token (presence + log correlation), never the token itself. */
53
+ sessionTokenId?: string | null;
54
+ expired?: boolean;
55
+ hasRefreshToken?: boolean;
56
+ roles: string[];
57
+ clientId: string;
58
+ }
59
+
60
+ /** PAY-2339: which check could not answer. A fixed, machine-readable value: consumers never parse message text. */
61
+ export type UnavailableCheck = 'revocation' | 'viability';
62
+
63
+ export interface SessionStatus {
64
+ exists: boolean | null; // null = check failed/timeout
65
+ forceInvalid: boolean | null; // null = check failed/timeout
66
+ /** PAY-2339: the revocation backend could not answer and this app HOLDS (revocationFailMode 'hold'): no sign-out. */
67
+ unavailable?: boolean;
68
+ requires2FA: boolean;
69
+ twoFactorComplete: boolean;
70
+ }
71
+
72
+ export type AuthAction =
73
+ | { type: 'allow' }
74
+ | { type: 'redirect'; location: string; reason: string; clearCookies?: boolean }
75
+ | { type: 'service_error'; reason: string; check?: UnavailableCheck }
76
+ | { type: 'refresh_needed' };
77
+
78
+ // =============================================================================
79
+ // CIRCUIT BREAKER INTERFACE
80
+ // =============================================================================
81
+
82
+ /**
83
+ * Circuit breaker interface for middleware integration.
84
+ * Implement this to provide custom circuit breaker behavior.
85
+ */
86
+ export interface CircuitBreakerProvider {
87
+ /** Check if circuit breaker is currently open */
88
+ isOpen(): boolean;
89
+ /** Check if a refresh attempt is allowed (for HALF_OPEN state) */
90
+ canAttemptRefresh(): boolean;
91
+ /** Record a successful operation (closes the breaker) */
92
+ recordSuccess(): void;
93
+ /** Record a failed operation (may open the breaker) */
94
+ recordFailure(error?: Error): void;
95
+ /** Check if an error is a network error (vs HTTP error) */
96
+ isNetworkError(error: any): boolean;
97
+ }
98
+
99
+ /**
100
+ * Logger interface for middleware.
101
+ * Implement this to provide custom logging.
102
+ */
103
+ export interface MiddlewareLogger {
104
+ info(message: string, data?: Record<string, any>): void;
105
+ warn(message: string, data?: Record<string, any>): void;
106
+ error(message: string, data?: Record<string, any>): void;
107
+ }
108
+
109
+ /**
110
+ * Configuration options for createMvpMiddleware
111
+ */
112
+ export interface MvpMiddlewareOptions {
113
+ /** Custom circuit breaker implementation */
114
+ circuitBreaker?: CircuitBreakerProvider;
115
+ /** Custom logger implementation */
116
+ logger?: MiddlewareLogger;
117
+ /** Custom viability check endpoint (default: /api/session/viability) */
118
+ viabilityEndpoint?: string;
119
+ /** Custom refresh endpoint (default: /api/auth/refresh) */
120
+ refreshEndpoint?: string;
121
+ /** Hook called on successful refresh */
122
+ onRefreshSuccess?: () => void;
123
+ /** Hook called on refresh failure */
124
+ onRefreshFailure?: (status: number, isNetworkError: boolean) => void;
125
+ /** Additional paths to bypass middleware (beyond /api/auth/ and /api/session/) */
126
+ bypassPaths?: string[];
127
+ /** Paths exempt from RBAC checks (auth still enforced, just no page-permission check) */
128
+ rbacExemptPaths?: string[];
129
+ /**
130
+ * PAY-2218 - send a page's session-ending redirect through the session-reset ROUTE instead of clearing the
131
+ * cookies in the middleware response. Next strips the router's prefetch headers before middleware runs
132
+ * (15.5.x and 16.x; 16.2.x restores them only with `skipProxyUrlNormalize: true`), so a middleware clear also
133
+ * fires on a router PREFETCH and signs the user out of a page they never opened. The route sees those headers.
134
+ *
135
+ * Requires the app to mount the route: app/api/auth/session-reset/route.ts
136
+ * export { GET } from '@payez/next-mvp/routes/auth/session-reset';
137
+ * `true` uses that default path; a string names another mount point under /api/auth/ (middleware bypasses it).
138
+ * Needs BETTER_AUTH_SECRET (or NEXTAUTH_SECRET) to sign the hand-off; without one the direct clear is kept and
139
+ * an error is logged once. Default: off (the direct clear), because an unmounted route would 404 every sign-out.
140
+ */
141
+ sessionResetRoute?: boolean | string;
142
+ /**
143
+ * PAY-1882 step 4 / PAY-2339 - how this app treats a producer that reports the session's
144
+ * revocation status as `'unknown'` (a Vibe read that FAILED: non-OK, timeout,
145
+ * throw - the status could not be determined).
146
+ *
147
+ * 'hold' (DEFAULT since PAY-2339, Jon 2026-10-13, option C) - the member is NOT signed out. The request is
148
+ * answered "temporarily unavailable": a page goes to /service-unavailable?callbackUrl=
149
+ * (the session cookie is NOT touched, so "Try again" returns the member still signed in);
150
+ * an /api request gets a 503 carrying `unavailable: true` and `check: 'revocation'`, a
151
+ * fixed machine-readable value. Nothing protected is served while the answer is unknown.
152
+ * Each held request logs one countable `revocation_unavailable` line.
153
+ * THIS REPLACES THE OLD DEFAULT: the PAY-1880 ruling (Jon, 2026-09-27, option A) made
154
+ * 'closed' the default and accepted that a Vibe outage signs an app's users out. That
155
+ * sentence no longer applies to 'unknown' by default: a Vibe outage no longer signs members
156
+ * out unless the app asks for 'closed'. 'revoked' is unchanged.
157
+ * 'closed' - an unknown revocation status is NOT viable; the session is force-invalidated and the
158
+ * user re-auths (TODAY'S sign-out, now opt-in by name). Consequence, accepted explicitly:
159
+ * a Vibe outage signs this app's users out.
160
+ * 'open' - an unknown status stays viable (availability over fail-closed). Per-app opt-out for a
161
+ * deployment with no revocation source.
162
+ *
163
+ * `'not_configured'` is UNCHANGED under the default: it is a configuration fault, not an outage, so it still
164
+ * force-invalidates exactly as before (a "try again" page would be false); `noRevocationSource` is the only way
165
+ * to make it viable. Neither mode changes `'revoked'` (always force-invalid, unchanged) or an ABSENT
166
+ * `revocationStatus` (an older producer - unchanged behaviour, so no consumer is signed out for not
167
+ * implementing the check). A consumer that wrote 'closed' or 'open' by name keeps exactly what it asked for.
168
+ */
169
+ revocationFailMode?: 'hold' | 'closed' | 'open';
170
+
171
+ /**
172
+ * PAY-2339 (BAPert 74640): where a page request goes while a revocation answer is held. DEFAULT (absent): the
173
+ * middleware answers inline with a minimal 503 HTML page ("Temporarily unavailable", a "Try again" link to the safe
174
+ * callback, no Set-Cookie), so a consumer that never added a page cannot get a 404. Name a same-site path (e.g.
175
+ * '/service-unavailable') to redirect there instead with `?callbackUrl=`; the consumer then mounts
176
+ * `export { default } from '@payez/next-mvp/pages/service-unavailable'` (or its own page) at that route.
177
+ */
178
+ unavailablePage?: string;
179
+
180
+ /**
181
+ * PAY-2302 cut 1c (BAPert 73585): this app has NO revocation source - for example a development environment
182
+ * provisioned without a vibe client id / HMAC key - and says so EXPLICITLY. Under it a viability answer of
183
+ * `revocationStatus: 'not_configured'` is viable, and a loud warning is logged once at startup.
184
+ *
185
+ * It relaxes ONLY 'not_configured'. 'unknown' (a revocation read that FAILED) and 'revoked' stay closed whatever
186
+ * `revocationFailMode` says, and the default (option absent) is unchanged: 'not_configured' fails closed, because
187
+ * for an app that HAS a vibe client it means a misconfiguration (BAPert 66356). Do not use this to paper over a
188
+ * misconfigured production app; remove it when the environment has a vibe client + HMAC key.
189
+ */
190
+ noRevocationSource?: boolean;
191
+ }
192
+
193
+ // =============================================================================
194
+ // DEFAULT IMPLEMENTATIONS
195
+ // =============================================================================
196
+
197
+ /** Default no-op circuit breaker (always closed) */
198
+ const defaultCircuitBreaker: CircuitBreakerProvider = {
199
+ isOpen: () => false,
200
+ canAttemptRefresh: () => true,
201
+ recordSuccess: () => {},
202
+ recordFailure: () => {},
203
+ isNetworkError: (error: any) => {
204
+ return error?.cause?.code === 'ECONNREFUSED' ||
205
+ error?.code === 'ECONNREFUSED' ||
206
+ error?.message?.includes('ECONNREFUSED') ||
207
+ error?.message?.includes('fetch failed') ||
208
+ error?.message?.includes('ENOTFOUND') ||
209
+ error?.message?.includes('ETIMEDOUT') ||
210
+ error?.message?.includes('ECONNRESET');
211
+ }
212
+ };
213
+
214
+ /** Default console logger */
215
+ const defaultLogger: MiddlewareLogger = {
216
+ info: (msg, data) => console.log(`[MIDDLEWARE] ${msg}`, data || ''),
217
+ warn: (msg, data) => console.warn(`[MIDDLEWARE] ${msg}`, data || ''),
218
+ error: (msg, data) => console.error(`[MIDDLEWARE] ${msg}`, data || ''),
219
+ };
220
+
221
+ // =============================================================================
222
+ // HELPERS
223
+ // =============================================================================
224
+
225
+ const LOGIN_PAGE = '/account-auth/login';
226
+ const VERIFY_CODE_PAGE = '/account-auth/verify-code';
227
+
228
+ // PAY-2315 (4.7.1): /login is a public alias of the login page. It used to take the 'public: allow' arm and never reached the
229
+ // 'already_authenticated' redirect, so a signed-in user was shown the sign-in form. It is the login page for every arm now.
230
+ // (safe-callback refuses /login as a destination, so this cannot redirect to itself.)
231
+ const LOGIN_ALIAS = '/login';
232
+
233
+ function isLoginPage(pathname: string): boolean {
234
+ return pathname === LOGIN_PAGE || pathname === LOGIN_ALIAS;
235
+ }
236
+
237
+ /**
238
+ * Ensures callback URLs never point to auth pages (prevents redirect loops) AND
239
+ * never point off-origin (prevents open redirect).
240
+ *
241
+ * 375656: the previous body tested `pathname` FIRST and returned '/' early, so
242
+ * the two call sites that pass an attacker-controlled `searchParams` are both
243
+ * inside `if (onLoginPage)` branches - and `isLoginPage` is exact equality against
244
+ * `LOGIN_PAGE`, which starts with `/account-auth/`. The early return therefore
245
+ * fired before the parameter was ever read. That made this function SAFE, but
246
+ * safe because of which argument the caller happened to pass, not because the
247
+ * attacker-controlled value was ever checked: `existing` was returned raw on the
248
+ * strength of a `startsWith` test that `https://evil.example.com` passes.
249
+ * Nothing named that dependency, and reordering the arguments would have opened
250
+ * it silently. Both inputs are now validated by the same parser-based guard.
251
+ */
252
+ function getSafeCallbackUrl(pathname: string, searchParams?: URLSearchParams): string {
253
+ return resolveCallbackDestination(pathname, searchParams);
254
+ }
255
+
256
+ /**
257
+ * PAY-2136 Rule A: a speculative request (router prefetch) must NEVER mutate cookies.
258
+ * A prefetch of /account/security was answered 307 + Set-Cookie (session cookies expired), the browser
259
+ * applied it, and the user's next real request (e.g. Approve) was signed out. Detected by any of:
260
+ * `Next-Router-Prefetch: 1|2` (Next sets it on EVERY Link prefetch; 2 = runtime prefetch) or `Sec-Purpose`/`Purpose`
261
+ * containing "prefetch".
262
+ * NOT `RSC: 1` + `_rsc`: a real client-side navigation carries both too (Nextpert-Scout 70976), so that shape
263
+ * would swallow in-app navigations and stop the middleware refreshing/clearing for them.
264
+ */
265
+ export function isPrefetchRequest(request: NextRequest): boolean {
266
+ const h = request.headers;
267
+ const nrp = h.get('next-router-prefetch');
268
+ if (nrp === '1' || nrp === '2') return true;
269
+ if (/prefetch/i.test(h.get('sec-purpose') ?? '') || /prefetch/i.test(h.get('purpose') ?? '')) return true;
270
+ return false;
271
+ }
272
+
273
+ /**
274
+ * Clear all session cookies on a response. The ONE site that writes the expiry Set-Cookie for the middleware,
275
+ * so the prefetch guard lives here: on a prefetch the redirect is still returned (routing unchanged) but
276
+ * carries no Set-Cookie; the next real navigation reaches the same decision and clears then.
277
+ */
278
+ function clearSessionCookies(response: NextResponse, request: NextRequest): void {
279
+ if (isPrefetchRequest(request)) return;
280
+ expireSessionCookies(response, Array.from(request.cookies, ([name]) => name));
281
+ }
282
+
283
+ /** Create redirect response, optionally clearing cookies */
284
+ function redirectTo(
285
+ request: NextRequest,
286
+ location: string,
287
+ clearCookies = false
288
+ ): NextResponse {
289
+ const response = NextResponse.redirect(new URL(location, request.url));
290
+ if (clearCookies) {
291
+ clearSessionCookies(response, request);
292
+ }
293
+ return response;
294
+ }
295
+
296
+ /** PAY-2218: where a page's clearing redirect goes when the app has mounted the session-reset route. */
297
+ interface ResetConfig {
298
+ route: string;
299
+ secret: string;
300
+ }
301
+
302
+ /**
303
+ * A redirect that must also END the session. With the reset route configured, a page request is sent through it
304
+ * WITHOUT any Set-Cookie here: the route sees the flight headers this middleware cannot (Next strips them before
305
+ * middleware), answers a prefetch 204 + no-store, and clears on the real navigation. `/api/` requests and apps
306
+ * without the route keep the direct clear exactly as before.
307
+ */
308
+ async function clearingRedirect(request: NextRequest, location: string, reset?: ResetConfig): Promise<NextResponse> {
309
+ if (!reset || request.nextUrl.pathname.startsWith('/api/')) {
310
+ return redirectTo(request, location, true);
311
+ }
312
+ const target = new URL(location, request.url);
313
+ const params = normaliseResetParams({
314
+ callbackUrl: target.searchParams.get('callbackUrl'),
315
+ reason: target.searchParams.get('reason'),
316
+ error: target.searchParams.get('error'),
317
+ });
318
+ // Bound to THIS request's session cookie: the route clears only when its own request carries the same one.
319
+ const bound = boundSessionCookie((n) => request.cookies.get(n)?.value, getSessionCookieName(), getSecureSessionCookieName());
320
+ const response = NextResponse.redirect(new URL(await signedResetHref(reset.route, params, reset.secret, bound), request.url));
321
+ response.headers.set('cache-control', 'no-store');
322
+ return response;
323
+ }
324
+
325
+ /**
326
+ * Terminal response for a FAILED token refresh.
327
+ *
328
+ * For `/api/` requests a redirect is the wrong terminator: fetch() follows the 307,
329
+ * receives 200 + an HTML login page, and the caller parses garbage instead of
330
+ * branching on an error. Those callers get a 401 JSON body they can branch on;
331
+ * document requests keep the existing redirect exactly as before.
332
+ *
333
+ * Cookie-clearing is preserved on BOTH arms. That is load-bearing, not incidental:
334
+ * on a permanent failure (401/403) stale cookies must be cleared or the next request
335
+ * refreshes again and loops (see the CRITICAL note in handleRefresh). A JSON arm that
336
+ * skipped clearing would convert a redirect loop into a silent refresh loop.
337
+ *
338
+ * Scoped deliberately to handleRefresh's failure exits. It is NOT folded into
339
+ * redirectTo(), and NOT hoisted above the refresh decision: an `/api/` hatch placed
340
+ * higher would intercept 'refresh_needed' and disable middleware token refresh across
341
+ * the whole API surface (card 340024 C1).
342
+ */
343
+ async function refreshFailureResponse(
344
+ request: NextRequest,
345
+ location: string,
346
+ errorCode: string,
347
+ clearCookies = false,
348
+ reset?: ResetConfig
349
+ ): Promise<NextResponse> {
350
+ if (request.nextUrl.pathname.startsWith('/api/')) {
351
+ const response = NextResponse.json({ error: errorCode }, { status: 401 });
352
+ if (clearCookies) {
353
+ clearSessionCookies(response, request);
354
+ }
355
+ return response;
356
+ }
357
+ return clearCookies ? clearingRedirect(request, location, reset) : redirectTo(request, location);
358
+ }
359
+
360
+ /**
361
+ * PAY-2147: the terminal answer when the middleware CANNOT TELL whether the session is good
362
+ * (circuit breaker open, a half-open breaker refusing a refresh attempt, or the viability read failed).
363
+ *
364
+ * "I could not check" is not a verdict on the session, so this NEVER touches a cookie: a transient blip
365
+ * in the IdP / viability path used to wipe the session of every user who made any request while the
366
+ * breaker was open. Cookies are cleared only on a POSITIVE signal (force_invalidated, stale_session,
367
+ * pre_2fa_token_expired, refresh 401/403, explicit sign-out) - never from here.
368
+ *
369
+ * - /api/* -> 503 JSON a caller can branch on (a redirect would be followed by fetch() into a 200 HTML page).
370
+ * - pages -> /service-unavailable (public + allowDuringCircuitBreakerOpen, so it cannot loop), carrying the
371
+ * requested path as `callbackUrl` so "Try again" returns the user to it still signed in. The path goes
372
+ * through safeCallbackPath (URL-parser origin check): an off-origin or auth-surface value is dropped.
373
+ */
374
+ function serviceUnavailableResponse(request: NextRequest, check: UnavailableCheck = 'viability', unavailablePage?: string): NextResponse {
375
+ const { pathname, search } = request.nextUrl;
376
+ if (pathname.startsWith('/api/')) {
377
+ // PAY-2339 P1/P12: one fixed machine-readable shape for every "could not check" answer. No protected data.
378
+ return NextResponse.json({ error: 'ServiceUnavailable', unavailable: true, check }, { status: 503 });
379
+ }
380
+ const callback = safeCallbackPath(pathname + search);
381
+ // PAY-2339 (BAPert 74640, P13): a HELD revocation answer needs no page in the consumer. Unless the app names its own
382
+ // page (`unavailablePage`), the middleware answers inline: a minimal 503 HTML, a "try again" link to the safe
383
+ // callback, and no Set-Cookie (nothing here touches the session). No redirect, so no 404 is possible.
384
+ // `unavailablePage` is accepted only as a PLAIN path ([A-Za-z0-9_-/]; no query, fragment, %-encoding, dot segment or
385
+ // scheme), so the self-check below compares like with like and a mis-set value falls back to the inline page (Scout 74668).
386
+ // PAY-2345: the viability-FAILURE arm (500 / timeout / breaker) takes the same path, so an app with no page of its own
387
+ // gets the inline 503 instead of a 404 during an outage. The two arms differ only in `check` (JSON body for /api/*). If the
388
+ // request is already FOR the named page (an app that did not register it as public), answer inline: redirecting to
389
+ // it again would loop with a longer callbackUrl each time (QAPert 74651).
390
+ const named = unavailablePage && /^\/[A-Za-z0-9_\-\/]*$/.test(unavailablePage) && !unavailablePage.includes('//') ? unavailablePage : null;
391
+ if (!named || pathname.replace(/\/+$/, '') === named.replace(/\/+$/, '')) {
392
+ const href = callback.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
393
+ return new NextResponse(
394
+ '<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Temporarily unavailable</title>' +
395
+ '<main role="status" style="font-family:system-ui,sans-serif;max-width:28rem;margin:20vh auto;padding:0 1rem;text-align:center">' +
396
+ '<h1 style="font-size:1.25rem">Temporarily unavailable</h1>' +
397
+ '<p>' + (check === 'revocation' ? 'We could not check your session just now. You are still signed in.' : 'We could not reach the sign-in service just now. Please try again.') + '</p>' +
398
+ '<p><a href="' + href + '">Try again</a></p></main>',
399
+ { status: 503, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'retry-after': '30' } },
400
+ );
401
+ }
402
+ const target = new URL(named || '/service-unavailable', request.url);
403
+ if (callback !== '/') target.searchParams.set('callbackUrl', callback);
404
+ return NextResponse.redirect(target);
405
+ }
406
+
407
+ // =============================================================================
408
+ // AUTH DECISION ENGINE
409
+ // =============================================================================
410
+
411
+ /**
412
+ * Pure function that determines what action to take based on auth state.
413
+ * No side effects - just examines context and returns a decision.
414
+ */
415
+ function makeAuthDecision(ctx: AuthContext): AuthAction {
416
+ const { pathname, isPublicRoute, isLoginPage: onLoginPage, searchParams, sessionPointer, sessionStatus, circuitBreakerOpen } = ctx;
417
+ const safeCallback = getSafeCallbackUrl(pathname);
418
+
419
+ // --- PAY-884: a provisional (pre-2FA) session whose token has expired can never finish 2FA ---
420
+ // The IdP issues no refresh token until 2FA completes, and it validates token lifetime, so
421
+ // once the provisional access token expires neither refresh nor code verification can
422
+ // succeed. Sending the user to the verify-code page (or to error=SessionExpired, which the
423
+ // login page deliberately shows as NO message) strands them mid-2FA with no explanation.
424
+ // Say what happened instead, and clear the dead session so the next sign-in starts clean.
425
+ // Scoped to the verify-code page and protected pages: /api/ keeps its 401-JSON carve-out
426
+ // (307290), the login page stays reachable, and public pages are left to the existing arms.
427
+ if (
428
+ isExpiredProvisionalSession(sessionPointer, sessionStatus) &&
429
+ !pathname.startsWith('/api/') &&
430
+ !onLoginPage &&
431
+ (pathname === VERIFY_CODE_PAGE || !isPublicRoute)
432
+ ) {
433
+ const callback = pathname === VERIFY_CODE_PAGE ? getSafeCallbackUrl(pathname, searchParams) : safeCallback;
434
+ return {
435
+ type: 'redirect',
436
+ location: `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(callback)}&error=${TWO_FACTOR_TIMEOUT_ERROR}`,
437
+ reason: 'pre_2fa_token_expired',
438
+ clearCookies: true,
439
+ };
440
+ }
441
+
442
+ // --- Public routes: Allow, but enforce 2FA for authenticated users ---
443
+ if (isPublicRoute && !onLoginPage) {
444
+ if (needsTwoFactorRedirect(pathname, sessionPointer, sessionStatus)) {
445
+ return redirect2FA(safeCallback);
446
+ }
447
+ return { type: 'allow' };
448
+ }
449
+
450
+ // --- Circuit breaker open: Service degraded ---
451
+ // PAY-2147: the breaker opening says the IdP/viability path is unreachable, NOT that the session is bad.
452
+ // Answer "service unavailable" (503 for /api, /service-unavailable for pages) and leave every cookie alone.
453
+ if (circuitBreakerOpen) {
454
+ if (onLoginPage) return { type: 'allow' };
455
+ return { type: 'service_error', reason: 'circuit_breaker_open' };
456
+ }
457
+
458
+ // --- No session cookie: Need to login ---
459
+ if (!sessionPointer.exists) {
460
+ if (onLoginPage) return { type: 'allow' };
461
+ if (pathname === '/') {
462
+ const unauthUrl = process.env.UNAUTHENTICATED_REDIRECT_URL || '/account-auth/login';
463
+ return { type: 'redirect', location: unauthUrl, reason: 'unauthenticated_root' };
464
+ }
465
+ if (pathname.startsWith('/api/')) {
466
+ return { type: 'allow' }; // Let API return proper 401 JSON
467
+ }
468
+ return {
469
+ type: 'redirect',
470
+ location: `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}`,
471
+ reason: 'no_session'
472
+ };
473
+ }
474
+
475
+ // --- Service error: Can't verify session ---
476
+ if (sessionStatus.exists === null || sessionStatus.forceInvalid === null) {
477
+ if (onLoginPage) return { type: 'allow' }; // the login page must stay reachable while the check is down
478
+ return { type: 'service_error', reason: 'viability_check_failed' };
479
+ }
480
+
481
+ // --- PAY-2339: the revocation backend could not answer and this app HOLDS (the default) ---
482
+ // Not a verdict on the session: no cookie is touched, nothing protected is served, and the answer says why in a
483
+ // fixed machine-readable form. The login page stays reachable.
484
+ if (sessionStatus.unavailable) {
485
+ if (onLoginPage) return { type: 'allow' };
486
+ return { type: 'service_error', reason: 'revocation_unknown', check: 'revocation' };
487
+ }
488
+
489
+ // --- Session force-invalidated (admin action, password change, etc.) ---
490
+ if (sessionStatus.forceInvalid) {
491
+ if (onLoginPage) return { type: 'allow' };
492
+ if (pathname.startsWith('/api/')) return { type: 'allow' };
493
+ return {
494
+ type: 'redirect',
495
+ location: `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&reason=invalidated`,
496
+ reason: 'force_invalidated',
497
+ clearCookies: true
498
+ };
499
+ }
500
+
501
+ // --- Stale session (cookie exists but Redis entry gone) ---
502
+ if (!sessionStatus.exists) {
503
+ if (onLoginPage) return { type: 'allow' };
504
+ if (pathname.startsWith('/api/')) return { type: 'allow' };
505
+ return {
506
+ type: 'redirect',
507
+ location: `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&reason=stale`,
508
+ reason: 'stale_session',
509
+ clearCookies: true
510
+ };
511
+ }
512
+
513
+ // --- 2FA required but not completed ---
514
+ if (needsTwoFactorRedirect(pathname, sessionPointer, sessionStatus)) {
515
+ if (onLoginPage) {
516
+ const cb = getSafeCallbackUrl(pathname, searchParams);
517
+ return { type: 'redirect', location: `${VERIFY_CODE_PAGE}?callbackUrl=${encodeURIComponent(cb)}`, reason: '2fa_from_login' };
518
+ }
519
+ return redirect2FA(safeCallback);
520
+ }
521
+
522
+ // --- Token expired: Try refresh ---
523
+ if (sessionPointer.expired) {
524
+ if (onLoginPage) return { type: 'allow' };
525
+ if (sessionPointer.hasRefreshToken) {
526
+ return { type: 'refresh_needed' };
527
+ }
528
+ // 307290: this is the TERMINAL arm of the expired branch - refresh is not
529
+ // possible here, so the only outcomes are "redirect" or "allow". Every other
530
+ // terminal branch in this function (:242 no_session, :259 force_invalidated,
531
+ // :271 stale_session, :318 2FA-incomplete) already exempts /api/ so the route
532
+ // can answer with a real 401 JSON; this one did not, and redirecting an API
533
+ // request to an HTML login page makes the failure INVISIBLE - fetch() follows
534
+ // the 307, gets 200 HTML, resolves successfully and parses garbage.
535
+ // MUST stay BELOW the hasRefreshToken check above: placed any higher it would
536
+ // intercept 'refresh_needed' and disable middleware token refresh for the
537
+ // entire API surface.
538
+ if (pathname.startsWith('/api/')) return { type: 'allow' }; // Let API return proper 401 JSON
539
+ return {
540
+ type: 'redirect',
541
+ location: `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&error=SessionExpired`,
542
+ reason: 'token_expired_no_refresh'
543
+ };
544
+ }
545
+
546
+ // --- Authenticated user on login page: Send to their destination ---
547
+ if (onLoginPage) {
548
+ const dest = getSafeCallbackUrl(pathname, searchParams);
549
+ return { type: 'redirect', location: dest, reason: 'already_authenticated' };
550
+ }
551
+
552
+ // --- All checks passed ---
553
+ return { type: 'allow' };
554
+ }
555
+
556
+ /** Login error code for a pre-2FA session that expired before the code was verified (PAY-884). */
557
+ export const TWO_FACTOR_TIMEOUT_ERROR = 'TwoFactorTimeout';
558
+
559
+ /**
560
+ * PAY-884: 2FA outstanding, access token expired, and no refresh token to renew it.
561
+ * Only a viability answer that positively says the session exists qualifies (null = check failed).
562
+ */
563
+ function isExpiredProvisionalSession(session: SessionPointer, status: SessionStatus): boolean {
564
+ return (
565
+ session.exists &&
566
+ status.exists === true &&
567
+ status.forceInvalid === false &&
568
+ status.requires2FA &&
569
+ !status.twoFactorComplete &&
570
+ session.expired === true &&
571
+ !session.hasRefreshToken
572
+ );
573
+ }
574
+
575
+ /** Check if user needs 2FA redirect */
576
+ function needsTwoFactorRedirect(pathname: string, session: SessionPointer, status: SessionStatus): boolean {
577
+ if (!session.exists || !status.exists) return false;
578
+ if (!status.requires2FA || status.twoFactorComplete) return false;
579
+ if (pathname === VERIFY_CODE_PAGE) return false;
580
+ if (should2FABypass(pathname)) return false;
581
+ if (pathname.startsWith('/api/')) return false; // APIs return JSON errors
582
+ return true;
583
+ }
584
+
585
+ function redirect2FA(callbackUrl: string): AuthAction {
586
+ return {
587
+ type: 'redirect',
588
+ location: `${VERIFY_CODE_PAGE}?callbackUrl=${encodeURIComponent(callbackUrl)}`,
589
+ reason: '2fa_required'
590
+ };
591
+ }
592
+
593
+ // =============================================================================
594
+ // MIDDLEWARE FACTORY
595
+ // =============================================================================
596
+
597
+ /** PAY-2218: the stripped-flight-headers warning is logged once per process, not once per request. */
598
+ let flightHeadersStrippedWarned = false;
599
+
600
+ export function createMvpMiddleware(options: MvpMiddlewareOptions = {}) {
601
+ // Resolve options with defaults
602
+ const cb = options.circuitBreaker || defaultCircuitBreaker;
603
+ const log = options.logger || defaultLogger;
604
+ const viabilityEndpoint = options.viabilityEndpoint || '/api/session/viability';
605
+ const refreshEndpoint = options.refreshEndpoint || '/api/auth/refresh';
606
+ const bypassPaths = options.bypassPaths || [];
607
+ const rbacExemptPaths = options.rbacExemptPaths || [];
608
+ // PAY-2339: the default is 'hold' (Jon 2026-10-13, option C): an unknown revocation answer does not sign the member
609
+ // out. PAY-1882 step 4 had 'closed' (Jon's PAY-1880 option A, BAPert 66316); it is now opt-in by name.
610
+ const revocationFailMode = options.revocationFailMode || 'hold';
611
+ // PAY-2302 cut 1c: strictly `=== true`, so a truthy string in a config file cannot switch revocation off.
612
+ const noRevocationSource = options.noRevocationSource === true;
613
+ if (noRevocationSource) {
614
+ log.warn(
615
+ '[next-mvp] noRevocationSource is ON: sessions of this app CANNOT be revoked server-side (no vibe client / HMAC key). ' +
616
+ "revocationStatus 'not_configured' is treated as viable; 'unknown' and 'revoked' still sign the user out. " +
617
+ 'Remove this option once the environment has a vibe client id and HMAC key.'
618
+ );
619
+ }
620
+ const sessionResetRoute =
621
+ options.sessionResetRoute === true ? DEFAULT_SESSION_RESET_ROUTE
622
+ : typeof options.sessionResetRoute === 'string' && options.sessionResetRoute ? options.sessionResetRoute
623
+ : undefined;
624
+ let resetSecretMissingLogged = false;
625
+ const resolveReset = (): ResetConfig | undefined => {
626
+ if (!sessionResetRoute) return undefined;
627
+ const secret = resetSecret();
628
+ if (secret) return { route: sessionResetRoute, secret };
629
+ if (!resetSecretMissingLogged) {
630
+ resetSecretMissingLogged = true;
631
+ log.error('sessionResetRoute is set but no BETTER_AUTH_SECRET/NEXTAUTH_SECRET is available to sign the hand-off; clearing in middleware instead', { sessionResetRoute });
632
+ }
633
+ return undefined;
634
+ };
635
+
636
+ return async function middleware(request: NextRequest): Promise<NextResponse> {
637
+ const { pathname, searchParams } = request.nextUrl;
638
+
639
+ // =========================================================================
640
+ // CRITICAL: Auth routes MUST bypass middleware
641
+ // =========================================================================
642
+ // These routes are called internally by middleware itself (viability check,
643
+ // token refresh). Without this bypass, we get infinite loops or 403s.
644
+ // This has caused production outages. DO NOT REMOVE.
645
+ // =========================================================================
646
+ if (pathname.startsWith('/api/auth/') || pathname.startsWith('/api/session/')) {
647
+ return NextResponse.next();
648
+ }
649
+
650
+ // Check custom bypass paths
651
+ for (const bypassPath of bypassPaths) {
652
+ if (pathname.startsWith(bypassPath)) {
653
+ return NextResponse.next();
654
+ }
655
+ }
656
+
657
+ // PAY-2218: a router request (it carries `next-url`) that arrives WITHOUT `rsc` had its flight headers stripped
658
+ // by Next before middleware ran, so isPrefetchRequest() cannot see a router prefetch on this app. Say so once.
659
+ if (!sessionResetRoute && !flightHeadersStrippedWarned && request.headers.has('next-url') && !request.headers.has('rsc')) {
660
+ flightHeadersStrippedWarned = true;
661
+ log.warn('Next strips router prefetch headers before middleware on this app, so a session-ending redirect can fire on a prefetch. Mount @payez/next-mvp/routes/auth/session-reset and set sessionResetRoute (PAY-2218).', { pathname });
662
+ }
663
+
664
+ const isPublic = isUnauthenticatedRoute(pathname);
665
+ const isLogin = isLoginPage(pathname);
666
+
667
+ // --- Optimization: Skip viability check for unauthenticated public browsing ---
668
+ if (isPublic && !isLogin) {
669
+ const hasCookie = request.cookies.get(getSessionCookieName())?.value ||
670
+ request.cookies.get(getSecureSessionCookieName())?.value;
671
+ if (!hasCookie) {
672
+ return NextResponse.next();
673
+ }
674
+ // Has cookie - continue to viability check for 2FA enforcement
675
+ }
676
+
677
+ // --- Fetch session viability from internal API ---
678
+ const { sessionStatus, sessionPointer } = await checkViability(
679
+ request,
680
+ viabilityEndpoint,
681
+ log,
682
+ revocationFailMode,
683
+ noRevocationSource
684
+ );
685
+
686
+ // --- Stale session (cookie exists but session doesn't): OBSERVE ONLY, DO NOT DECIDE ---
687
+ //
688
+ // *** CARD 349607. THIS BLOCK USED TO `return` HERE, AND THAT RETURN WAS A BYPASS. ***
689
+ //
690
+ // makeAuthDecision() below already handles this exact condition at its
691
+ // `if (!sessionStatus.exists)` branch, and it handles it with TWO guard
692
+ // clauses that this early return did not have:
693
+ //
694
+ // if (onLoginPage) return { type: 'allow' }; // no redirect loop
695
+ // if (pathname.startsWith('/api/'))return { type: 'allow' }; // no HTML 307 at fetch()
696
+ //
697
+ // Returning here skipped BOTH. The `/api/` case is the reported harm: a
698
+ // stale-session XHR was answered with a 307 to an HTML login page, so the
699
+ // failure is invisible to `fetch()` - the caller sees an opaque redirect
700
+ // instead of an auth error, which is the same "correct logic preempted by
701
+ // an earlier return" shape recorded on 307290.
702
+ //
703
+ // This was NOT a missing feature one level down - the decision engine's
704
+ // branch is a strict superset of the behaviour this block implemented.
705
+ // The early return was a DUPLICATE that had drifted from the original.
706
+ // Adding a single `/api/` exemption here would have re-created the
707
+ // duplication (and left the onLoginPage divergence in place), so the
708
+ // duplicate is removed instead and the engine is left as the ONE decider.
709
+ //
710
+ // The warning is deliberately KEPT: it is the only operational signal that
711
+ // stale sessions are occurring, and it costs nothing to log before
712
+ // delegating. Do not re-introduce a `return` in this block.
713
+ if (!isPublic && sessionStatus.exists === false && sessionPointer.sessionTokenId) {
714
+ // PAY-1505: the KEY is `sessionTokenId`, not `sessionToken`. The value has
715
+ // always been a tokenLogId fingerprint (viability.ts builds it as
716
+ // `tokenLogId(sessionToken)`), but labelling it `sessionToken` told any
717
+ // operator reading this log that the line carried the token itself. The
718
+ // credential-in-logs source guard reads the key for exactly that reason:
719
+ // a field named `sessionToken` must hold a safe value, and proving that
720
+ // across an API boundary is not possible from the name alone.
721
+ log.warn('Stale session detected (delegating to decision engine)', {
722
+ sessionTokenId: sessionPointer.sessionTokenId,
723
+ pathname
724
+ });
725
+ }
726
+
727
+ // --- Run decision engine ---
728
+ const decision = makeAuthDecision({
729
+ pathname,
730
+ isPublicRoute: isPublic,
731
+ isLoginPage: isLogin,
732
+ searchParams,
733
+ sessionPointer,
734
+ sessionStatus,
735
+ circuitBreakerOpen: cb.isOpen(),
736
+ });
737
+
738
+ // --- Execute decision ---
739
+ return executeDecision(request, decision, pathname, sessionPointer, sessionStatus, {
740
+ circuitBreaker: cb,
741
+ logger: log,
742
+ refreshEndpoint,
743
+ rbacExemptPaths,
744
+ reset: resolveReset(),
745
+ unavailablePage: options.unavailablePage,
746
+ onRefreshSuccess: options.onRefreshSuccess,
747
+ onRefreshFailure: options.onRefreshFailure,
748
+ });
749
+ };
750
+ }
751
+
752
+ /**
753
+ * The revocation decision, as a pure function (PAY-1882 step 4, NightHawk 66392).
754
+ *
755
+ * Separate + exported so the B1 defect is testable with a REAL mutant: the bug was
756
+ * that `data.forceInvalid === true` was read BEFORE revocationFailMode, so a
757
+ * producer that folds 'unknown' into forceInvalid made the 'open' lever dead.
758
+ *
759
+ * RULE: when the producer supplies `revocationStatus`, it is AUTHORITATIVE - the
760
+ * decision is made from it plus failMode, and the folded booleans are ignored for
761
+ * 'unknown'/'not_configured'. The booleans are read ONLY when revocationStatus is
762
+ * absent (idealresume.online's older boolean-only route).
763
+ */
764
+ export type RevocationOutcome = 'viable' | 'invalid' | 'hold';
765
+
766
+ /**
767
+ * PAY-2339: the three-way form of the revocation decision. `decideForceInvalid` below is kept as its boolean view.
768
+ *
769
+ * 'invalid' - sign the member out (cookies cleared, login page)
770
+ * 'hold' - the backend could not answer and this app holds: no sign-out, answer "temporarily unavailable"
771
+ * 'viable' - carry on
772
+ */
773
+ export function decideRevocationOutcome(
774
+ data: { revocationStatus?: unknown; forceInvalid?: unknown; revoked?: unknown },
775
+ revocationFailMode: 'hold' | 'closed' | 'open',
776
+ noRevocationSource: boolean = false,
777
+ ): RevocationOutcome {
778
+ const status = data.revocationStatus;
779
+ if (typeof status === 'string') {
780
+ if (status === 'revoked') return 'invalid';
781
+ if (status === 'active') return 'viable';
782
+ // PAY-2302 cut 1c: an app that declared it has no revocation source. ONLY the exact 'not_configured' string.
783
+ if (status === 'not_configured' && noRevocationSource === true) return 'viable';
784
+ // PAY-2339: 'not_configured' is a configuration fault, not an outage. Under the default it stays signed out
785
+ // exactly as before; only 'open' relaxes it (a Vibe-less app).
786
+ if (status === 'not_configured') return revocationFailMode === 'open' ? 'viable' : 'invalid';
787
+ // S1 (NightHawk 66409): ANY other string - 'unknown', a typo like 'Revoked', 'error', or '' - is treated as
788
+ // 'unknown' (the fail mode applies), NEVER as 'active'. Only an explicit 'active' is viable.
789
+ if (revocationFailMode === 'open') return 'viable';
790
+ return revocationFailMode === 'closed' ? 'invalid' : 'hold';
791
+ }
792
+ // No revocationStatus: an older boolean producer. Absence of both stays viable.
793
+ return data.forceInvalid === true || data.revoked === true ? 'invalid' : 'viable';
794
+ }
795
+
796
+ export function decideForceInvalid(
797
+ data: { revocationStatus?: unknown; forceInvalid?: unknown; revoked?: unknown },
798
+ revocationFailMode: 'hold' | 'closed' | 'open',
799
+ noRevocationSource: boolean = false,
800
+ ): boolean {
801
+ return decideRevocationOutcome(data, revocationFailMode, noRevocationSource) === 'invalid';
802
+ }
803
+
804
+ /**
805
+ * PAY-2147: does the request carry a session cookie? Used ONLY when the viability read itself FAILED.
806
+ * Before this, a failed read left sessionPointer.exists=false, so the decision engine took its `no_session` arm
807
+ * (pages -> the LOGIN page, /api -> straight through) and its `exists === null` -> service_error arm below it was
808
+ * unreachable dead code: a user holding a perfectly good session was told to sign in during an outage. With a
809
+ * cookie present and the check down we do not know the session is bad, so the engine must say "service
810
+ * unavailable", not "sign in". No cookie -> unchanged (no_session).
811
+ */
812
+ function hasSessionCookie(request: NextRequest): boolean {
813
+ return !!(request.cookies.get(getSessionCookieName())?.value || request.cookies.get(getSecureSessionCookieName())?.value);
814
+ }
815
+
816
+ /** Check session viability via internal API */
817
+ async function checkViability(
818
+ request: NextRequest,
819
+ endpoint: string,
820
+ log: MiddlewareLogger,
821
+ revocationFailMode: 'hold' | 'closed' | 'open',
822
+ noRevocationSource: boolean = false
823
+ ): Promise<{
824
+ sessionStatus: SessionStatus;
825
+ sessionPointer: SessionPointer;
826
+ }> {
827
+ const sessionStatus: SessionStatus = {
828
+ exists: false,
829
+ forceInvalid: false,
830
+ requires2FA: false,
831
+ twoFactorComplete: false,
832
+ };
833
+ const sessionPointer: SessionPointer = {
834
+ exists: false,
835
+ sessionTokenId: undefined,
836
+ expired: false,
837
+ hasRefreshToken: false,
838
+ roles: [],
839
+ clientId: '',
840
+ };
841
+
842
+ try {
843
+ const baseUrl = getInternalApiUrl(request);
844
+ const response = await fetch(new URL(endpoint, baseUrl), {
845
+ method: 'GET',
846
+ headers: {
847
+ 'Accept': 'application/json',
848
+ 'Cache-Control': 'no-store',
849
+ 'Cookie': request.headers.get('cookie') || ''
850
+ },
851
+ credentials: 'include',
852
+ signal: AbortSignal.timeout(5000),
853
+ });
854
+
855
+ if (response.ok) {
856
+ const data = await response.json();
857
+ // Support both response formats (viability and refresh-viability)
858
+ sessionStatus.exists = data.authenticated ?? (!!data.sessionTokenId && data.reason !== 'session_not_found');
859
+ sessionStatus.requires2FA = data.requires2FA || false;
860
+ sessionStatus.twoFactorComplete = data.twoFactorComplete || false;
861
+ // --- Session revocation (PAY-1882 Step 4) ---
862
+ // Two producer spellings are honoured, because both exist on the wire:
863
+ // - boolean `forceInvalid` / `revoked` (idealresume.online's route today)
864
+ // - the three-state `revocationStatus` the package's own viability
865
+ // handlers now emit (PAY-1882 step 4).
866
+ //
867
+ // RULING (Jon, via BAPert 66316): 'revoked' ALWAYS fails closed. 'unknown'
868
+ // (a FAILED Vibe read) fails closed by default and is the ONLY state that
869
+ // the per-app `revocationFailMode` option can relax to viable.
870
+ //
871
+ // Four producer states (BAPert 66320), with the fail-closed ruling for
872
+ // 'not_configured' added by BAPert 66356:
873
+ // 'active' - Vibe read OK, not revoked -> viable
874
+ // 'revoked' - Vibe says revoked -> force-invalid, ALWAYS
875
+ // 'unknown' - read FAILED -> force-invalid if 'closed'
876
+ // 'not_configured' - no revocation source -> force-invalid if 'closed'
877
+ //
878
+ // WHY 'not_configured' FAILS CLOSED WHEN 'closed': both live consumers of
879
+ // this middleware (portal.keelauth.com / keelauth-portal, idealvibe.online)
880
+ // DO set IDP_URL + VIBE_CLIENT_ID + VIBE_HMAC_KEY (BAPert 66356, measured in
881
+ // their committed manifests + the graft pipeline's own secret check), so for
882
+ // them 'not_configured' can only mean a MISCONFIGURATION - revocation
883
+ // silently switched off is exactly the PAY-1880 fail-open bug. An app with
884
+ // no revocation source that genuinely wants availability sets
885
+ // revocationFailMode 'open'.
886
+ //
887
+ // A MISSING revocationStatus (an older producer) keeps today's behaviour,
888
+ // i.e. NOT force-invalid: absence must never sign out a population that
889
+ // never opted into the check.
890
+ //
891
+ // *** revocationStatus IS AUTHORITATIVE WHEN PRESENT (NightHawk 66392). ***
892
+ // The package's own producers emit `forceInvalid: revocationStatus ===
893
+ // 'revoked' || revocationStatus === 'unknown'`. If the boolean were read
894
+ // FIRST, `forceInvalid: true` would short-circuit before revocationFailMode
895
+ // was consulted, so `'open'` could never keep an 'unknown' session viable -
896
+ // the opt-out lever would be DEAD. So: when a producer supplies a
897
+ // revocations status, decide from it + failMode and ignore the folded
898
+ // booleans for unknown/not_configured. The booleans are read ONLY when
899
+ // revocationStatus is ABSENT (idealresume.online's older boolean route).
900
+ const outcome = decideRevocationOutcome(data, revocationFailMode, noRevocationSource);
901
+ sessionStatus.forceInvalid = outcome === 'invalid';
902
+ if (outcome === 'hold') {
903
+ sessionStatus.unavailable = true;
904
+ // PAY-2339 P11: one countable line per held request, naming the check. No session value, no address.
905
+ log.warn('revocation_unavailable', { check: 'revocation' });
906
+ }
907
+ // PAY-2216: a STALE session - the cookie resolved to a token, but no store holds the session - used to set
908
+ // pointer.exists=false, so makeAuthDecision took no_session (login, cookie NOT cleared) and its stale_session
909
+ // arm (clearCookies) was unreachable: a dead cookie lingered and re-ran viability on every request.
910
+ // Stale is a POSITIVE signal and needs ALL of: this 2xx read, exists === false, a sessionTokenId (every
911
+ // producer sets it only when a cookie resolved to a token: routes/auth/viability.ts, api-handlers/session/
912
+ // viability.ts, refresh-viability.ts 'session_not_found'), and a session cookie on THIS request (plain or
913
+ // __Secure-). A FAILED read never gets here (exists=null -> service_error, PAY-2147); a store error throws
914
+ // inside every producer and answers 5xx or 'unknown' WITHOUT a sessionTokenId; no cookie -> no_session.
915
+ const stale = sessionStatus.exists === false && !!data.sessionTokenId && hasSessionCookie(request);
916
+ sessionPointer.exists = stale ? true : (sessionStatus.exists ?? false);
917
+ sessionPointer.sessionTokenId = data.sessionTokenId ?? null;
918
+ sessionPointer.expired = data.accessTokenExpired || false;
919
+ sessionPointer.hasRefreshToken = data.hasRefreshToken ?? data.canRefresh ?? false;
920
+ sessionPointer.roles = data.roles || [];
921
+ sessionPointer.clientId = data.clientId || '';
922
+ } else {
923
+ log.error('Viability check failed', { status: response.status });
924
+ sessionStatus.exists = null;
925
+ sessionStatus.forceInvalid = null;
926
+ sessionPointer.exists = hasSessionCookie(request); // PAY-2147: see hasSessionCookie
927
+ }
928
+ } catch (error) {
929
+ log.error('Viability check error', { error: error instanceof Error ? error.message : String(error) });
930
+ sessionStatus.exists = null;
931
+ sessionStatus.forceInvalid = null;
932
+ sessionPointer.exists = hasSessionCookie(request); // PAY-2147: see hasSessionCookie
933
+ }
934
+
935
+ return { sessionStatus, sessionPointer };
936
+ }
937
+
938
+ /** Options passed to executeDecision */
939
+ interface ExecuteOptions {
940
+ circuitBreaker: CircuitBreakerProvider;
941
+ logger: MiddlewareLogger;
942
+ refreshEndpoint: string;
943
+ rbacExemptPaths: string[];
944
+ /** PAY-2218: set when the app mounted the session-reset route AND a signing secret exists. */
945
+ reset?: ResetConfig;
946
+ /** PAY-2339: the app's named hold page; absent = the inline 503 HTML. */
947
+ unavailablePage?: string;
948
+ onRefreshSuccess?: () => void;
949
+ onRefreshFailure?: (status: number, isNetworkError: boolean) => void;
950
+ }
951
+
952
+ /** Execute the auth decision */
953
+ async function executeDecision(
954
+ request: NextRequest,
955
+ decision: AuthAction,
956
+ pathname: string,
957
+ sessionPointer: SessionPointer,
958
+ sessionStatus: SessionStatus,
959
+ opts: ExecuteOptions
960
+ ): Promise<NextResponse> {
961
+ const safeCallback = getSafeCallbackUrl(pathname);
962
+
963
+ switch (decision.type) {
964
+ case 'allow':
965
+ return handleAllow(request, pathname, sessionPointer, sessionStatus, opts.rbacExemptPaths);
966
+
967
+ case 'redirect':
968
+ return decision.clearCookies
969
+ ? clearingRedirect(request, decision.location, opts.reset)
970
+ : redirectTo(request, decision.location);
971
+
972
+ case 'service_error':
973
+ return serviceUnavailableResponse(request, decision.check ?? 'viability', opts.unavailablePage);
974
+
975
+ case 'refresh_needed':
976
+ // PAY-2136: a speculative request must not spend (rotate) a single-use refresh token or, on a 401/403,
977
+ // clear the session. Let it through; the real navigation refreshes.
978
+ if (isPrefetchRequest(request)) return NextResponse.next();
979
+ return handleRefresh(request, safeCallback, opts);
980
+ }
981
+ }
982
+
983
+ /** Paths that must never be RBAC-checked (they are RBAC redirect targets) */
984
+ const RBAC_EXEMPT_PATHS = ['/error', '/unauthorized', '/service-unavailable'];
985
+
986
+ /** Handle 'allow' decision - run RBAC if enabled */
987
+ async function handleAllow(
988
+ request: NextRequest,
989
+ pathname: string,
990
+ sessionPointer: SessionPointer,
991
+ sessionStatus: SessionStatus,
992
+ rbacExemptPaths: string[] = []
993
+ ): Promise<NextResponse> {
994
+ const isPublic = isUnauthenticatedRoute(pathname);
995
+
996
+ // PAY-2216: RBAC needs a session that viability CONFIRMED. A stale /api request (pointer.exists only says the
997
+ // cookie is present, status.exists is false) is allowed through so the route handler answers its own 401 - not an
998
+ // RBAC 401 here. TRUTHY, not `=== true` (NightHawk 71960): status.exists carries the producer's JSON value as-is,
999
+ // and a consumer route may answer authenticated: 1 / "true"; `=== true` skipped RBAC for those healthy sessions
1000
+ // (fail-OPEN). Truthy keeps 4.5.3's RBAC coverage exactly and still excludes stale (false) and a failed read (null).
1001
+ if (isRBACEnabled() && !isPublic && sessionPointer.exists && sessionStatus.exists) {
1002
+ // Skip RBAC for error/fallback pages (prevent redirect loops) and app-configured exempt paths
1003
+ if (RBAC_EXEMPT_PATHS.some(p => pathname.startsWith(p)) ||
1004
+ rbacExemptPaths.some(p => pathname.startsWith(p))) {
1005
+ return NextResponse.next();
1006
+ }
1007
+
1008
+ if (!sessionPointer.clientId) {
1009
+ console.error('[MIDDLEWARE] RBAC: No clientId — returning 401');
1010
+ if (pathname.startsWith('/api/')) {
1011
+ return NextResponse.json(
1012
+ { error: 'Unauthorized — missing clientId for RBAC' },
1013
+ { status: 401 }
1014
+ );
1015
+ }
1016
+ return NextResponse.redirect(new URL('/unauthorized', request.url));
1017
+ }
1018
+
1019
+ try {
1020
+ const result = await checkPagePermission(pathname, sessionPointer.roles, sessionPointer.clientId);
1021
+
1022
+ if (!result.allowed) {
1023
+ console.log('[MIDDLEWARE] RBAC denied:', { pathname, reason: result.reason });
1024
+
1025
+ // In development, fail open - RBAC API may not be fully configured
1026
+ if (process.env.NODE_ENV !== 'production') {
1027
+ console.warn('[MIDDLEWARE] RBAC: Allowing in development despite denial:', result.reason);
1028
+ return NextResponse.next();
1029
+ }
1030
+
1031
+ return NextResponse.redirect(new URL(result.redirect || '/unauthorized', request.url));
1032
+ }
1033
+
1034
+ if (result.requires_2fa && !sessionStatus.twoFactorComplete) {
1035
+ return NextResponse.redirect(
1036
+ new URL(`${VERIFY_CODE_PAGE}?callbackUrl=${encodeURIComponent(pathname)}`, request.url)
1037
+ );
1038
+ }
1039
+ } catch (error) {
1040
+ console.error('[MIDDLEWARE] RBAC error:', error);
1041
+
1042
+ // In development, fail open
1043
+ if (process.env.NODE_ENV !== 'production') {
1044
+ console.warn('[MIDDLEWARE] RBAC: Allowing in development despite error');
1045
+ return NextResponse.next();
1046
+ }
1047
+
1048
+ return NextResponse.redirect(new URL('/error?code=rbac_error', request.url));
1049
+ }
1050
+ }
1051
+
1052
+ return NextResponse.next();
1053
+ }
1054
+
1055
+ /** Handle token refresh */
1056
+ async function handleRefresh(
1057
+ request: NextRequest,
1058
+ safeCallback: string,
1059
+ opts: ExecuteOptions
1060
+ ): Promise<NextResponse> {
1061
+ const { circuitBreaker: cb, logger: log, refreshEndpoint, onRefreshSuccess, onRefreshFailure } = opts;
1062
+
1063
+ // Check if circuit breaker allows refresh attempt
1064
+ if (!cb.canAttemptRefresh()) {
1065
+ log.warn('Circuit breaker preventing refresh attempt');
1066
+ // PAY-2147: a breaker that merely declines to TRY a refresh has learned nothing about the session; wiping the
1067
+ // cookies here logged out every user on the first request after a blip (HALF_OPEN refuses all but one probe).
1068
+ return serviceUnavailableResponse(request, 'viability', opts.unavailablePage);
1069
+ }
1070
+
1071
+ try {
1072
+ const baseUrl = getInternalApiUrl(request);
1073
+ const response = await fetch(new URL(refreshEndpoint, baseUrl), {
1074
+ method: 'POST',
1075
+ headers: {
1076
+ 'Accept': 'application/json',
1077
+ 'Content-Type': 'application/json',
1078
+ 'Cookie': request.headers.get('cookie') || '',
1079
+ 'x-session-token': request.cookies.get(getSessionCookieName())?.value ||
1080
+ request.cookies.get(getSecureSessionCookieName())?.value || ''
1081
+ },
1082
+ credentials: 'include',
1083
+ signal: AbortSignal.timeout(5000),
1084
+ });
1085
+
1086
+ if (response.ok) {
1087
+ const data = await response.json();
1088
+ if (data.refreshed || data.reason === 'already_fresh') {
1089
+ cb.recordSuccess();
1090
+ onRefreshSuccess?.();
1091
+ log.info('Token refresh successful');
1092
+ return NextResponse.next();
1093
+ }
1094
+ }
1095
+
1096
+ // Refresh failed - check for permanent failures (401/403) vs transient (5xx)
1097
+ // CRITICAL: Always clear cookies on 401/403 to prevent redirect loops
1098
+ // CRITICAL: Only record circuit breaker failures for network errors, not HTTP errors
1099
+ // HTTP 500 means the server responded - it's reachable. Only network failures should trip the breaker.
1100
+ const isPermanentFailure = response.status === 401 || response.status === 403;
1101
+ const errorParam = isPermanentFailure ? 'SessionExpired' : 'RefreshError';
1102
+
1103
+ log.warn('Refresh failed', {
1104
+ status: response.status,
1105
+ isPermanentFailure,
1106
+ clearingCookies: isPermanentFailure
1107
+ });
1108
+
1109
+ onRefreshFailure?.(response.status, false);
1110
+
1111
+ return refreshFailureResponse(
1112
+ request,
1113
+ `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&error=${errorParam}`,
1114
+ errorParam,
1115
+ isPermanentFailure,
1116
+ opts.reset
1117
+ );
1118
+ } catch (error: any) {
1119
+ const isNetwork = cb.isNetworkError(error);
1120
+ log.error('Refresh error', {
1121
+ error: error instanceof Error ? error.message : String(error),
1122
+ isNetworkError: isNetwork
1123
+ });
1124
+
1125
+ // Only record circuit breaker failure for actual network errors
1126
+ if (isNetwork) {
1127
+ cb.recordFailure(error);
1128
+ }
1129
+
1130
+ onRefreshFailure?.(0, isNetwork);
1131
+
1132
+ // Network error - don't clear cookies, might be transient
1133
+ return refreshFailureResponse(
1134
+ request,
1135
+ `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&error=RefreshError`,
1136
+ 'RefreshError',
1137
+ false
1138
+ );
1139
+ }
1140
+ }
1141
+