@payez/next-mvp 4.5.1 → 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.
- package/dist/auth/auth-decision.d.ts +5 -0
- package/dist/auth/auth-decision.js +16 -2
- package/dist/lib/idp-fetch.d.ts +8 -1
- package/dist/lib/idp-fetch.js +5 -2
- package/dist/lib/retry-after.d.ts +10 -0
- package/dist/lib/retry-after.js +30 -0
- package/dist/lib/two-factor-verify-failure.d.ts +8 -0
- package/dist/lib/two-factor-verify-failure.js +35 -3
- package/dist/middleware/create-middleware.js +47 -8
- package/package.json +1 -1
- package/scripts/check-publish-health.mjs +12 -3
- package/scripts/publish-health-tag.mjs +27 -0
- package/src/auth/auth-decision.ts +19 -2
- package/src/lib/idp-fetch.ts +10 -2
- package/src/lib/retry-after.ts +24 -0
- package/src/lib/two-factor-verify-failure.ts +277 -248
- package/src/middleware/create-middleware.ts +48 -14
|
@@ -39,5 +39,10 @@ export type AuthAction = {
|
|
|
39
39
|
* Main decision function - pure, testable, deterministic.
|
|
40
40
|
* PAY-2136: when `context.isPrefetch` is set, no redirect it returns asks for cookies to be cleared; the
|
|
41
41
|
* redirect itself is kept, and the next real navigation reaches the same decision and clears then.
|
|
42
|
+
*
|
|
43
|
+
* @deprecated PAY-2180: this exported engine is NOT the one the middleware runs. `createMvpMiddleware` decides with
|
|
44
|
+
* its own private engine (middleware/create-middleware.ts), so calling this answers a question nothing enforces.
|
|
45
|
+
* A sweep found no code consumer in the portal, idealvibe, idealresume or cryptaply. It will be REMOVED in the next
|
|
46
|
+
* major version, together with its re-exports from the package root and `/edge`. Use `createMvpMiddleware`.
|
|
42
47
|
*/
|
|
43
48
|
export declare function makeAuthDecision(context: AuthContext): AuthAction;
|
|
@@ -9,12 +9,24 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
9
9
|
exports.makeAuthDecision = makeAuthDecision;
|
|
10
10
|
const safe_callback_1 = require("./safe-callback");
|
|
11
11
|
const token_log_id_1 = require("../lib/token-log-id");
|
|
12
|
+
/** PAY-2180: warn once per process, not per request. */
|
|
13
|
+
let deprecationWarned = false;
|
|
12
14
|
/**
|
|
13
15
|
* Main decision function - pure, testable, deterministic.
|
|
14
16
|
* PAY-2136: when `context.isPrefetch` is set, no redirect it returns asks for cookies to be cleared; the
|
|
15
17
|
* redirect itself is kept, and the next real navigation reaches the same decision and clears then.
|
|
18
|
+
*
|
|
19
|
+
* @deprecated PAY-2180: this exported engine is NOT the one the middleware runs. `createMvpMiddleware` decides with
|
|
20
|
+
* its own private engine (middleware/create-middleware.ts), so calling this answers a question nothing enforces.
|
|
21
|
+
* A sweep found no code consumer in the portal, idealvibe, idealresume or cryptaply. It will be REMOVED in the next
|
|
22
|
+
* major version, together with its re-exports from the package root and `/edge`. Use `createMvpMiddleware`.
|
|
16
23
|
*/
|
|
17
24
|
function makeAuthDecision(context) {
|
|
25
|
+
if (!deprecationWarned) {
|
|
26
|
+
deprecationWarned = true;
|
|
27
|
+
console.warn('[next-mvp] makeAuthDecision (auth/auth-decision) is deprecated (PAY-2180) and will be removed in the next major: ' +
|
|
28
|
+
'createMvpMiddleware does not use it. Use createMvpMiddleware.');
|
|
29
|
+
}
|
|
18
30
|
const action = decide(context);
|
|
19
31
|
if (context.isPrefetch && action.type === 'redirect' && action.clearCookies) {
|
|
20
32
|
const { clearCookies: _dropped, ...rest } = action;
|
|
@@ -49,8 +61,10 @@ function decide(context) {
|
|
|
49
61
|
return {
|
|
50
62
|
type: 'redirect',
|
|
51
63
|
location: '/account-auth/login?error=ServiceUnavailable&reason=CircuitBreakerOpen',
|
|
52
|
-
reason: 'CircuitBreakerOpen'
|
|
53
|
-
|
|
64
|
+
reason: 'CircuitBreakerOpen'
|
|
65
|
+
// PAY-2147: NO clearCookies. The breaker opening is not a verdict on the session; clearing here logged out every
|
|
66
|
+
// user who made a request during a blip. (Deprecated twin: it cannot express a 503 or the /service-unavailable
|
|
67
|
+
// page - createMvpMiddleware does, see serviceUnavailableResponse.)
|
|
54
68
|
};
|
|
55
69
|
}
|
|
56
70
|
// No session pointer (JWT cookie) - need to login
|
package/dist/lib/idp-fetch.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { NextRequest } from 'next/server';
|
|
2
2
|
import { assertAbsoluteIdpUrl } from './idp-url-guard';
|
|
3
|
-
|
|
3
|
+
import { parseRetryAfterSeconds } from './retry-after';
|
|
4
|
+
export { assertAbsoluteIdpUrl, parseRetryAfterSeconds };
|
|
4
5
|
/**
|
|
5
6
|
* Centralized IDP fetch helper
|
|
6
7
|
* - Injects Bearer from Redis session
|
|
@@ -12,5 +13,11 @@ export interface IdpFetchResult<T = any> {
|
|
|
12
13
|
status: number;
|
|
13
14
|
json: T | null;
|
|
14
15
|
attemptedRefresh: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* PAY-1608: the upstream Retry-After in whole seconds, when the IDP sent a usable one. Core sends it on every
|
|
18
|
+
* 2FA lock (MfaController.SecondFactorLocked: 429 + Retry-After 900; VerifyTotp: what is left of the lock).
|
|
19
|
+
* It used to be dropped here, so no BFF could state the real lock window. Absent otherwise.
|
|
20
|
+
*/
|
|
21
|
+
retryAfterSeconds?: number;
|
|
15
22
|
}
|
|
16
23
|
export declare function idpFetchJSON<T = any>(req: NextRequest, targetUrl: string, init?: RequestInit): Promise<IdpFetchResult<T>>;
|
package/dist/lib/idp-fetch.js
CHANGED
|
@@ -33,13 +33,15 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.assertAbsoluteIdpUrl = void 0;
|
|
36
|
+
exports.parseRetryAfterSeconds = exports.assertAbsoluteIdpUrl = void 0;
|
|
37
37
|
exports.idpFetchJSON = idpFetchJSON;
|
|
38
38
|
const test_aware_get_token_1 = require("./test-aware-get-token");
|
|
39
39
|
const session_store_1 = require("./session-store");
|
|
40
40
|
const internal_api_1 = require("./internal-api");
|
|
41
41
|
const idp_url_guard_1 = require("./idp-url-guard");
|
|
42
42
|
Object.defineProperty(exports, "assertAbsoluteIdpUrl", { enumerable: true, get: function () { return idp_url_guard_1.assertAbsoluteIdpUrl; } });
|
|
43
|
+
const retry_after_1 = require("./retry-after");
|
|
44
|
+
Object.defineProperty(exports, "parseRetryAfterSeconds", { enumerable: true, get: function () { return retry_after_1.parseRetryAfterSeconds; } });
|
|
43
45
|
function buildHeaders(req, bearer, extra) {
|
|
44
46
|
const headers = {
|
|
45
47
|
Accept: 'application/json',
|
|
@@ -177,5 +179,6 @@ async function idpFetchJSON(req, targetUrl, init = {}) {
|
|
|
177
179
|
}
|
|
178
180
|
catch { }
|
|
179
181
|
}
|
|
180
|
-
|
|
182
|
+
const retryAfterSeconds = (0, retry_after_1.parseRetryAfterSeconds)(res.headers?.get?.('Retry-After'));
|
|
183
|
+
return { ok: res.ok, status: res.status, json: json, attemptedRefresh, ...(retryAfterSeconds ? { retryAfterSeconds } : {}) };
|
|
181
184
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAY-1608: parse an HTTP Retry-After header to whole seconds.
|
|
3
|
+
*
|
|
4
|
+
* Accepts delta-seconds ("900") and an HTTP date in the future. Answers undefined for absent, zero, negative,
|
|
5
|
+
* fractional, past or malformed values - a caller that gets undefined states no window rather than a guessed one.
|
|
6
|
+
*
|
|
7
|
+
* A dependency-free leaf on purpose: idp-fetch re-exports it, but a test can load it without the session/redis
|
|
8
|
+
* graph idp-fetch pulls in.
|
|
9
|
+
*/
|
|
10
|
+
export declare function parseRetryAfterSeconds(value: string | null | undefined, nowMs?: number): number | undefined;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseRetryAfterSeconds = parseRetryAfterSeconds;
|
|
4
|
+
/**
|
|
5
|
+
* PAY-1608: parse an HTTP Retry-After header to whole seconds.
|
|
6
|
+
*
|
|
7
|
+
* Accepts delta-seconds ("900") and an HTTP date in the future. Answers undefined for absent, zero, negative,
|
|
8
|
+
* fractional, past or malformed values - a caller that gets undefined states no window rather than a guessed one.
|
|
9
|
+
*
|
|
10
|
+
* A dependency-free leaf on purpose: idp-fetch re-exports it, but a test can load it without the session/redis
|
|
11
|
+
* graph idp-fetch pulls in.
|
|
12
|
+
*/
|
|
13
|
+
function parseRetryAfterSeconds(value, nowMs = Date.now()) {
|
|
14
|
+
if (typeof value !== 'string')
|
|
15
|
+
return undefined;
|
|
16
|
+
const v = value.trim();
|
|
17
|
+
if (/^\d+$/.test(v)) {
|
|
18
|
+
const n = parseInt(v, 10);
|
|
19
|
+
return n > 0 ? n : undefined;
|
|
20
|
+
}
|
|
21
|
+
// Only something that looks like an HTTP date (it has letters: "Sat, 10 Oct 2026 12:01:30 GMT") is parsed as
|
|
22
|
+
// one; "-5" and "1.5" must not reach Date.parse, which would read them as years.
|
|
23
|
+
if (!/[a-z]/i.test(v))
|
|
24
|
+
return undefined;
|
|
25
|
+
const at = Date.parse(v);
|
|
26
|
+
if (!Number.isFinite(at))
|
|
27
|
+
return undefined;
|
|
28
|
+
const secs = Math.ceil((at - nowMs) / 1000);
|
|
29
|
+
return secs > 0 ? secs : undefined;
|
|
30
|
+
}
|
|
@@ -56,10 +56,18 @@ export declare function stepUpRefusal(status: number, json: unknown): StepUpRefu
|
|
|
56
56
|
*/
|
|
57
57
|
export declare function upstreamErrorTexts(json: unknown): string[];
|
|
58
58
|
export declare function classifyTwoFactorVerifyFailure(status: number, json: unknown): TwoFactorVerifyFailureCode;
|
|
59
|
+
/**
|
|
60
|
+
* PAY-1608: the window as words - "15 minutes", "1 minute", "45 seconds", "2 hours". Rounded UP, so the user is never
|
|
61
|
+
* told a shorter wait than the IDP enforces. Undefined in, undefined out.
|
|
62
|
+
*/
|
|
63
|
+
export declare function formatLockWindow(seconds: number | undefined): string | undefined;
|
|
64
|
+
/** PAY-1608: the LOCKED sentence with the IDP's real window, or the number-free one when none was sent. */
|
|
65
|
+
export declare function lockedText(retryAfterSeconds?: number): string;
|
|
59
66
|
export declare function twoFactorVerifyFailure(result: {
|
|
60
67
|
status: number;
|
|
61
68
|
json: unknown;
|
|
62
69
|
attemptedRefresh: boolean;
|
|
70
|
+
retryAfterSeconds?: number;
|
|
63
71
|
}, method: TwoFactorVerifyMethod): NextResponse;
|
|
64
72
|
/**
|
|
65
73
|
* PAY-1443 residual: account/send-code on the SAME verify-code screen. A send has no
|
|
@@ -4,6 +4,8 @@ exports.STEP_UP_REFUSAL_CODE = void 0;
|
|
|
4
4
|
exports.stepUpRefusal = stepUpRefusal;
|
|
5
5
|
exports.upstreamErrorTexts = upstreamErrorTexts;
|
|
6
6
|
exports.classifyTwoFactorVerifyFailure = classifyTwoFactorVerifyFailure;
|
|
7
|
+
exports.formatLockWindow = formatLockWindow;
|
|
8
|
+
exports.lockedText = lockedText;
|
|
7
9
|
exports.twoFactorVerifyFailure = twoFactorVerifyFailure;
|
|
8
10
|
exports.classifyTwoFactorSendFailure = classifyTwoFactorSendFailure;
|
|
9
11
|
exports.twoFactorSendCodeFailure = twoFactorSendCodeFailure;
|
|
@@ -33,7 +35,9 @@ const INVALID_CODE_TEXT = {
|
|
|
33
35
|
backup_code: 'Invalid backup code. Please try again.',
|
|
34
36
|
};
|
|
35
37
|
const FIXED_TEXT = {
|
|
36
|
-
|
|
38
|
+
// PAY-1608: NO NUMBER HERE. This is the sentence for a lock whose window the IDP did not send; lockedText()
|
|
39
|
+
// states the real window when Retry-After arrives. A hard-coded "15 minutes" was true only while every lock was 15.
|
|
40
|
+
LOCKED: 'Too many incorrect codes. Verification is locked for now - even a correct code is refused until the lock ends. Wait before entering another code.',
|
|
37
41
|
RATE_LIMITED: 'Too many attempts. Please wait a moment and try again.',
|
|
38
42
|
SESSION_REJECTED: 'We could not verify the code for this session. Please try again.',
|
|
39
43
|
VERIFY_UNAVAILABLE: 'We could not verify the code right now. Please try again.',
|
|
@@ -186,13 +190,41 @@ function classifyTwoFactorVerifyFailure(status, json) {
|
|
|
186
190
|
return 'VERIFY_UNAVAILABLE';
|
|
187
191
|
return 'VERIFY_FAILED';
|
|
188
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* PAY-1608: the window as words - "15 minutes", "1 minute", "45 seconds", "2 hours". Rounded UP, so the user is never
|
|
195
|
+
* told a shorter wait than the IDP enforces. Undefined in, undefined out.
|
|
196
|
+
*/
|
|
197
|
+
function formatLockWindow(seconds) {
|
|
198
|
+
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds <= 0)
|
|
199
|
+
return undefined;
|
|
200
|
+
const plural = (n, unit) => `${n} ${unit}${n === 1 ? '' : 's'}`;
|
|
201
|
+
if (seconds < 60)
|
|
202
|
+
return plural(Math.ceil(seconds), 'second');
|
|
203
|
+
const minutes = Math.ceil(seconds / 60);
|
|
204
|
+
if (minutes < 120)
|
|
205
|
+
return plural(minutes, 'minute');
|
|
206
|
+
return plural(Math.ceil(minutes / 60), 'hour');
|
|
207
|
+
}
|
|
208
|
+
/** PAY-1608: the LOCKED sentence with the IDP's real window, or the number-free one when none was sent. */
|
|
209
|
+
function lockedText(retryAfterSeconds) {
|
|
210
|
+
const window = formatLockWindow(retryAfterSeconds);
|
|
211
|
+
return window
|
|
212
|
+
? `Too many incorrect codes. Verification is locked for ${window} - wait before entering another code.`
|
|
213
|
+
: FIXED_TEXT.LOCKED;
|
|
214
|
+
}
|
|
189
215
|
function twoFactorVerifyFailure(result, method) {
|
|
190
216
|
const refusal = stepUpRefusal(result.status, result.json);
|
|
191
217
|
if (refusal)
|
|
192
218
|
return stepUpResponse(refusal, result.attemptedRefresh);
|
|
193
219
|
const code = classifyTwoFactorVerifyFailure(result.status, result.json);
|
|
194
|
-
const error = code === 'INVALID_CODE' ? INVALID_CODE_TEXT[method]
|
|
195
|
-
|
|
220
|
+
const error = code === 'INVALID_CODE' ? INVALID_CODE_TEXT[method]
|
|
221
|
+
: code === 'LOCKED' ? lockedText(result.retryAfterSeconds)
|
|
222
|
+
: FIXED_TEXT[code];
|
|
223
|
+
// PAY-1608: a lock or a throttle relays the IDP's window, as a number (for a client that words it itself) and as the
|
|
224
|
+
// standard header. Never on other failures, and never invented: absent upstream means absent here.
|
|
225
|
+
const wait = (code === 'LOCKED' || code === 'RATE_LIMITED') && formatLockWindow(result.retryAfterSeconds)
|
|
226
|
+
? Math.ceil(result.retryAfterSeconds) : undefined;
|
|
227
|
+
return server_1.NextResponse.json({ success: false, error, code, ...(wait ? { retryAfterSeconds: wait } : {}), meta: { attemptedRefresh: result.attemptedRefresh } }, { status: result.status, ...(wait ? { headers: { 'Retry-After': String(wait) } } : {}) });
|
|
196
228
|
}
|
|
197
229
|
const SEND_LABEL = { sms: 'text message', email: 'email' };
|
|
198
230
|
function classifyTwoFactorSendFailure(status, json) {
|
|
@@ -160,6 +160,31 @@ function refreshFailureResponse(request, location, errorCode, clearCookies = fal
|
|
|
160
160
|
}
|
|
161
161
|
return redirectTo(request, location, clearCookies);
|
|
162
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* PAY-2147: the terminal answer when the middleware CANNOT TELL whether the session is good
|
|
165
|
+
* (circuit breaker open, a half-open breaker refusing a refresh attempt, or the viability read failed).
|
|
166
|
+
*
|
|
167
|
+
* "I could not check" is not a verdict on the session, so this NEVER touches a cookie: a transient blip
|
|
168
|
+
* in the IdP / viability path used to wipe the session of every user who made any request while the
|
|
169
|
+
* breaker was open. Cookies are cleared only on a POSITIVE signal (force_invalidated, stale_session,
|
|
170
|
+
* pre_2fa_token_expired, refresh 401/403, explicit sign-out) - never from here.
|
|
171
|
+
*
|
|
172
|
+
* - /api/* -> 503 JSON a caller can branch on (a redirect would be followed by fetch() into a 200 HTML page).
|
|
173
|
+
* - pages -> /service-unavailable (public + allowDuringCircuitBreakerOpen, so it cannot loop), carrying the
|
|
174
|
+
* requested path as `callbackUrl` so "Try again" returns the user to it still signed in. The path goes
|
|
175
|
+
* through safeCallbackPath (URL-parser origin check): an off-origin or auth-surface value is dropped.
|
|
176
|
+
*/
|
|
177
|
+
function serviceUnavailableResponse(request) {
|
|
178
|
+
const { pathname, search } = request.nextUrl;
|
|
179
|
+
if (pathname.startsWith('/api/')) {
|
|
180
|
+
return server_1.NextResponse.json({ error: 'ServiceUnavailable' }, { status: 503 });
|
|
181
|
+
}
|
|
182
|
+
const target = new URL('/service-unavailable', request.url);
|
|
183
|
+
const callback = (0, safe_callback_1.safeCallbackPath)(pathname + search);
|
|
184
|
+
if (callback !== '/')
|
|
185
|
+
target.searchParams.set('callbackUrl', callback);
|
|
186
|
+
return server_1.NextResponse.redirect(target);
|
|
187
|
+
}
|
|
163
188
|
// =============================================================================
|
|
164
189
|
// AUTH DECISION ENGINE
|
|
165
190
|
// =============================================================================
|
|
@@ -198,15 +223,12 @@ function makeAuthDecision(ctx) {
|
|
|
198
223
|
return { type: 'allow' };
|
|
199
224
|
}
|
|
200
225
|
// --- Circuit breaker open: Service degraded ---
|
|
226
|
+
// PAY-2147: the breaker opening says the IdP/viability path is unreachable, NOT that the session is bad.
|
|
227
|
+
// Answer "service unavailable" (503 for /api, /service-unavailable for pages) and leave every cookie alone.
|
|
201
228
|
if (circuitBreakerOpen) {
|
|
202
229
|
if (onLoginPage)
|
|
203
230
|
return { type: 'allow' };
|
|
204
|
-
return {
|
|
205
|
-
type: 'redirect',
|
|
206
|
-
location: `${LOGIN_PAGE}?error=ServiceUnavailable`,
|
|
207
|
-
reason: 'circuit_breaker_open',
|
|
208
|
-
clearCookies: true
|
|
209
|
-
};
|
|
231
|
+
return { type: 'service_error', reason: 'circuit_breaker_open' };
|
|
210
232
|
}
|
|
211
233
|
// --- No session cookie: Need to login ---
|
|
212
234
|
if (!sessionPointer.exists) {
|
|
@@ -227,6 +249,8 @@ function makeAuthDecision(ctx) {
|
|
|
227
249
|
}
|
|
228
250
|
// --- Service error: Can't verify session ---
|
|
229
251
|
if (sessionStatus.exists === null || sessionStatus.forceInvalid === null) {
|
|
252
|
+
if (onLoginPage)
|
|
253
|
+
return { type: 'allow' }; // the login page must stay reachable while the check is down
|
|
230
254
|
return { type: 'service_error', reason: 'viability_check_failed' };
|
|
231
255
|
}
|
|
232
256
|
// --- Session force-invalidated (admin action, password change, etc.) ---
|
|
@@ -466,6 +490,17 @@ function decideForceInvalid(data, revocationFailMode) {
|
|
|
466
490
|
// No revocationStatus: an older boolean producer. Absence of both stays false.
|
|
467
491
|
return data.forceInvalid === true || data.revoked === true;
|
|
468
492
|
}
|
|
493
|
+
/**
|
|
494
|
+
* PAY-2147: does the request carry a session cookie? Used ONLY when the viability read itself FAILED.
|
|
495
|
+
* Before this, a failed read left sessionPointer.exists=false, so the decision engine took its `no_session` arm
|
|
496
|
+
* (pages -> the LOGIN page, /api -> straight through) and its `exists === null` -> service_error arm below it was
|
|
497
|
+
* unreachable dead code: a user holding a perfectly good session was told to sign in during an outage. With a
|
|
498
|
+
* cookie present and the check down we do not know the session is bad, so the engine must say "service
|
|
499
|
+
* unavailable", not "sign in". No cookie -> unchanged (no_session).
|
|
500
|
+
*/
|
|
501
|
+
function hasSessionCookie(request) {
|
|
502
|
+
return !!(request.cookies.get((0, app_slug_1.getSessionCookieName)())?.value || request.cookies.get((0, app_slug_1.getSecureSessionCookieName)())?.value);
|
|
503
|
+
}
|
|
469
504
|
/** Check session viability via internal API */
|
|
470
505
|
async function checkViability(request, endpoint, log, revocationFailMode) {
|
|
471
506
|
const sessionStatus = {
|
|
@@ -551,12 +586,14 @@ async function checkViability(request, endpoint, log, revocationFailMode) {
|
|
|
551
586
|
log.error('Viability check failed', { status: response.status });
|
|
552
587
|
sessionStatus.exists = null;
|
|
553
588
|
sessionStatus.forceInvalid = null;
|
|
589
|
+
sessionPointer.exists = hasSessionCookie(request); // PAY-2147: see hasSessionCookie
|
|
554
590
|
}
|
|
555
591
|
}
|
|
556
592
|
catch (error) {
|
|
557
593
|
log.error('Viability check error', { error: error instanceof Error ? error.message : String(error) });
|
|
558
594
|
sessionStatus.exists = null;
|
|
559
595
|
sessionStatus.forceInvalid = null;
|
|
596
|
+
sessionPointer.exists = hasSessionCookie(request); // PAY-2147: see hasSessionCookie
|
|
560
597
|
}
|
|
561
598
|
return { sessionStatus, sessionPointer };
|
|
562
599
|
}
|
|
@@ -569,7 +606,7 @@ async function executeDecision(request, decision, pathname, sessionPointer, sess
|
|
|
569
606
|
case 'redirect':
|
|
570
607
|
return redirectTo(request, decision.location, decision.clearCookies);
|
|
571
608
|
case 'service_error':
|
|
572
|
-
return
|
|
609
|
+
return serviceUnavailableResponse(request);
|
|
573
610
|
case 'refresh_needed':
|
|
574
611
|
// PAY-2136: a speculative request must not spend (rotate) a single-use refresh token or, on a 401/403,
|
|
575
612
|
// clear the session. Let it through; the real navigation refreshes.
|
|
@@ -629,7 +666,9 @@ async function handleRefresh(request, safeCallback, opts) {
|
|
|
629
666
|
// Check if circuit breaker allows refresh attempt
|
|
630
667
|
if (!cb.canAttemptRefresh()) {
|
|
631
668
|
log.warn('Circuit breaker preventing refresh attempt');
|
|
632
|
-
|
|
669
|
+
// PAY-2147: a breaker that merely declines to TRY a refresh has learned nothing about the session; wiping the
|
|
670
|
+
// cookies here logged out every user on the first request after a blip (HALF_OPEN refuses all but one probe).
|
|
671
|
+
return serviceUnavailableResponse(request);
|
|
633
672
|
}
|
|
634
673
|
try {
|
|
635
674
|
const baseUrl = (0, internal_api_url_1.getInternalApiUrl)(request);
|
package/package.json
CHANGED
|
@@ -31,6 +31,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
31
31
|
import { readFileSync } from 'node:fs';
|
|
32
32
|
import { fileURLToPath } from 'node:url';
|
|
33
33
|
import { dirname, join } from 'node:path';
|
|
34
|
+
import { resolvePublishTag } from './publish-health-tag.mjs';
|
|
34
35
|
|
|
35
36
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
36
37
|
const pkgPath = join(here, '..', 'package.json');
|
|
@@ -69,6 +70,14 @@ try {
|
|
|
69
70
|
const name = local.name;
|
|
70
71
|
const localVersion = local.version;
|
|
71
72
|
|
|
73
|
+
// PAY-2136: compare against the dist-tag being published (`npm publish --tag next` -> `next`), not always `latest`.
|
|
74
|
+
const tag = resolvePublishTag(process.argv.slice(2), process.env);
|
|
75
|
+
if (tag === null) {
|
|
76
|
+
console.error('[publish-health] INCONCLUSIVE: the requested dist-tag is not a plain tag name.');
|
|
77
|
+
process.exit(2);
|
|
78
|
+
}
|
|
79
|
+
const registrySpec = tag === 'latest' ? name : `${name}@${tag}`;
|
|
80
|
+
|
|
72
81
|
// Auth is reported separately: a dead credential is the ROOT of 171900, and folding
|
|
73
82
|
// it into "lagging" would hide which of the two things is actually broken.
|
|
74
83
|
let authUser = null;
|
|
@@ -105,11 +114,11 @@ const REGISTRY_RETRY_MS = 2000;
|
|
|
105
114
|
let registryVersion = null;
|
|
106
115
|
for (let attempt = 1; attempt <= REGISTRY_ATTEMPTS; attempt++) {
|
|
107
116
|
try {
|
|
108
|
-
registryVersion = run(['view',
|
|
117
|
+
registryVersion = run(['view', registrySpec, 'version', '--prefer-online']);
|
|
109
118
|
} catch (err) {
|
|
110
119
|
const msg = String(err.stderr || err.message || '');
|
|
111
120
|
if (/E404|404 Not Found/.test(msg)) {
|
|
112
|
-
console.error(`[publish-health] FAIL: ${
|
|
121
|
+
console.error(`[publish-health] FAIL: ${registrySpec} has NEVER been published to the registry.`);
|
|
113
122
|
console.error(`[publish-health] local=${localVersion} registry=<absent>`);
|
|
114
123
|
process.exit(1);
|
|
115
124
|
}
|
|
@@ -135,7 +144,7 @@ const delta = compareVersions(localVersion, registryVersion);
|
|
|
135
144
|
|
|
136
145
|
console.log(`[publish-health] package : ${name}`);
|
|
137
146
|
console.log(`[publish-health] local : ${localVersion}`);
|
|
138
|
-
console.log(`[publish-health] registry : ${registryVersion}`);
|
|
147
|
+
console.log(`[publish-health] registry : ${registryVersion} (dist-tag ${tag})`);
|
|
139
148
|
console.log(`[publish-health] npm auth : ${authUser ? `logged in as ${authUser}` : 'NOT AUTHENTICATED (npm whoami failed)'}`);
|
|
140
149
|
|
|
141
150
|
if (delta > 0) {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which dist-tag should the publish-health check compare the local version against?
|
|
3
|
+
*
|
|
4
|
+
* PAY-2136 (BAPert 71043): `npm publish --tag next` runs this check as `postpublish`, and it compared local against
|
|
5
|
+
* the `latest` tag, so a CORRECT next-tag publish printed "registry LAGS local" and exited 1 - noise nobody should
|
|
6
|
+
* learn to ignore. Resolution order: `--tag <x>` / `--tag=<x>` argument, then npm_config_tag (npm sets it inside
|
|
7
|
+
* lifecycle scripts from `--tag`), then `latest`. A tag that is not a plain word is rejected (null), never guessed.
|
|
8
|
+
*/
|
|
9
|
+
const TAG = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
10
|
+
|
|
11
|
+
export function resolvePublishTag(argv = [], env = {}) {
|
|
12
|
+
let raw;
|
|
13
|
+
for (let i = 0; i < argv.length; i++) {
|
|
14
|
+
const a = argv[i];
|
|
15
|
+
if (a === '--tag') {
|
|
16
|
+
raw = argv[i + 1];
|
|
17
|
+
break;
|
|
18
|
+
}
|
|
19
|
+
if (a.startsWith('--tag=')) {
|
|
20
|
+
raw = a.slice('--tag='.length);
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (raw === undefined) raw = env.npm_config_tag;
|
|
25
|
+
if (raw === undefined || raw === '') return 'latest';
|
|
26
|
+
return TAG.test(raw) ? raw : null;
|
|
27
|
+
}
|
|
@@ -36,12 +36,27 @@ export type AuthAction =
|
|
|
36
36
|
| { type: 'redirect'; location: string; reason: string; clearCookies?: boolean }
|
|
37
37
|
| { type: 'service_error'; reason: string };
|
|
38
38
|
|
|
39
|
+
/** PAY-2180: warn once per process, not per request. */
|
|
40
|
+
let deprecationWarned = false;
|
|
41
|
+
|
|
39
42
|
/**
|
|
40
43
|
* Main decision function - pure, testable, deterministic.
|
|
41
44
|
* PAY-2136: when `context.isPrefetch` is set, no redirect it returns asks for cookies to be cleared; the
|
|
42
45
|
* redirect itself is kept, and the next real navigation reaches the same decision and clears then.
|
|
46
|
+
*
|
|
47
|
+
* @deprecated PAY-2180: this exported engine is NOT the one the middleware runs. `createMvpMiddleware` decides with
|
|
48
|
+
* its own private engine (middleware/create-middleware.ts), so calling this answers a question nothing enforces.
|
|
49
|
+
* A sweep found no code consumer in the portal, idealvibe, idealresume or cryptaply. It will be REMOVED in the next
|
|
50
|
+
* major version, together with its re-exports from the package root and `/edge`. Use `createMvpMiddleware`.
|
|
43
51
|
*/
|
|
44
52
|
export function makeAuthDecision(context: AuthContext): AuthAction {
|
|
53
|
+
if (!deprecationWarned) {
|
|
54
|
+
deprecationWarned = true;
|
|
55
|
+
console.warn(
|
|
56
|
+
'[next-mvp] makeAuthDecision (auth/auth-decision) is deprecated (PAY-2180) and will be removed in the next major: ' +
|
|
57
|
+
'createMvpMiddleware does not use it. Use createMvpMiddleware.'
|
|
58
|
+
);
|
|
59
|
+
}
|
|
45
60
|
const action = decide(context);
|
|
46
61
|
if (context.isPrefetch && action.type === 'redirect' && action.clearCookies) {
|
|
47
62
|
const { clearCookies: _dropped, ...rest } = action;
|
|
@@ -88,8 +103,10 @@ function decide(context: AuthContext): AuthAction {
|
|
|
88
103
|
return {
|
|
89
104
|
type: 'redirect',
|
|
90
105
|
location: '/account-auth/login?error=ServiceUnavailable&reason=CircuitBreakerOpen',
|
|
91
|
-
reason: 'CircuitBreakerOpen'
|
|
92
|
-
|
|
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.)
|
|
93
110
|
};
|
|
94
111
|
}
|
|
95
112
|
|
package/src/lib/idp-fetch.ts
CHANGED
|
@@ -3,12 +3,13 @@ import { getTokenTestAware } from './test-aware-get-token';
|
|
|
3
3
|
import { getSession } from './session-store';
|
|
4
4
|
import { internalRefresh } from './internal-api';
|
|
5
5
|
import { assertAbsoluteIdpUrl } from './idp-url-guard';
|
|
6
|
+
import { parseRetryAfterSeconds } from './retry-after';
|
|
6
7
|
|
|
7
8
|
// Re-exported so `idp-fetch` remains the single obvious import for IDP calling
|
|
8
9
|
// concerns; the implementation lives in a dependency-free leaf so the three
|
|
9
10
|
// direct-fetch callers (auth/jwks.ts, auth/passkey-signin.ts x2) do not have to
|
|
10
11
|
// pull this module's server-only chain in for one regex.
|
|
11
|
-
export { assertAbsoluteIdpUrl };
|
|
12
|
+
export { assertAbsoluteIdpUrl, parseRetryAfterSeconds };
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Centralized IDP fetch helper
|
|
@@ -21,6 +22,12 @@ export interface IdpFetchResult<T = any> {
|
|
|
21
22
|
status: number;
|
|
22
23
|
json: T | null;
|
|
23
24
|
attemptedRefresh: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* PAY-1608: the upstream Retry-After in whole seconds, when the IDP sent a usable one. Core sends it on every
|
|
27
|
+
* 2FA lock (MfaController.SecondFactorLocked: 429 + Retry-After 900; VerifyTotp: what is left of the lock).
|
|
28
|
+
* It used to be dropped here, so no BFF could state the real lock window. Absent otherwise.
|
|
29
|
+
*/
|
|
30
|
+
retryAfterSeconds?: number;
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
function buildHeaders(req: NextRequest, bearer?: string, extra?: Record<string, string>) {
|
|
@@ -158,5 +165,6 @@ export async function idpFetchJSON<T = any>(
|
|
|
158
165
|
} catch {}
|
|
159
166
|
}
|
|
160
167
|
|
|
161
|
-
|
|
168
|
+
const retryAfterSeconds = parseRetryAfterSeconds(res.headers?.get?.('Retry-After'));
|
|
169
|
+
return { ok: res.ok, status: res.status, json: json as T | null, attemptedRefresh, ...(retryAfterSeconds ? { retryAfterSeconds } : {}) };
|
|
162
170
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAY-1608: parse an HTTP Retry-After header to whole seconds.
|
|
3
|
+
*
|
|
4
|
+
* Accepts delta-seconds ("900") and an HTTP date in the future. Answers undefined for absent, zero, negative,
|
|
5
|
+
* fractional, past or malformed values - a caller that gets undefined states no window rather than a guessed one.
|
|
6
|
+
*
|
|
7
|
+
* A dependency-free leaf on purpose: idp-fetch re-exports it, but a test can load it without the session/redis
|
|
8
|
+
* graph idp-fetch pulls in.
|
|
9
|
+
*/
|
|
10
|
+
export function parseRetryAfterSeconds(value: string | null | undefined, nowMs: number = Date.now()): number | undefined {
|
|
11
|
+
if (typeof value !== 'string') return undefined;
|
|
12
|
+
const v = value.trim();
|
|
13
|
+
if (/^\d+$/.test(v)) {
|
|
14
|
+
const n = parseInt(v, 10);
|
|
15
|
+
return n > 0 ? n : undefined;
|
|
16
|
+
}
|
|
17
|
+
// Only something that looks like an HTTP date (it has letters: "Sat, 10 Oct 2026 12:01:30 GMT") is parsed as
|
|
18
|
+
// one; "-5" and "1.5" must not reach Date.parse, which would read them as years.
|
|
19
|
+
if (!/[a-z]/i.test(v)) return undefined;
|
|
20
|
+
const at = Date.parse(v);
|
|
21
|
+
if (!Number.isFinite(at)) return undefined;
|
|
22
|
+
const secs = Math.ceil((at - nowMs) / 1000);
|
|
23
|
+
return secs > 0 ? secs : undefined;
|
|
24
|
+
}
|
|
@@ -1,248 +1,277 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PAY-1443: the ONE way a 2FA verify handler answers an IDP rejection.
|
|
3
|
-
*
|
|
4
|
-
* Found live (portal.keelauth.com /account-auth/verify-code, 2026-09-17): a wrong
|
|
5
|
-
* authenticator code rendered as the banner text `{ message = Invalid authentication code }`.
|
|
6
|
-
* The handlers relayed `result.json?.message` verbatim, and the IDP's error text is not a
|
|
7
|
-
* user-facing string:
|
|
8
|
-
* - deployed IDP: the >=400 envelope sets Message = value.ToString() on the controller's
|
|
9
|
-
* `new { message = ... }`, producing C#'s anonymous-object ToString `{ message = X }`
|
|
10
|
-
* (or a DTO's TYPE NAME for a typed body);
|
|
11
|
-
* - IDP master (e03a0e449): no ToString, but the lookup is case-sensitive, so the envelope
|
|
12
|
-
* message is "An error occurred" and the real text sits only in error.details.message.
|
|
13
|
-
*
|
|
14
|
-
* So the upstream text is used ONLY TO CLASSIFY, never rendered. The caller gets:
|
|
15
|
-
* - the upstream HTTP status, unchanged (verify-code's 401 branch probes session liveness);
|
|
16
|
-
* - a stable `code` the UI can switch on;
|
|
17
|
-
* - one of the fixed sentences below.
|
|
18
|
-
*/
|
|
19
|
-
import { NextResponse } from 'next/server';
|
|
20
|
-
|
|
21
|
-
export type TwoFactorVerifyMethod = 'totp' | 'sms' | 'email' | 'backup_code';
|
|
22
|
-
|
|
23
|
-
export type TwoFactorVerifyFailureCode =
|
|
24
|
-
| 'INVALID_CODE'
|
|
25
|
-
| 'LOCKED'
|
|
26
|
-
| 'RATE_LIMITED'
|
|
27
|
-
| 'SESSION_REJECTED'
|
|
28
|
-
| 'STEP_UP_FACTOR_NOT_ALLOWED'
|
|
29
|
-
| 'VERIFY_UNAVAILABLE'
|
|
30
|
-
| 'VERIFY_FAILED';
|
|
31
|
-
|
|
32
|
-
const INVALID_CODE_TEXT: Record<TwoFactorVerifyMethod, string> = {
|
|
33
|
-
totp: 'Invalid authentication code. Please try again.',
|
|
34
|
-
sms: 'Invalid verification code. Please try again.',
|
|
35
|
-
email: 'Invalid verification code. Please try again.',
|
|
36
|
-
backup_code: 'Invalid backup code. Please try again.',
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
const FIXED_TEXT: Record<Exclude<TwoFactorVerifyFailureCode, 'INVALID_CODE' | 'STEP_UP_FACTOR_NOT_ALLOWED'>, string> = {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
*
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
export type
|
|
94
|
-
export type
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (typeof
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
add(j.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
// PAY-
|
|
176
|
-
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if (
|
|
186
|
-
|
|
187
|
-
if (
|
|
188
|
-
if (
|
|
189
|
-
return '
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
);
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
1
|
+
/**
|
|
2
|
+
* PAY-1443: the ONE way a 2FA verify handler answers an IDP rejection.
|
|
3
|
+
*
|
|
4
|
+
* Found live (portal.keelauth.com /account-auth/verify-code, 2026-09-17): a wrong
|
|
5
|
+
* authenticator code rendered as the banner text `{ message = Invalid authentication code }`.
|
|
6
|
+
* The handlers relayed `result.json?.message` verbatim, and the IDP's error text is not a
|
|
7
|
+
* user-facing string:
|
|
8
|
+
* - deployed IDP: the >=400 envelope sets Message = value.ToString() on the controller's
|
|
9
|
+
* `new { message = ... }`, producing C#'s anonymous-object ToString `{ message = X }`
|
|
10
|
+
* (or a DTO's TYPE NAME for a typed body);
|
|
11
|
+
* - IDP master (e03a0e449): no ToString, but the lookup is case-sensitive, so the envelope
|
|
12
|
+
* message is "An error occurred" and the real text sits only in error.details.message.
|
|
13
|
+
*
|
|
14
|
+
* So the upstream text is used ONLY TO CLASSIFY, never rendered. The caller gets:
|
|
15
|
+
* - the upstream HTTP status, unchanged (verify-code's 401 branch probes session liveness);
|
|
16
|
+
* - a stable `code` the UI can switch on;
|
|
17
|
+
* - one of the fixed sentences below.
|
|
18
|
+
*/
|
|
19
|
+
import { NextResponse } from 'next/server';
|
|
20
|
+
|
|
21
|
+
export type TwoFactorVerifyMethod = 'totp' | 'sms' | 'email' | 'backup_code';
|
|
22
|
+
|
|
23
|
+
export type TwoFactorVerifyFailureCode =
|
|
24
|
+
| 'INVALID_CODE'
|
|
25
|
+
| 'LOCKED'
|
|
26
|
+
| 'RATE_LIMITED'
|
|
27
|
+
| 'SESSION_REJECTED'
|
|
28
|
+
| 'STEP_UP_FACTOR_NOT_ALLOWED'
|
|
29
|
+
| 'VERIFY_UNAVAILABLE'
|
|
30
|
+
| 'VERIFY_FAILED';
|
|
31
|
+
|
|
32
|
+
const INVALID_CODE_TEXT: Record<TwoFactorVerifyMethod, string> = {
|
|
33
|
+
totp: 'Invalid authentication code. Please try again.',
|
|
34
|
+
sms: 'Invalid verification code. Please try again.',
|
|
35
|
+
email: 'Invalid verification code. Please try again.',
|
|
36
|
+
backup_code: 'Invalid backup code. Please try again.',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const FIXED_TEXT: Record<Exclude<TwoFactorVerifyFailureCode, 'INVALID_CODE' | 'STEP_UP_FACTOR_NOT_ALLOWED'>, string> = {
|
|
40
|
+
// PAY-1608: NO NUMBER HERE. This is the sentence for a lock whose window the IDP did not send; lockedText()
|
|
41
|
+
// states the real window when Retry-After arrives. A hard-coded "15 minutes" was true only while every lock was 15.
|
|
42
|
+
LOCKED: 'Too many incorrect codes. Verification is locked for now - even a correct code is refused until the lock ends. Wait before entering another code.',
|
|
43
|
+
RATE_LIMITED: 'Too many attempts. Please wait a moment and try again.',
|
|
44
|
+
SESSION_REJECTED: 'We could not verify the code for this session. Please try again.',
|
|
45
|
+
VERIFY_UNAVAILABLE: 'We could not verify the code right now. Please try again.',
|
|
46
|
+
VERIFY_FAILED: 'We could not verify that code. Please try again.',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* MfaTotpService ErrorCode "locked": the bare code, or `code = locked` inside a deployed
|
|
51
|
+
* ToString envelope.
|
|
52
|
+
*
|
|
53
|
+
* CAUGHT BEFORE SHIPPING (self-test, this file): an earlier draft wrote this with word
|
|
54
|
+
* boundaries around `code` and `locked`. The literal that landed on disk was not the two
|
|
55
|
+
* characters backslash+b - it was a raw ASCII backspace byte (0x08), invisible in every editor
|
|
56
|
+
* and terminal that rendered this file, INCLUDING Read/cat/grep in this session. A regex with a
|
|
57
|
+
* literal backspace control character where a word-boundary assertion was intended matches
|
|
58
|
+
* nothing real, so `IDP_LOCKED.test(...)` was silently false on every fixture - caught only
|
|
59
|
+
* because the RED-first test stayed red after the earlier "fix" and a hex dump of the compiled
|
|
60
|
+
* line showed 08 where 5c62 (backslash, b) should have been. Word boundaries are not
|
|
61
|
+
* load-bearing for the shipped control cases (a bare code=locked substring is specific enough
|
|
62
|
+
* here - the IdP field name is literally code), so this drops them rather than re-risk the same
|
|
63
|
+
* invisible-byte trap.
|
|
64
|
+
*/
|
|
65
|
+
const IDP_LOCKED = /^locked$|code\s*=\s*locked/i;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* MfaTotpService ErrorCode "server_error" (PAY-1589: secret cannot be decrypted) or "service_unavailable" (330830): the replay store could not be read or written, so
|
|
69
|
+
* the verify FAILED CLOSED. It is a fault on our side, not a wrong code. Matched on the CODE only, never on
|
|
70
|
+
* the prose - "unavailable" appears in unrelated messages - in the same two forms as IDP_LOCKED.
|
|
71
|
+
*/
|
|
72
|
+
const IDP_UNAVAILABLE = /^(?:service_unavailable|server_error)$|code\s*=\s*(?:service_unavailable|server_error)\b/i;
|
|
73
|
+
|
|
74
|
+
const WRONG_CODE = /\b(invalid|incorrect|wrong|expired)\b[^.]{0,40}\b(code|otp|totp|token)\b|\binvalid_code\b/i;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* PAY-1611: the IdP's 403 STEP_UP_FACTOR_NOT_ALLOWED refusal (Core ExternalAuthController.DeliveredCodeStepUpRefusal).
|
|
78
|
+
* A session signed in with an emailed/texted code only cannot re-prove itself with another emailed/texted code; the
|
|
79
|
+
* IdP says which factors it WILL take and what the user can do instead. The portal can only offer those options if
|
|
80
|
+
* this BFF relays them, so on that one refusal we emit the code plus ALLOWLISTED copies of the two lists.
|
|
81
|
+
*
|
|
82
|
+
* On the wire PayEzControllerBase.OnActionExecuted re-envelopes the action's object: the code is `error.code` and the
|
|
83
|
+
* object sits under `error.details`, so the lists are `error.details.allowed_methods` / `error.details.next_steps`.
|
|
84
|
+
* The unenveloped action shape (top-level `error` string, lists at top level) is accepted too.
|
|
85
|
+
*
|
|
86
|
+
* NEVER relayed (PAY-1443 stands): `error_description` or any other IdP prose, and any IdP `endpoint` - the consumer
|
|
87
|
+
* maps each action to its OWN route from a constant table. Unknown methods/actions are dropped, not passed through;
|
|
88
|
+
* missing or malformed lists give EMPTY lists, never invented ones.
|
|
89
|
+
*/
|
|
90
|
+
export const STEP_UP_REFUSAL_CODE = 'STEP_UP_FACTOR_NOT_ALLOWED';
|
|
91
|
+
const STEP_UP_METHODS = ['totp', 'webauthn', 'backup_code'] as const;
|
|
92
|
+
const STEP_UP_ACTIONS = ['use_authenticator', 'reset_password', 'enroll_authenticator'] as const;
|
|
93
|
+
export type StepUpMethod = (typeof STEP_UP_METHODS)[number];
|
|
94
|
+
export type StepUpAction = (typeof STEP_UP_ACTIONS)[number];
|
|
95
|
+
export type StepUpNextStep = { action: 'use_authenticator'; methods: StepUpMethod[] } | { action: Exclude<StepUpAction, 'use_authenticator'> };
|
|
96
|
+
export type StepUpRefusal = { allowed_methods: StepUpMethod[]; next_steps: StepUpNextStep[] };
|
|
97
|
+
|
|
98
|
+
const STEP_UP_TEXT =
|
|
99
|
+
'This session was signed in with an emailed or texted code only, so another emailed or texted code cannot confirm it is you.';
|
|
100
|
+
|
|
101
|
+
function allowedMethods(v: unknown): StepUpMethod[] {
|
|
102
|
+
if (!Array.isArray(v)) return [];
|
|
103
|
+
const out: StepUpMethod[] = [];
|
|
104
|
+
for (const m of v) {
|
|
105
|
+
if ((STEP_UP_METHODS as readonly unknown[]).includes(m) && !out.includes(m as StepUpMethod)) out.push(m as StepUpMethod);
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The step-up refusal's allowlisted lists, or null when this is not that refusal (status 403 + the exact code only). */
|
|
111
|
+
export function stepUpRefusal(status: number, json: unknown): StepUpRefusal | null {
|
|
112
|
+
if (status !== 403 || !json || typeof json !== 'object') return null;
|
|
113
|
+
const j = json as Record<string, unknown>;
|
|
114
|
+
const err = j.error;
|
|
115
|
+
let code: unknown;
|
|
116
|
+
let src: Record<string, unknown> = j;
|
|
117
|
+
if (err && typeof err === 'object') {
|
|
118
|
+
const e = err as Record<string, unknown>;
|
|
119
|
+
code = e.code;
|
|
120
|
+
src = e.details && typeof e.details === 'object' ? (e.details as Record<string, unknown>) : {};
|
|
121
|
+
} else {
|
|
122
|
+
code = err;
|
|
123
|
+
}
|
|
124
|
+
if (code !== STEP_UP_REFUSAL_CODE) return null;
|
|
125
|
+
const next_steps: StepUpNextStep[] = [];
|
|
126
|
+
const seen = new Set<string>();
|
|
127
|
+
for (const step of Array.isArray(src.next_steps) ? src.next_steps : []) {
|
|
128
|
+
if (!step || typeof step !== 'object') continue;
|
|
129
|
+
const action = (step as Record<string, unknown>).action;
|
|
130
|
+
if (typeof action !== 'string' || !(STEP_UP_ACTIONS as readonly string[]).includes(action) || seen.has(action)) continue;
|
|
131
|
+
seen.add(action);
|
|
132
|
+
next_steps.push(
|
|
133
|
+
action === 'use_authenticator'
|
|
134
|
+
? { action, methods: allowedMethods((step as Record<string, unknown>).methods) }
|
|
135
|
+
: { action: action as Exclude<StepUpAction, 'use_authenticator'> },
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return { allowed_methods: allowedMethods(src.allowed_methods), next_steps };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function stepUpResponse(refusal: StepUpRefusal, attemptedRefresh: boolean): NextResponse {
|
|
142
|
+
return NextResponse.json(
|
|
143
|
+
{ success: false, error: STEP_UP_TEXT, code: STEP_UP_REFUSAL_CODE, ...refusal, meta: { attemptedRefresh } },
|
|
144
|
+
{ status: 403 },
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Every string the IDP might have put anywhere in the error envelope, unwrapped from
|
|
150
|
+
* `{ message = X }` ToString form. Classification input only.
|
|
151
|
+
*/
|
|
152
|
+
export function upstreamErrorTexts(json: unknown): string[] {
|
|
153
|
+
const out: string[] = [];
|
|
154
|
+
const add = (v: unknown) => {
|
|
155
|
+
if (typeof v !== 'string' || !v) return;
|
|
156
|
+
out.push(v);
|
|
157
|
+
const m = v.match(/^\{\s*\w+\s*=\s*(.*?)\s*\}$/s);
|
|
158
|
+
if (m) out.push(m[1]);
|
|
159
|
+
};
|
|
160
|
+
const j = json as Record<string, any> | null | undefined;
|
|
161
|
+
if (!j || typeof j !== 'object') return out;
|
|
162
|
+
add(j.message);
|
|
163
|
+
add(j.code);
|
|
164
|
+
add(j.error);
|
|
165
|
+
if (j.error && typeof j.error === 'object') {
|
|
166
|
+
add(j.error.message);
|
|
167
|
+
add(j.error.code);
|
|
168
|
+
const d = j.error.details;
|
|
169
|
+
if (d && typeof d === 'object') { add(d.message); add(d.Message); add(d.code); add(d.errorCode); }
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function classifyTwoFactorVerifyFailure(status: number, json: unknown): TwoFactorVerifyFailureCode {
|
|
175
|
+
// PAY-1611: checked FIRST - an exact code on a 403, so no prose rule below can claim it first.
|
|
176
|
+
if (stepUpRefusal(status, json)) return 'STEP_UP_FACTOR_NOT_ALLOWED';
|
|
177
|
+
// PAY-902 (BAPert R484): the IdP's lockout CODE, not its prose. Door A is post-authentication, so stating
|
|
178
|
+
// the lockout discloses nothing; telling a locked-out user "try again" sends them to spend attempts.
|
|
179
|
+
//
|
|
180
|
+
// PAY-1574: THIS MUST COME BEFORE THE 429 TEST. PayEz-Core PR 366 answers a TOTP lockout as HTTP 429 carrying
|
|
181
|
+
// code "locked". With `status === 429` first, that status returned RATE_LIMITED and the lock code the IdP had
|
|
182
|
+
// just started sending was discarded unread - the tester saw a plain rate limit. A 429 with NO lock code is
|
|
183
|
+
// still RATE_LIMITED, below.
|
|
184
|
+
if (upstreamErrorTexts(json).some((t) => IDP_LOCKED.test(t))) return 'LOCKED';
|
|
185
|
+
if (status === 429) return 'RATE_LIMITED';
|
|
186
|
+
// PAY-1574: the verify failed closed on OUR side (replay store down). Not a wrong code, whatever the status.
|
|
187
|
+
if (upstreamErrorTexts(json).some((t) => IDP_UNAVAILABLE.test(t))) return 'VERIFY_UNAVAILABLE';
|
|
188
|
+
if (upstreamErrorTexts(json).some((t) => WRONG_CODE.test(t))) return 'INVALID_CODE';
|
|
189
|
+
if (status === 401 || status === 403) return 'SESSION_REJECTED';
|
|
190
|
+
if (status >= 500 || status === 0) return 'VERIFY_UNAVAILABLE';
|
|
191
|
+
return 'VERIFY_FAILED';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* PAY-1608: the window as words - "15 minutes", "1 minute", "45 seconds", "2 hours". Rounded UP, so the user is never
|
|
196
|
+
* told a shorter wait than the IDP enforces. Undefined in, undefined out.
|
|
197
|
+
*/
|
|
198
|
+
export function formatLockWindow(seconds: number | undefined): string | undefined {
|
|
199
|
+
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds <= 0) return undefined;
|
|
200
|
+
const plural = (n: number, unit: string) => `${n} ${unit}${n === 1 ? '' : 's'}`;
|
|
201
|
+
if (seconds < 60) return plural(Math.ceil(seconds), 'second');
|
|
202
|
+
const minutes = Math.ceil(seconds / 60);
|
|
203
|
+
if (minutes < 120) return plural(minutes, 'minute');
|
|
204
|
+
return plural(Math.ceil(minutes / 60), 'hour');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** PAY-1608: the LOCKED sentence with the IDP's real window, or the number-free one when none was sent. */
|
|
208
|
+
export function lockedText(retryAfterSeconds?: number): string {
|
|
209
|
+
const window = formatLockWindow(retryAfterSeconds);
|
|
210
|
+
return window
|
|
211
|
+
? `Too many incorrect codes. Verification is locked for ${window} - wait before entering another code.`
|
|
212
|
+
: FIXED_TEXT.LOCKED;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function twoFactorVerifyFailure(
|
|
216
|
+
result: { status: number; json: unknown; attemptedRefresh: boolean; retryAfterSeconds?: number },
|
|
217
|
+
method: TwoFactorVerifyMethod,
|
|
218
|
+
): NextResponse {
|
|
219
|
+
const refusal = stepUpRefusal(result.status, result.json);
|
|
220
|
+
if (refusal) return stepUpResponse(refusal, result.attemptedRefresh);
|
|
221
|
+
const code = classifyTwoFactorVerifyFailure(result.status, result.json);
|
|
222
|
+
const error = code === 'INVALID_CODE' ? INVALID_CODE_TEXT[method]
|
|
223
|
+
: code === 'LOCKED' ? lockedText(result.retryAfterSeconds)
|
|
224
|
+
: FIXED_TEXT[code as keyof typeof FIXED_TEXT];
|
|
225
|
+
// PAY-1608: a lock or a throttle relays the IDP's window, as a number (for a client that words it itself) and as the
|
|
226
|
+
// standard header. Never on other failures, and never invented: absent upstream means absent here.
|
|
227
|
+
const wait = (code === 'LOCKED' || code === 'RATE_LIMITED') && formatLockWindow(result.retryAfterSeconds)
|
|
228
|
+
? Math.ceil(result.retryAfterSeconds as number) : undefined;
|
|
229
|
+
return NextResponse.json(
|
|
230
|
+
{ success: false, error, code, ...(wait ? { retryAfterSeconds: wait } : {}), meta: { attemptedRefresh: result.attemptedRefresh } },
|
|
231
|
+
{ status: result.status, ...(wait ? { headers: { 'Retry-After': String(wait) } } : {}) },
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* PAY-1443 residual: account/send-code on the SAME verify-code screen. A send has no
|
|
237
|
+
* "wrong code" outcome, so it gets its own small classification, but the same rule applies:
|
|
238
|
+
* upstream text/code never reach the response. The portal renders `data.message || data.error`,
|
|
239
|
+
* so no `message` field is emitted on failure.
|
|
240
|
+
*/
|
|
241
|
+
export type TwoFactorSendMethod = 'sms' | 'email';
|
|
242
|
+
|
|
243
|
+
export type TwoFactorSendFailureCode =
|
|
244
|
+
| 'RATE_LIMITED'
|
|
245
|
+
| 'SESSION_REJECTED'
|
|
246
|
+
| 'STEP_UP_FACTOR_NOT_ALLOWED'
|
|
247
|
+
| 'SEND_UNAVAILABLE'
|
|
248
|
+
| 'SEND_FAILED';
|
|
249
|
+
|
|
250
|
+
const SEND_LABEL: Record<TwoFactorSendMethod, string> = { sms: 'text message', email: 'email' };
|
|
251
|
+
|
|
252
|
+
export function classifyTwoFactorSendFailure(status: number, json?: unknown): TwoFactorSendFailureCode {
|
|
253
|
+
if (stepUpRefusal(status, json)) return 'STEP_UP_FACTOR_NOT_ALLOWED'; // PAY-1611, before the 401/403 branch
|
|
254
|
+
if (status === 429) return 'RATE_LIMITED';
|
|
255
|
+
if (status === 401 || status === 403) return 'SESSION_REJECTED';
|
|
256
|
+
if (status >= 500 || status === 0) return 'SEND_UNAVAILABLE';
|
|
257
|
+
return 'SEND_FAILED';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function twoFactorSendCodeFailure(
|
|
261
|
+
result: { status: number; attemptedRefresh: boolean; json?: unknown },
|
|
262
|
+
method: TwoFactorSendMethod,
|
|
263
|
+
): NextResponse {
|
|
264
|
+
const refusal = stepUpRefusal(result.status, result.json);
|
|
265
|
+
if (refusal) return stepUpResponse(refusal, result.attemptedRefresh);
|
|
266
|
+
const code = classifyTwoFactorSendFailure(result.status, result.json);
|
|
267
|
+
const label = SEND_LABEL[method];
|
|
268
|
+
const error =
|
|
269
|
+
code === 'RATE_LIMITED' ? FIXED_TEXT.RATE_LIMITED
|
|
270
|
+
: code === 'SESSION_REJECTED' ? `We could not send a ${label} code for this session. Please try again.`
|
|
271
|
+
: code === 'SEND_UNAVAILABLE' ? `We could not send a ${label} code right now. Please try again.`
|
|
272
|
+
: `We could not send a ${label} code. Please try again.`;
|
|
273
|
+
return NextResponse.json(
|
|
274
|
+
{ success: false, error, code, meta: { attemptedRefresh: result.attemptedRefresh } },
|
|
275
|
+
{ status: result.status },
|
|
276
|
+
);
|
|
277
|
+
}
|
|
@@ -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 {
|