@payez/next-mvp 4.5.2 → 4.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -103,8 +103,10 @@ function decide(context: AuthContext): AuthAction {
|
|
|
103
103
|
return {
|
|
104
104
|
type: 'redirect',
|
|
105
105
|
location: '/account-auth/login?error=ServiceUnavailable&reason=CircuitBreakerOpen',
|
|
106
|
-
reason: 'CircuitBreakerOpen'
|
|
107
|
-
|
|
106
|
+
reason: 'CircuitBreakerOpen'
|
|
107
|
+
// PAY-2147: NO clearCookies. The breaker opening is not a verdict on the session; clearing here logged out every
|
|
108
|
+
// user who made a request during a blip. (Deprecated twin: it cannot express a 503 or the /service-unavailable
|
|
109
|
+
// page - createMvpMiddleware does, see serviceUnavailableResponse.)
|
|
108
110
|
};
|
|
109
111
|
}
|
|
110
112
|
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { NextRequest, NextResponse } from 'next/server';
|
|
21
21
|
import { isUnauthenticatedRoute, should2FABypass } from '../auth/route-config';
|
|
22
|
-
import { resolveCallbackDestination } from '../auth/safe-callback';
|
|
22
|
+
import { resolveCallbackDestination, safeCallbackPath } from '../auth/safe-callback';
|
|
23
23
|
import { getInternalApiUrl } from '../edge/internal-api-url';
|
|
24
24
|
import { getSessionCookieName, getSecureSessionCookieName } from '../lib/app-slug';
|
|
25
25
|
import { checkPagePermission, isRBACEnabled } from './rbac-check';
|
|
@@ -290,6 +290,31 @@ function refreshFailureResponse(
|
|
|
290
290
|
return redirectTo(request, location, clearCookies);
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
/**
|
|
294
|
+
* PAY-2147: the terminal answer when the middleware CANNOT TELL whether the session is good
|
|
295
|
+
* (circuit breaker open, a half-open breaker refusing a refresh attempt, or the viability read failed).
|
|
296
|
+
*
|
|
297
|
+
* "I could not check" is not a verdict on the session, so this NEVER touches a cookie: a transient blip
|
|
298
|
+
* in the IdP / viability path used to wipe the session of every user who made any request while the
|
|
299
|
+
* breaker was open. Cookies are cleared only on a POSITIVE signal (force_invalidated, stale_session,
|
|
300
|
+
* pre_2fa_token_expired, refresh 401/403, explicit sign-out) - never from here.
|
|
301
|
+
*
|
|
302
|
+
* - /api/* -> 503 JSON a caller can branch on (a redirect would be followed by fetch() into a 200 HTML page).
|
|
303
|
+
* - pages -> /service-unavailable (public + allowDuringCircuitBreakerOpen, so it cannot loop), carrying the
|
|
304
|
+
* requested path as `callbackUrl` so "Try again" returns the user to it still signed in. The path goes
|
|
305
|
+
* through safeCallbackPath (URL-parser origin check): an off-origin or auth-surface value is dropped.
|
|
306
|
+
*/
|
|
307
|
+
function serviceUnavailableResponse(request: NextRequest): NextResponse {
|
|
308
|
+
const { pathname, search } = request.nextUrl;
|
|
309
|
+
if (pathname.startsWith('/api/')) {
|
|
310
|
+
return NextResponse.json({ error: 'ServiceUnavailable' }, { status: 503 });
|
|
311
|
+
}
|
|
312
|
+
const target = new URL('/service-unavailable', request.url);
|
|
313
|
+
const callback = safeCallbackPath(pathname + search);
|
|
314
|
+
if (callback !== '/') target.searchParams.set('callbackUrl', callback);
|
|
315
|
+
return NextResponse.redirect(target);
|
|
316
|
+
}
|
|
317
|
+
|
|
293
318
|
// =============================================================================
|
|
294
319
|
// AUTH DECISION ENGINE
|
|
295
320
|
// =============================================================================
|
|
@@ -334,14 +359,11 @@ function makeAuthDecision(ctx: AuthContext): AuthAction {
|
|
|
334
359
|
}
|
|
335
360
|
|
|
336
361
|
// --- Circuit breaker open: Service degraded ---
|
|
362
|
+
// PAY-2147: the breaker opening says the IdP/viability path is unreachable, NOT that the session is bad.
|
|
363
|
+
// Answer "service unavailable" (503 for /api, /service-unavailable for pages) and leave every cookie alone.
|
|
337
364
|
if (circuitBreakerOpen) {
|
|
338
365
|
if (onLoginPage) return { type: 'allow' };
|
|
339
|
-
return {
|
|
340
|
-
type: 'redirect',
|
|
341
|
-
location: `${LOGIN_PAGE}?error=ServiceUnavailable`,
|
|
342
|
-
reason: 'circuit_breaker_open',
|
|
343
|
-
clearCookies: true
|
|
344
|
-
};
|
|
366
|
+
return { type: 'service_error', reason: 'circuit_breaker_open' };
|
|
345
367
|
}
|
|
346
368
|
|
|
347
369
|
// --- No session cookie: Need to login ---
|
|
@@ -363,6 +385,7 @@ function makeAuthDecision(ctx: AuthContext): AuthAction {
|
|
|
363
385
|
|
|
364
386
|
// --- Service error: Can't verify session ---
|
|
365
387
|
if (sessionStatus.exists === null || sessionStatus.forceInvalid === null) {
|
|
388
|
+
if (onLoginPage) return { type: 'allow' }; // the login page must stay reachable while the check is down
|
|
366
389
|
return { type: 'service_error', reason: 'viability_check_failed' };
|
|
367
390
|
}
|
|
368
391
|
|
|
@@ -622,6 +645,18 @@ export function decideForceInvalid(
|
|
|
622
645
|
return data.forceInvalid === true || data.revoked === true;
|
|
623
646
|
}
|
|
624
647
|
|
|
648
|
+
/**
|
|
649
|
+
* PAY-2147: does the request carry a session cookie? Used ONLY when the viability read itself FAILED.
|
|
650
|
+
* Before this, a failed read left sessionPointer.exists=false, so the decision engine took its `no_session` arm
|
|
651
|
+
* (pages -> the LOGIN page, /api -> straight through) and its `exists === null` -> service_error arm below it was
|
|
652
|
+
* unreachable dead code: a user holding a perfectly good session was told to sign in during an outage. With a
|
|
653
|
+
* cookie present and the check down we do not know the session is bad, so the engine must say "service
|
|
654
|
+
* unavailable", not "sign in". No cookie -> unchanged (no_session).
|
|
655
|
+
*/
|
|
656
|
+
function hasSessionCookie(request: NextRequest): boolean {
|
|
657
|
+
return !!(request.cookies.get(getSessionCookieName())?.value || request.cookies.get(getSecureSessionCookieName())?.value);
|
|
658
|
+
}
|
|
659
|
+
|
|
625
660
|
/** Check session viability via internal API */
|
|
626
661
|
async function checkViability(
|
|
627
662
|
request: NextRequest,
|
|
@@ -716,11 +751,13 @@ async function checkViability(
|
|
|
716
751
|
log.error('Viability check failed', { status: response.status });
|
|
717
752
|
sessionStatus.exists = null;
|
|
718
753
|
sessionStatus.forceInvalid = null;
|
|
754
|
+
sessionPointer.exists = hasSessionCookie(request); // PAY-2147: see hasSessionCookie
|
|
719
755
|
}
|
|
720
756
|
} catch (error) {
|
|
721
757
|
log.error('Viability check error', { error: error instanceof Error ? error.message : String(error) });
|
|
722
758
|
sessionStatus.exists = null;
|
|
723
759
|
sessionStatus.forceInvalid = null;
|
|
760
|
+
sessionPointer.exists = hasSessionCookie(request); // PAY-2147: see hasSessionCookie
|
|
724
761
|
}
|
|
725
762
|
|
|
726
763
|
return { sessionStatus, sessionPointer };
|
|
@@ -755,7 +792,7 @@ async function executeDecision(
|
|
|
755
792
|
return redirectTo(request, decision.location, decision.clearCookies);
|
|
756
793
|
|
|
757
794
|
case 'service_error':
|
|
758
|
-
return
|
|
795
|
+
return serviceUnavailableResponse(request);
|
|
759
796
|
|
|
760
797
|
case 'refresh_needed':
|
|
761
798
|
// PAY-2136: a speculative request must not spend (rotate) a single-use refresh token or, on a 401/403,
|
|
@@ -843,12 +880,9 @@ async function handleRefresh(
|
|
|
843
880
|
// Check if circuit breaker allows refresh attempt
|
|
844
881
|
if (!cb.canAttemptRefresh()) {
|
|
845
882
|
log.warn('Circuit breaker preventing refresh attempt');
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
'ServiceUnavailable',
|
|
850
|
-
true
|
|
851
|
-
);
|
|
883
|
+
// PAY-2147: a breaker that merely declines to TRY a refresh has learned nothing about the session; wiping the
|
|
884
|
+
// cookies here logged out every user on the first request after a blip (HALF_OPEN refuses all but one probe).
|
|
885
|
+
return serviceUnavailableResponse(request);
|
|
852
886
|
}
|
|
853
887
|
|
|
854
888
|
try {
|