@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.
- package/POST-INSTALL.md +408 -408
- package/README.md +859 -859
- package/dist/components/account/MobileNavDrawer.js +18 -18
- package/dist/components/account/UserAvatarMenu.js +1 -1
- package/dist/components/admin/AlertSettingsTab.js +40 -40
- package/dist/components/admin/LoggingSettingsTab.js +28 -28
- package/dist/lib/session-store.js +21 -21
- package/dist/lib/startup-init.js +19 -19
- package/dist/pages/login/page.js +28 -28
- package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
- package/dist/vibe/sessions.js +28 -3
- package/package.json +2 -2
- package/scripts/check-publish-eol.mjs +33 -15
- package/src/api/auth-handler.ts +550 -550
- package/src/api/index.ts +17 -17
- package/src/api-handlers/account/change-password.ts +170 -170
- package/src/api-handlers/account/masked-info.ts +46 -46
- package/src/api-handlers/account/profile.ts +99 -99
- package/src/api-handlers/account/recovery/forward.ts +117 -117
- package/src/api-handlers/account/recovery/initiate.ts +59 -59
- package/src/api-handlers/account/recovery/send-code.ts +21 -21
- package/src/api-handlers/account/recovery/verify-code.ts +20 -20
- package/src/api-handlers/account/reset-password.ts +87 -87
- package/src/api-handlers/account/send-code.ts +70 -70
- package/src/api-handlers/account/update-phone.ts +84 -84
- package/src/api-handlers/account/validate-password.ts +139 -139
- package/src/api-handlers/account/verify-email.ts +134 -134
- package/src/api-handlers/account/verify-sms.ts +134 -134
- package/src/api-handlers/admin/analytics.ts +452 -452
- package/src/api-handlers/admin/audit.ts +232 -232
- package/src/api-handlers/admin/index.ts +63 -63
- package/src/api-handlers/admin/redis-sessions.ts +259 -259
- package/src/api-handlers/admin/sessions.ts +327 -327
- package/src/api-handlers/admin/site-logs.ts +388 -388
- package/src/api-handlers/admin/stats.ts +260 -260
- package/src/api-handlers/admin/users.ts +251 -251
- package/src/api-handlers/admin/vibe-data.ts +333 -333
- package/src/api-handlers/anon/preferences.ts +123 -123
- package/src/api-handlers/auth/credential-login-email-code.ts +92 -92
- package/src/api-handlers/auth/credential-login.ts +948 -948
- package/src/api-handlers/auth/jwks.ts +31 -31
- package/src/api-handlers/auth/login.ts +252 -252
- package/src/api-handlers/auth/passkey-signin.ts +116 -116
- package/src/api-handlers/auth/refresh.ts +829 -829
- package/src/api-handlers/auth/signout.ts +201 -201
- package/src/api-handlers/auth/status.ts +23 -23
- package/src/api-handlers/auth/update-session.ts +144 -144
- package/src/api-handlers/auth/validate.ts +45 -45
- package/src/api-handlers/auth/verify-code.ts +116 -116
- package/src/api-handlers/mfa/backup-code-verify.ts +170 -170
- package/src/api-handlers/mfa/backup-codes.ts +42 -42
- package/src/api-handlers/mfa/methods.ts +47 -47
- package/src/api-handlers/mfa/totp-confirm.ts +43 -43
- package/src/api-handlers/mfa/totp-enroll.ts +43 -43
- package/src/api-handlers/mfa/totp-verify.ts +172 -172
- package/src/api-handlers/oauth/resume.ts +182 -182
- package/src/api-handlers/onboarding/send-email-code.ts +18 -18
- package/src/api-handlers/onboarding/set-password.ts +21 -21
- package/src/api-handlers/onboarding/verify-code.ts +20 -20
- package/src/api-handlers/onboarding/verify-welcome-email.ts +17 -17
- package/src/api-handlers/passkeys/list.ts +32 -32
- package/src/api-handlers/passkeys/manage.ts +55 -55
- package/src/api-handlers/passkeys/register-options.ts +43 -43
- package/src/api-handlers/passkeys/register-verify.ts +42 -42
- package/src/api-handlers/session/refresh-viability.ts +56 -56
- package/src/api-handlers/session/viability.ts +238 -238
- package/src/api-handlers/test/force-expire.ts +167 -167
- package/src/auth/auth-decision.ts +292 -292
- package/src/auth/better-auth.ts +1688 -1688
- package/src/auth/idp-oauth.ts +310 -310
- package/src/auth/password-reset-bridge.ts +98 -98
- package/src/auth/revocation-fields.ts +16 -16
- package/src/auth/route-config.ts +236 -236
- package/src/auth/safe-callback.ts +199 -199
- package/src/auth/session-cookie-clear.ts +30 -30
- package/src/auth/session-reset-link.ts +159 -159
- package/src/auth/session-response-sanitizer.ts +151 -151
- package/src/auth/types/auth-types.ts +555 -555
- package/src/auth/types/index.ts +7 -7
- package/src/auth/unauthenticated-routes.ts +2 -2
- package/src/auth/utils/idp-client.ts +364 -364
- package/src/auth/utils/index.ts +6 -6
- package/src/auth/utils/token-utils.ts +243 -243
- package/src/client/AuthContext.tsx +192 -192
- package/src/client/better-auth-client.ts +70 -70
- package/src/client/fetch-with-auth.ts +47 -47
- package/src/client/fetchWithSession.ts +21 -21
- package/src/client/index.ts +13 -13
- package/src/client/useAnonSession.ts +131 -131
- package/src/components/SessionSync.tsx +138 -138
- package/src/components/SignalRHealthCheck.tsx +131 -131
- package/src/components/account/MobileNavDrawer.tsx +305 -305
- package/src/components/account/UserAvatarMenu.tsx +277 -277
- package/src/components/account/index.ts +13 -13
- package/src/components/admin/AlertSettingsTab.tsx +728 -728
- package/src/components/admin/AnalyticsTab.tsx +703 -703
- package/src/components/admin/DataBrowserTab.tsx +508 -508
- package/src/components/admin/LoggingSettingsTab.tsx +665 -665
- package/src/components/admin/SessionsTab.tsx +421 -421
- package/src/components/admin/StatsTab.tsx +379 -379
- package/src/components/admin/VibeAdminContext.tsx +87 -87
- package/src/components/admin/VibeAdminLayout.tsx +187 -187
- package/src/components/admin/index.ts +59 -59
- package/src/components/auth/CredentialSignInForm.tsx +240 -240
- package/src/components/auth/FederatedAuthSection.tsx +129 -129
- package/src/components/auth/ModeAwareLoginPage.tsx +146 -146
- package/src/components/auth/ModeAwareSignupPage.tsx +200 -200
- package/src/components/auth/PasswordResetBridge.tsx +30 -30
- package/src/components/auth/TraditionalAuthSection.tsx +103 -103
- package/src/components/recovery/CompleteStep.tsx +36 -36
- package/src/components/recovery/InitiateRecoveryStep.tsx +68 -68
- package/src/components/recovery/RequestedStep.tsx +30 -30
- package/src/components/recovery/SelectMethodStep.tsx +73 -73
- package/src/components/recovery/SetPasswordStep.tsx +118 -118
- package/src/components/recovery/VerifyCodeStep.tsx +90 -90
- package/src/components/reserved/ReservedRecoveryWarning.tsx +160 -160
- package/src/components/reserved/ReservedStatusBox.tsx +118 -118
- package/src/components/ui/BetaBadge.tsx +58 -58
- package/src/components/ui/Footer.tsx +93 -93
- package/src/config/env.ts +55 -55
- package/src/config/logger.ts +62 -62
- package/src/config/logging-config.ts +82 -82
- package/src/config/unauthenticated-routes.ts +19 -19
- package/src/config/vibe-log-transport.ts +264 -264
- package/src/edge/internal-api-url.ts +65 -65
- package/src/edge/middleware.ts +42 -42
- package/src/hooks/useAuth.ts +117 -117
- package/src/hooks/useAuthSettings.ts +109 -109
- package/src/hooks/useAvailableProviders.ts +143 -143
- package/src/hooks/usePasswordValidation.ts +127 -127
- package/src/hooks/useProfile.ts +82 -82
- package/src/hooks/usePublicAuthSettings.ts +171 -171
- package/src/hooks/useSessionExpiration.ts +101 -101
- package/src/hooks/useViabilitySession.ts +337 -337
- package/src/index.ts +64 -64
- package/src/lib/anon-session.ts +213 -213
- package/src/lib/api-handler.ts +643 -643
- package/src/lib/app-slug.ts +228 -228
- package/src/lib/authorize-resume.ts +172 -172
- package/src/lib/bearer-fetch.ts +31 -31
- package/src/lib/client-key.ts +88 -88
- package/src/lib/code-step-copy.ts +83 -83
- package/src/lib/credential-sign-in-client.ts +101 -101
- package/src/lib/demo-mode.ts +18 -18
- package/src/lib/device-id.ts +67 -67
- package/src/lib/ensure-fresh-access-token.ts +509 -509
- package/src/lib/geolocation.ts +265 -265
- package/src/lib/idp-client-config.ts +1005 -1005
- package/src/lib/idp-fetch.ts +169 -169
- package/src/lib/idp-url-guard.ts +37 -37
- package/src/lib/internal-api.ts +171 -171
- package/src/lib/jwt-decode-client.ts +45 -45
- package/src/lib/jwt-decode.ts +82 -82
- package/src/lib/lock-window.ts +49 -49
- package/src/lib/login-error-message.ts +41 -41
- package/src/lib/never-in-response.ts +64 -64
- package/src/lib/nextauth-secret.ts +168 -168
- package/src/lib/onboarding-proxy.ts +57 -57
- package/src/lib/password-rules.ts +50 -50
- package/src/lib/rate-limit-service.ts +9 -9
- package/src/lib/redis.ts +105 -105
- package/src/lib/refresh-token-validator.ts +64 -64
- package/src/lib/roles.ts +177 -177
- package/src/lib/safe-error-response.ts +149 -149
- package/src/lib/secret-validation.ts +8 -8
- package/src/lib/session-cookie-shape.ts +185 -185
- package/src/lib/session-store.ts +1092 -1092
- package/src/lib/session.ts +34 -34
- package/src/lib/single-flight.ts +64 -64
- package/src/lib/site-logger.ts +245 -245
- package/src/lib/standardized-client-api.ts +901 -901
- package/src/lib/startup-init.ts +276 -276
- package/src/lib/test-aware-get-token.ts +41 -41
- package/src/lib/token-expiry.ts +40 -40
- package/src/lib/token-lifecycle.ts +546 -546
- package/src/lib/token-log-id.ts +69 -69
- package/src/lib/twofa-rejection.ts +91 -91
- package/src/lib/types/api-responses.ts +336 -336
- package/src/lib/user-agent-parser.ts +252 -252
- package/src/logging/api/admin-analytics.ts +51 -51
- package/src/logging/api/audit-log.ts +53 -53
- package/src/logging/components/AdminAnalyticsLayout.tsx +49 -49
- package/src/logging/components/AuditLogViewer.tsx +125 -125
- package/src/logging/components/ErrorMetricsCard.tsx +98 -98
- package/src/logging/components/HealthMetricsCard.tsx +70 -70
- package/src/logging/hooks/useAdminAnalytics.ts +22 -22
- package/src/logging/hooks/useAuditLog.ts +24 -24
- package/src/logging/hooks/useErrorMetrics.ts +40 -40
- package/src/logging/hooks/useHealthMetrics.ts +44 -44
- package/src/logging/index.ts +18 -18
- package/src/logging/types/analytics.ts +81 -81
- package/src/logging/types/audit.ts +31 -31
- package/src/logging/types/index.ts +3 -3
- package/src/middleware/create-middleware.ts +1141 -1141
- package/src/middleware/rbac-check.ts +279 -279
- package/src/middleware/twofa-presets.ts +224 -224
- package/src/models/DecodedAccessToken.ts +16 -16
- package/src/models/SessionModel.ts +331 -331
- package/src/pages/admin-login/page.tsx +111 -111
- package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -527
- package/src/pages/admin-page-permissions/index.ts +7 -7
- package/src/pages/admin-roles/RolesAdminPage.tsx +533 -533
- package/src/pages/admin-roles/index.ts +9 -9
- package/src/pages/admin-roles/modals.tsx +469 -469
- package/src/pages/client-admin/ClientSiteAdminPage.tsx +382 -382
- package/src/pages/client-admin/index.ts +33 -33
- package/src/pages/coming-soon/page.tsx +83 -83
- package/src/pages/login/page.tsx +338 -338
- package/src/pages/onboarding/index.ts +11 -11
- package/src/pages/onboarding/page.tsx +212 -212
- package/src/pages/profile/EnhancedProfilePage.tsx +514 -514
- package/src/pages/profile/index.ts +9 -9
- package/src/pages/profile/page.tsx +166 -166
- package/src/pages/recovery/page.tsx +267 -267
- package/src/pages/roles/MyRolesPage.tsx +211 -211
- package/src/pages/roles/components.tsx +294 -294
- package/src/pages/roles/index.ts +17 -17
- package/src/pages/security/EnhancedSecurityPage.tsx +660 -660
- package/src/pages/security/index.ts +12 -12
- package/src/pages/security/page.tsx +507 -507
- package/src/pages/service-unavailable/page.tsx +63 -63
- package/src/pages/settings/EnhancedSettingsPage.tsx +642 -642
- package/src/pages/settings/index.ts +9 -9
- package/src/pages/settings/page.tsx +47 -47
- package/src/pages/showcase/ShowcasePage.tsx +532 -532
- package/src/pages/showcase/index.ts +13 -13
- package/src/pages/test-env/EmergencyLogoutPage.tsx +192 -192
- package/src/pages/test-env/JwtInspectPage.tsx +449 -449
- package/src/pages/test-env/RefreshTokenPage.tsx +178 -178
- package/src/pages/test-env/TestEnvPage.tsx +130 -130
- package/src/pages/test-env/guarded.tsx +81 -81
- package/src/pages/test-env/index.ts +77 -77
- package/src/pages/test-env/redact-credentials.ts +96 -96
- package/src/pages/verify-code/page.tsx +746 -746
- package/src/routes/account/index.ts +31 -31
- package/src/routes/account/masked-info.ts +36 -36
- package/src/routes/account/send-code.ts +39 -39
- package/src/routes/account/update-phone.ts +13 -13
- package/src/routes/account/verify-email.ts +40 -40
- package/src/routes/account/verify-sms.ts +40 -40
- package/src/routes/auth/credential-login-email-code.ts +6 -6
- package/src/routes/auth/credential-login.ts +6 -6
- package/src/routes/auth/index.ts +22 -22
- package/src/routes/auth/logout.ts +111 -111
- package/src/routes/auth/nextauth.ts +45 -45
- package/src/routes/auth/passkey-signin.ts +13 -13
- package/src/routes/auth/refresh.ts +77 -77
- package/src/routes/auth/session-reset.ts +37 -37
- package/src/routes/auth/session.ts +261 -261
- package/src/routes/auth/settings.ts +181 -181
- package/src/routes/auth/viability.ts +519 -519
- package/src/routes/index.ts +17 -17
- package/src/routes/mfa/backup-code-verify.ts +1 -1
- package/src/routes/mfa/backup-codes.ts +10 -10
- package/src/routes/mfa/methods.ts +22 -22
- package/src/routes/mfa/totp-confirm.ts +10 -10
- package/src/routes/mfa/totp-enroll.ts +10 -10
- package/src/routes/mfa/totp-verify.ts +14 -14
- package/src/routes/onboarding/index.ts +15 -15
- package/src/routes/onboarding/send-email-code.ts +2 -2
- package/src/routes/onboarding/set-password.ts +2 -2
- package/src/routes/onboarding/verify-code.ts +2 -2
- package/src/routes/onboarding/verify-welcome-email.ts +2 -2
- package/src/routes/passkeys/list.ts +10 -10
- package/src/routes/passkeys/manage.ts +23 -23
- package/src/routes/passkeys/register-options.ts +10 -10
- package/src/routes/passkeys/register-verify.ts +10 -10
- package/src/routes/session/index.ts +7 -7
- package/src/routes/session/refresh-viability.ts +17 -17
- package/src/server/auth-guard.ts +203 -203
- package/src/server/auth.ts +486 -486
- package/src/server/decode-session.ts +309 -309
- package/src/server/slim-middleware.ts +117 -117
- package/src/server/verified-session-token.ts +103 -103
- package/src/server/with-auth.ts +93 -93
- package/src/services/signalrActivityService.ts +258 -258
- package/src/stores/authStore.ts +1900 -1900
- package/src/templates/instrumentation.ts +60 -60
- package/src/theme/ThemeProvider.tsx +39 -39
- package/src/theme/default.ts +33 -33
- package/src/theme/index.ts +31 -31
- package/src/theme/types.ts +69 -69
- package/src/theme/useTheme.ts +57 -57
- package/src/theme/utils.ts +40 -40
- package/src/types/api.ts +12 -12
- package/src/types/auth.d.ts +23 -23
- package/src/types/auth.ts +48 -48
- package/src/types/logging.ts +11 -11
- package/src/types/recovery.ts +54 -54
- package/src/utils/api.ts +401 -401
- package/src/utils/circuitBreaker.ts +40 -40
- package/src/utils/error-message.ts +108 -108
- package/src/utils/layout/reservedSpace.ts +124 -124
- package/src/utils/logout.ts +30 -30
- package/src/vibe/client.ts +877 -877
- package/src/vibe/enterprise-auth.ts +208 -208
- package/src/vibe/errors.ts +209 -209
- package/src/vibe/generic.ts +339 -339
- package/src/vibe/hooks/index.ts +367 -367
- package/src/vibe/index.ts +133 -133
- package/src/vibe/sessions.ts +925 -899
- 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, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
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, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
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
|
+
|