@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
- clearCookies: true
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 NextResponse.redirect(new URL('/service-unavailable', request.url));
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
- return refreshFailureResponse(
847
- request,
848
- `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&error=ServiceUnavailable`,
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 {