@payez/next-mvp 4.7.10 → 4.8.1
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/api-handlers/session/viability.js +3 -3
- package/dist/auth/auth-decision.d.ts +3 -0
- package/dist/auth/auth-decision.js +9 -0
- package/dist/auth/revocation-fields.d.ts +16 -0
- package/dist/auth/revocation-fields.js +19 -0
- package/dist/middleware/create-middleware.d.ts +49 -19
- package/dist/middleware/create-middleware.js +66 -29
- package/dist/pages/service-unavailable/page.d.ts +15 -0
- package/dist/pages/service-unavailable/page.js +41 -0
- package/dist/routes/auth/viability.d.ts +19 -9
- package/dist/routes/auth/viability.js +5 -6
- package/package.json +6 -1
- package/src/api-handlers/session/viability.ts +3 -3
- package/src/auth/auth-decision.ts +13 -1
- package/src/auth/revocation-fields.ts +16 -0
- package/src/middleware/create-middleware.ts +118 -42
- package/src/pages/service-unavailable/page.tsx +63 -0
- package/src/routes/auth/viability.ts +5 -6
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
8
|
exports.GET = GET;
|
|
9
|
+
const revocation_fields_1 = require("../../auth/revocation-fields");
|
|
9
10
|
const server_1 = require("next/server");
|
|
10
11
|
const session_store_1 = require("../../lib/session-store");
|
|
11
12
|
const auth_1 = require("../../server/auth");
|
|
@@ -45,7 +46,7 @@ async function GET(req) {
|
|
|
45
46
|
const sessionState = await (0, auth_1.resolveSessionState)(req);
|
|
46
47
|
if (sessionState.state === 'unknown') {
|
|
47
48
|
console.warn('[VIABILITY] Session store unavailable - session state unknown');
|
|
48
|
-
return server_1.NextResponse.json({ authenticated: false, revocationStatus: 'unknown', error: 'Session store unavailable', code: 'STORE_UNAVAILABLE' }, { status: 500 });
|
|
49
|
+
return server_1.NextResponse.json({ authenticated: false, revocationStatus: 'unknown', unavailable: true, error: 'Session store unavailable', code: 'STORE_UNAVAILABLE' }, { status: 500 });
|
|
49
50
|
}
|
|
50
51
|
if (sessionState.state === 'missing') {
|
|
51
52
|
console.warn('[VIABILITY] Stale cookie detected - verified cookie, session not in Redis');
|
|
@@ -174,8 +175,7 @@ async function GET(req) {
|
|
|
174
175
|
// Session revocation (PAY-1882 step 4): the four-state status, plus the
|
|
175
176
|
// boolean the middleware's existing force-invalid branch already reads.
|
|
176
177
|
revocationStatus,
|
|
177
|
-
|
|
178
|
-
revoked: revocationStatus === 'revoked',
|
|
178
|
+
...(0, revocation_fields_1.revocationFields)(revocationStatus), // PAY-2339: forceInvalid only for 'revoked'; unavailable:true for 'unknown'
|
|
179
179
|
// Cards 141688/141741 — the IDP's routing decision, relayed verbatim.
|
|
180
180
|
//
|
|
181
181
|
// THIS IS THE SECOND IMPLEMENTATION OF VIABILITY. The other is
|
|
@@ -17,6 +17,8 @@ export interface AuthContext {
|
|
|
17
17
|
sessionStatus: {
|
|
18
18
|
exists: boolean | null;
|
|
19
19
|
forceInvalid: boolean | null;
|
|
20
|
+
/** PAY-2339: the producer said the revocation backend could not answer. makeAuthDecision HOLDS (no sign-out). */
|
|
21
|
+
unavailable?: boolean;
|
|
20
22
|
requires2FA: boolean;
|
|
21
23
|
twoFactorComplete: boolean;
|
|
22
24
|
};
|
|
@@ -34,6 +36,7 @@ export type AuthAction = {
|
|
|
34
36
|
} | {
|
|
35
37
|
type: 'service_error';
|
|
36
38
|
reason: string;
|
|
39
|
+
check?: 'revocation' | 'viability';
|
|
37
40
|
};
|
|
38
41
|
/**
|
|
39
42
|
* Main decision function - pure, testable, deterministic.
|
|
@@ -94,6 +94,15 @@ function decide(context) {
|
|
|
94
94
|
reason: 'redis_unavailable'
|
|
95
95
|
};
|
|
96
96
|
}
|
|
97
|
+
// PAY-2339 (BAPert 74633): the revocation backend could not answer. Hold: not a verdict on the session, so no
|
|
98
|
+
// sign-out and no cookie is touched. A caller maps this to its own "temporarily unavailable" answer (the
|
|
99
|
+
// createMvpMiddleware twin sends /service-unavailable or a 503 with unavailable:true, check:'revocation').
|
|
100
|
+
if (sessionStatus.unavailable) {
|
|
101
|
+
if (isLoginPage) {
|
|
102
|
+
return { type: 'allow' };
|
|
103
|
+
}
|
|
104
|
+
return { type: 'service_error', reason: 'revocation_unknown', check: 'revocation' };
|
|
105
|
+
}
|
|
97
106
|
// Session force invalidated - need fresh login
|
|
98
107
|
if (sessionStatus.forceInvalid) {
|
|
99
108
|
if (isLoginPage) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAY-2339 (BAPert 74633 gap 1): the two revocation fields every viability producer emits, in ONE place so the
|
|
3
|
+
* producers (api-handlers/session/viability.ts and routes/auth/viability.ts, four return sites) cannot drift.
|
|
4
|
+
*
|
|
5
|
+
* forceInvalid - true ONLY for 'revoked'. An 'unknown' answer is no longer a sign-out verdict.
|
|
6
|
+
* unavailable - true when the revocation backend could not answer: 'unknown' and ANY string that is not one of the
|
|
7
|
+
* three recognised states. A consumer that reads this JSON (useSessionCheck, makeAuthDecision) holds
|
|
8
|
+
* instead of signing the member out. `revocationStatus` itself is emitted unchanged.
|
|
9
|
+
*
|
|
10
|
+
* 'not_configured' is a configuration fault, not an outage: neither field is set for it (unchanged).
|
|
11
|
+
*/
|
|
12
|
+
export declare function revocationFields(status: unknown): {
|
|
13
|
+
forceInvalid: boolean;
|
|
14
|
+
unavailable: boolean;
|
|
15
|
+
revoked: boolean;
|
|
16
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.revocationFields = revocationFields;
|
|
4
|
+
/**
|
|
5
|
+
* PAY-2339 (BAPert 74633 gap 1): the two revocation fields every viability producer emits, in ONE place so the
|
|
6
|
+
* producers (api-handlers/session/viability.ts and routes/auth/viability.ts, four return sites) cannot drift.
|
|
7
|
+
*
|
|
8
|
+
* forceInvalid - true ONLY for 'revoked'. An 'unknown' answer is no longer a sign-out verdict.
|
|
9
|
+
* unavailable - true when the revocation backend could not answer: 'unknown' and ANY string that is not one of the
|
|
10
|
+
* three recognised states. A consumer that reads this JSON (useSessionCheck, makeAuthDecision) holds
|
|
11
|
+
* instead of signing the member out. `revocationStatus` itself is emitted unchanged.
|
|
12
|
+
*
|
|
13
|
+
* 'not_configured' is a configuration fault, not an outage: neither field is set for it (unchanged).
|
|
14
|
+
*/
|
|
15
|
+
function revocationFields(status) {
|
|
16
|
+
const revoked = status === 'revoked';
|
|
17
|
+
const unavailable = !(status === 'revoked' || status === 'active' || status === 'not_configured');
|
|
18
|
+
return { forceInvalid: revoked, unavailable, revoked };
|
|
19
|
+
}
|
|
@@ -35,9 +35,13 @@ export interface SessionPointer {
|
|
|
35
35
|
roles: string[];
|
|
36
36
|
clientId: string;
|
|
37
37
|
}
|
|
38
|
+
/** PAY-2339: which check could not answer. A fixed, machine-readable value: consumers never parse message text. */
|
|
39
|
+
export type UnavailableCheck = 'revocation' | 'viability';
|
|
38
40
|
export interface SessionStatus {
|
|
39
41
|
exists: boolean | null;
|
|
40
42
|
forceInvalid: boolean | null;
|
|
43
|
+
/** PAY-2339: the revocation backend could not answer and this app HOLDS (revocationFailMode 'hold'): no sign-out. */
|
|
44
|
+
unavailable?: boolean;
|
|
41
45
|
requires2FA: boolean;
|
|
42
46
|
twoFactorComplete: boolean;
|
|
43
47
|
}
|
|
@@ -51,6 +55,7 @@ export type AuthAction = {
|
|
|
51
55
|
} | {
|
|
52
56
|
type: 'service_error';
|
|
53
57
|
reason: string;
|
|
58
|
+
check?: UnavailableCheck;
|
|
54
59
|
} | {
|
|
55
60
|
type: 'refresh_needed';
|
|
56
61
|
};
|
|
@@ -113,29 +118,41 @@ export interface MvpMiddlewareOptions {
|
|
|
113
118
|
*/
|
|
114
119
|
sessionResetRoute?: boolean | string;
|
|
115
120
|
/**
|
|
116
|
-
* PAY-1882 step 4 - how this app treats a producer that reports the session's
|
|
121
|
+
* PAY-1882 step 4 / PAY-2339 - how this app treats a producer that reports the session's
|
|
117
122
|
* revocation status as `'unknown'` (a Vibe read that FAILED: non-OK, timeout,
|
|
118
123
|
* throw - the status could not be determined).
|
|
119
124
|
*
|
|
120
|
-
* '
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
125
|
+
* 'hold' (DEFAULT since PAY-2339, Jon 2026-10-13, option C) - the member is NOT signed out. The request is
|
|
126
|
+
* answered "temporarily unavailable": a page goes to /service-unavailable?callbackUrl=
|
|
127
|
+
* (the session cookie is NOT touched, so "Try again" returns the member still signed in);
|
|
128
|
+
* an /api request gets a 503 carrying `unavailable: true` and `check: 'revocation'`, a
|
|
129
|
+
* fixed machine-readable value. Nothing protected is served while the answer is unknown.
|
|
130
|
+
* Each held request logs one countable `revocation_unavailable` line.
|
|
131
|
+
* THIS REPLACES THE OLD DEFAULT: the PAY-1880 ruling (Jon, 2026-09-27, option A) made
|
|
132
|
+
* 'closed' the default and accepted that a Vibe outage signs an app's users out. That
|
|
133
|
+
* sentence no longer applies to 'unknown' by default: a Vibe outage no longer signs members
|
|
134
|
+
* out unless the app asks for 'closed'. 'revoked' is unchanged.
|
|
135
|
+
* 'closed' - an unknown revocation status is NOT viable; the session is force-invalidated and the
|
|
136
|
+
* user re-auths (TODAY'S sign-out, now opt-in by name). Consequence, accepted explicitly:
|
|
137
|
+
* a Vibe outage signs this app's users out.
|
|
138
|
+
* 'open' - an unknown status stays viable (availability over fail-closed). Per-app opt-out for a
|
|
139
|
+
* deployment with no revocation source.
|
|
133
140
|
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
141
|
+
* `'not_configured'` is UNCHANGED under the default: it is a configuration fault, not an outage, so it still
|
|
142
|
+
* force-invalidates exactly as before (a "try again" page would be false); `noRevocationSource` is the only way
|
|
143
|
+
* to make it viable. Neither mode changes `'revoked'` (always force-invalid, unchanged) or an ABSENT
|
|
144
|
+
* `revocationStatus` (an older producer - unchanged behaviour, so no consumer is signed out for not
|
|
145
|
+
* implementing the check). A consumer that wrote 'closed' or 'open' by name keeps exactly what it asked for.
|
|
137
146
|
*/
|
|
138
|
-
revocationFailMode?: 'closed' | 'open';
|
|
147
|
+
revocationFailMode?: 'hold' | 'closed' | 'open';
|
|
148
|
+
/**
|
|
149
|
+
* PAY-2339 (BAPert 74640): where a page request goes while a revocation answer is held. DEFAULT (absent): the
|
|
150
|
+
* middleware answers inline with a minimal 503 HTML page ("Temporarily unavailable", a "Try again" link to the safe
|
|
151
|
+
* callback, no Set-Cookie), so a consumer that never added a page cannot get a 404. Name a same-site path (e.g.
|
|
152
|
+
* '/service-unavailable') to redirect there instead with `?callbackUrl=`; the consumer then mounts
|
|
153
|
+
* `export { default } from '@payez/next-mvp/pages/service-unavailable'` (or its own page) at that route.
|
|
154
|
+
*/
|
|
155
|
+
unavailablePage?: string;
|
|
139
156
|
/**
|
|
140
157
|
* PAY-2302 cut 1c (BAPert 73585): this app has NO revocation source - for example a development environment
|
|
141
158
|
* provisioned without a vibe client id / HMAC key - and says so EXPLICITLY. Under it a viability answer of
|
|
@@ -173,8 +190,21 @@ export declare function createMvpMiddleware(options?: MvpMiddlewareOptions): (re
|
|
|
173
190
|
* 'unknown'/'not_configured'. The booleans are read ONLY when revocationStatus is
|
|
174
191
|
* absent (idealresume.online's older boolean-only route).
|
|
175
192
|
*/
|
|
193
|
+
export type RevocationOutcome = 'viable' | 'invalid' | 'hold';
|
|
194
|
+
/**
|
|
195
|
+
* PAY-2339: the three-way form of the revocation decision. `decideForceInvalid` below is kept as its boolean view.
|
|
196
|
+
*
|
|
197
|
+
* 'invalid' - sign the member out (cookies cleared, login page)
|
|
198
|
+
* 'hold' - the backend could not answer and this app holds: no sign-out, answer "temporarily unavailable"
|
|
199
|
+
* 'viable' - carry on
|
|
200
|
+
*/
|
|
201
|
+
export declare function decideRevocationOutcome(data: {
|
|
202
|
+
revocationStatus?: unknown;
|
|
203
|
+
forceInvalid?: unknown;
|
|
204
|
+
revoked?: unknown;
|
|
205
|
+
}, revocationFailMode: 'hold' | 'closed' | 'open', noRevocationSource?: boolean): RevocationOutcome;
|
|
176
206
|
export declare function decideForceInvalid(data: {
|
|
177
207
|
revocationStatus?: unknown;
|
|
178
208
|
forceInvalid?: unknown;
|
|
179
209
|
revoked?: unknown;
|
|
180
|
-
}, revocationFailMode: 'closed' | 'open', noRevocationSource?: boolean): boolean;
|
|
210
|
+
}, revocationFailMode: 'hold' | 'closed' | 'open', noRevocationSource?: boolean): boolean;
|
|
@@ -21,6 +21,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
21
21
|
exports.TWO_FACTOR_TIMEOUT_ERROR = void 0;
|
|
22
22
|
exports.isPrefetchRequest = isPrefetchRequest;
|
|
23
23
|
exports.createMvpMiddleware = createMvpMiddleware;
|
|
24
|
+
exports.decideRevocationOutcome = decideRevocationOutcome;
|
|
24
25
|
exports.decideForceInvalid = decideForceInvalid;
|
|
25
26
|
const server_1 = require("next/server");
|
|
26
27
|
const route_config_1 = require("../auth/route-config");
|
|
@@ -185,13 +186,32 @@ async function refreshFailureResponse(request, location, errorCode, clearCookies
|
|
|
185
186
|
* requested path as `callbackUrl` so "Try again" returns the user to it still signed in. The path goes
|
|
186
187
|
* through safeCallbackPath (URL-parser origin check): an off-origin or auth-surface value is dropped.
|
|
187
188
|
*/
|
|
188
|
-
function serviceUnavailableResponse(request) {
|
|
189
|
+
function serviceUnavailableResponse(request, check = 'viability', unavailablePage) {
|
|
189
190
|
const { pathname, search } = request.nextUrl;
|
|
190
191
|
if (pathname.startsWith('/api/')) {
|
|
191
|
-
|
|
192
|
+
// PAY-2339 P1/P12: one fixed machine-readable shape for every "could not check" answer. No protected data.
|
|
193
|
+
return server_1.NextResponse.json({ error: 'ServiceUnavailable', unavailable: true, check }, { status: 503 });
|
|
192
194
|
}
|
|
193
|
-
const target = new URL('/service-unavailable', request.url);
|
|
194
195
|
const callback = (0, safe_callback_1.safeCallbackPath)(pathname + search);
|
|
196
|
+
// PAY-2339 (BAPert 74640, P13): a HELD revocation answer needs no page in the consumer. Unless the app names its own
|
|
197
|
+
// page (`unavailablePage`), the middleware answers inline: a minimal 503 HTML, a "try again" link to the safe
|
|
198
|
+
// callback, and no Set-Cookie (nothing here touches the session). No redirect, so no 404 is possible.
|
|
199
|
+
// `unavailablePage` is accepted only as a PLAIN path ([A-Za-z0-9_-/]; no query, fragment, %-encoding, dot segment or
|
|
200
|
+
// scheme), so the self-check below compares like with like and a mis-set value falls back to the inline page (Scout 74668).
|
|
201
|
+
// PAY-2345: the viability-FAILURE arm (500 / timeout / breaker) takes the same path, so an app with no page of its own
|
|
202
|
+
// gets the inline 503 instead of a 404 during an outage. The two arms differ only in `check` (JSON body for /api/*). If the
|
|
203
|
+
// request is already FOR the named page (an app that did not register it as public), answer inline: redirecting to
|
|
204
|
+
// it again would loop with a longer callbackUrl each time (QAPert 74651).
|
|
205
|
+
const named = unavailablePage && /^\/[A-Za-z0-9_\-\/]*$/.test(unavailablePage) && !unavailablePage.includes('//') ? unavailablePage : null;
|
|
206
|
+
if (!named || pathname.replace(/\/+$/, '') === named.replace(/\/+$/, '')) {
|
|
207
|
+
const href = callback.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
208
|
+
return new server_1.NextResponse('<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Temporarily unavailable</title>' +
|
|
209
|
+
'<main role="status" style="font-family:system-ui,sans-serif;max-width:28rem;margin:20vh auto;padding:0 1rem;text-align:center">' +
|
|
210
|
+
'<h1 style="font-size:1.25rem">Temporarily unavailable</h1>' +
|
|
211
|
+
'<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>' +
|
|
212
|
+
'<p><a href="' + href + '">Try again</a></p></main>', { status: 503, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'retry-after': '30' } });
|
|
213
|
+
}
|
|
214
|
+
const target = new URL(named || '/service-unavailable', request.url);
|
|
195
215
|
if (callback !== '/')
|
|
196
216
|
target.searchParams.set('callbackUrl', callback);
|
|
197
217
|
return server_1.NextResponse.redirect(target);
|
|
@@ -264,6 +284,14 @@ function makeAuthDecision(ctx) {
|
|
|
264
284
|
return { type: 'allow' }; // the login page must stay reachable while the check is down
|
|
265
285
|
return { type: 'service_error', reason: 'viability_check_failed' };
|
|
266
286
|
}
|
|
287
|
+
// --- PAY-2339: the revocation backend could not answer and this app HOLDS (the default) ---
|
|
288
|
+
// Not a verdict on the session: no cookie is touched, nothing protected is served, and the answer says why in a
|
|
289
|
+
// fixed machine-readable form. The login page stays reachable.
|
|
290
|
+
if (sessionStatus.unavailable) {
|
|
291
|
+
if (onLoginPage)
|
|
292
|
+
return { type: 'allow' };
|
|
293
|
+
return { type: 'service_error', reason: 'revocation_unknown', check: 'revocation' };
|
|
294
|
+
}
|
|
267
295
|
// --- Session force-invalidated (admin action, password change, etc.) ---
|
|
268
296
|
if (sessionStatus.forceInvalid) {
|
|
269
297
|
if (onLoginPage)
|
|
@@ -380,8 +408,9 @@ function createMvpMiddleware(options = {}) {
|
|
|
380
408
|
const refreshEndpoint = options.refreshEndpoint || '/api/auth/refresh';
|
|
381
409
|
const bypassPaths = options.bypassPaths || [];
|
|
382
410
|
const rbacExemptPaths = options.rbacExemptPaths || [];
|
|
383
|
-
// PAY-
|
|
384
|
-
|
|
411
|
+
// PAY-2339: the default is 'hold' (Jon 2026-10-13, option C): an unknown revocation answer does not sign the member
|
|
412
|
+
// out. PAY-1882 step 4 had 'closed' (Jon's PAY-1880 option A, BAPert 66316); it is now opt-in by name.
|
|
413
|
+
const revocationFailMode = options.revocationFailMode || 'hold';
|
|
385
414
|
// PAY-2302 cut 1c: strictly `=== true`, so a truthy string in a config file cannot switch revocation off.
|
|
386
415
|
const noRevocationSource = options.noRevocationSource === true;
|
|
387
416
|
if (noRevocationSource) {
|
|
@@ -499,42 +528,44 @@ function createMvpMiddleware(options = {}) {
|
|
|
499
528
|
refreshEndpoint,
|
|
500
529
|
rbacExemptPaths,
|
|
501
530
|
reset: resolveReset(),
|
|
531
|
+
unavailablePage: options.unavailablePage,
|
|
502
532
|
onRefreshSuccess: options.onRefreshSuccess,
|
|
503
533
|
onRefreshFailure: options.onRefreshFailure,
|
|
504
534
|
});
|
|
505
535
|
};
|
|
506
536
|
}
|
|
507
537
|
/**
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
* Separate + exported so the B1 defect is testable with a REAL mutant: the bug was
|
|
511
|
-
* that `data.forceInvalid === true` was read BEFORE revocationFailMode, so a
|
|
512
|
-
* producer that folds 'unknown' into forceInvalid made the 'open' lever dead.
|
|
538
|
+
* PAY-2339: the three-way form of the revocation decision. `decideForceInvalid` below is kept as its boolean view.
|
|
513
539
|
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
* absent (idealresume.online's older boolean-only route).
|
|
540
|
+
* 'invalid' - sign the member out (cookies cleared, login page)
|
|
541
|
+
* 'hold' - the backend could not answer and this app holds: no sign-out, answer "temporarily unavailable"
|
|
542
|
+
* 'viable' - carry on
|
|
518
543
|
*/
|
|
519
|
-
function
|
|
544
|
+
function decideRevocationOutcome(data, revocationFailMode, noRevocationSource = false) {
|
|
520
545
|
const status = data.revocationStatus;
|
|
521
546
|
if (typeof status === 'string') {
|
|
522
547
|
if (status === 'revoked')
|
|
523
|
-
return
|
|
548
|
+
return 'invalid';
|
|
524
549
|
if (status === 'active')
|
|
525
|
-
return
|
|
550
|
+
return 'viable';
|
|
526
551
|
// PAY-2302 cut 1c: an app that declared it has no revocation source. ONLY the exact 'not_configured' string.
|
|
527
552
|
if (status === 'not_configured' && noRevocationSource === true)
|
|
528
|
-
return
|
|
529
|
-
//
|
|
530
|
-
//
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
//
|
|
534
|
-
|
|
553
|
+
return 'viable';
|
|
554
|
+
// PAY-2339: 'not_configured' is a configuration fault, not an outage. Under the default it stays signed out
|
|
555
|
+
// exactly as before; only 'open' relaxes it (a Vibe-less app).
|
|
556
|
+
if (status === 'not_configured')
|
|
557
|
+
return revocationFailMode === 'open' ? 'viable' : 'invalid';
|
|
558
|
+
// S1 (NightHawk 66409): ANY other string - 'unknown', a typo like 'Revoked', 'error', or '' - is treated as
|
|
559
|
+
// 'unknown' (the fail mode applies), NEVER as 'active'. Only an explicit 'active' is viable.
|
|
560
|
+
if (revocationFailMode === 'open')
|
|
561
|
+
return 'viable';
|
|
562
|
+
return revocationFailMode === 'closed' ? 'invalid' : 'hold';
|
|
535
563
|
}
|
|
536
|
-
// No revocationStatus: an older boolean producer. Absence of both stays
|
|
537
|
-
return data.forceInvalid === true || data.revoked === true;
|
|
564
|
+
// No revocationStatus: an older boolean producer. Absence of both stays viable.
|
|
565
|
+
return data.forceInvalid === true || data.revoked === true ? 'invalid' : 'viable';
|
|
566
|
+
}
|
|
567
|
+
function decideForceInvalid(data, revocationFailMode, noRevocationSource = false) {
|
|
568
|
+
return decideRevocationOutcome(data, revocationFailMode, noRevocationSource) === 'invalid';
|
|
538
569
|
}
|
|
539
570
|
/**
|
|
540
571
|
* PAY-2147: does the request carry a session cookie? Used ONLY when the viability read itself FAILED.
|
|
@@ -620,7 +651,13 @@ async function checkViability(request, endpoint, log, revocationFailMode, noRevo
|
|
|
620
651
|
// revocations status, decide from it + failMode and ignore the folded
|
|
621
652
|
// booleans for unknown/not_configured. The booleans are read ONLY when
|
|
622
653
|
// revocationStatus is ABSENT (idealresume.online's older boolean route).
|
|
623
|
-
|
|
654
|
+
const outcome = decideRevocationOutcome(data, revocationFailMode, noRevocationSource);
|
|
655
|
+
sessionStatus.forceInvalid = outcome === 'invalid';
|
|
656
|
+
if (outcome === 'hold') {
|
|
657
|
+
sessionStatus.unavailable = true;
|
|
658
|
+
// PAY-2339 P11: one countable line per held request, naming the check. No session value, no address.
|
|
659
|
+
log.warn('revocation_unavailable', { check: 'revocation' });
|
|
660
|
+
}
|
|
624
661
|
// PAY-2216: a STALE session - the cookie resolved to a token, but no store holds the session - used to set
|
|
625
662
|
// pointer.exists=false, so makeAuthDecision took no_session (login, cookie NOT cleared) and its stale_session
|
|
626
663
|
// arm (clearCookies) was unreachable: a dead cookie lingered and re-ran viability on every request.
|
|
@@ -663,7 +700,7 @@ async function executeDecision(request, decision, pathname, sessionPointer, sess
|
|
|
663
700
|
? clearingRedirect(request, decision.location, opts.reset)
|
|
664
701
|
: redirectTo(request, decision.location);
|
|
665
702
|
case 'service_error':
|
|
666
|
-
return serviceUnavailableResponse(request);
|
|
703
|
+
return serviceUnavailableResponse(request, decision.check ?? 'viability', opts.unavailablePage);
|
|
667
704
|
case 'refresh_needed':
|
|
668
705
|
// PAY-2136: a speculative request must not spend (rotate) a single-use refresh token or, on a 401/403,
|
|
669
706
|
// clear the session. Let it through; the real navigation refreshes.
|
|
@@ -730,7 +767,7 @@ async function handleRefresh(request, safeCallback, opts) {
|
|
|
730
767
|
log.warn('Circuit breaker preventing refresh attempt');
|
|
731
768
|
// PAY-2147: a breaker that merely declines to TRY a refresh has learned nothing about the session; wiping the
|
|
732
769
|
// cookies here logged out every user on the first request after a blip (HALF_OPEN refuses all but one probe).
|
|
733
|
-
return serviceUnavailableResponse(request);
|
|
770
|
+
return serviceUnavailableResponse(request, 'viability', opts.unavailablePage);
|
|
734
771
|
}
|
|
735
772
|
try {
|
|
736
773
|
const baseUrl = (0, internal_api_url_1.getInternalApiUrl)(request);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAY-2339 (option C): the default "temporarily unavailable" page the middleware sends a member to when the revocation
|
|
3
|
+
* check (or the viability read) could not answer. Mount it in one line in the consuming app:
|
|
4
|
+
*
|
|
5
|
+
* // app/service-unavailable/page.tsx
|
|
6
|
+
* export { default } from '@payez/next-mvp/pages/service-unavailable';
|
|
7
|
+
*
|
|
8
|
+
* It signs nobody out and clears nothing: "Try again" goes back to `callbackUrl`, and the member is still signed in. A
|
|
9
|
+
* consumer may replace the page (and the wording) with its own at the same route.
|
|
10
|
+
*
|
|
11
|
+
* `callbackUrl` is untrusted input. Only a same-origin PATH is followed ("/dashboard"); anything else (an absolute URL,
|
|
12
|
+
* a protocol-relative "//host", a "javascript:" value, a backslash trick, nothing at all) falls back to "/".
|
|
13
|
+
*/
|
|
14
|
+
export declare function safeReturnPath(raw: string | null | undefined): string;
|
|
15
|
+
export default function ServiceUnavailablePage(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
'use client';
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.safeReturnPath = safeReturnPath;
|
|
5
|
+
exports.default = ServiceUnavailablePage;
|
|
6
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
7
|
+
const react_1 = require("react");
|
|
8
|
+
const navigation_1 = require("next/navigation");
|
|
9
|
+
/**
|
|
10
|
+
* PAY-2339 (option C): the default "temporarily unavailable" page the middleware sends a member to when the revocation
|
|
11
|
+
* check (or the viability read) could not answer. Mount it in one line in the consuming app:
|
|
12
|
+
*
|
|
13
|
+
* // app/service-unavailable/page.tsx
|
|
14
|
+
* export { default } from '@payez/next-mvp/pages/service-unavailable';
|
|
15
|
+
*
|
|
16
|
+
* It signs nobody out and clears nothing: "Try again" goes back to `callbackUrl`, and the member is still signed in. A
|
|
17
|
+
* consumer may replace the page (and the wording) with its own at the same route.
|
|
18
|
+
*
|
|
19
|
+
* `callbackUrl` is untrusted input. Only a same-origin PATH is followed ("/dashboard"); anything else (an absolute URL,
|
|
20
|
+
* a protocol-relative "//host", a "javascript:" value, a backslash trick, nothing at all) falls back to "/".
|
|
21
|
+
*/
|
|
22
|
+
function safeReturnPath(raw) {
|
|
23
|
+
if (!raw || typeof raw !== 'string')
|
|
24
|
+
return '/';
|
|
25
|
+
if (!raw.startsWith('/') || raw.startsWith('//') || raw.includes('\\'))
|
|
26
|
+
return '/';
|
|
27
|
+
if (/[\u0000-\u001f]/.test(raw))
|
|
28
|
+
return '/';
|
|
29
|
+
return raw;
|
|
30
|
+
}
|
|
31
|
+
function ServiceUnavailableContent() {
|
|
32
|
+
const params = (0, navigation_1.useSearchParams)();
|
|
33
|
+
const back = safeReturnPath(params?.get('callbackUrl'));
|
|
34
|
+
return ((0, jsx_runtime_1.jsx)("div", { className: "min-h-screen flex items-center justify-center p-4", children: (0, jsx_runtime_1.jsxs)("div", { role: "status", className: "max-w-md w-full text-center rounded-xl p-8 shadow-lg border", style: {
|
|
35
|
+
backgroundColor: 'var(--bg-card, #ffffff)',
|
|
36
|
+
borderColor: 'var(--border-default, #e5e7eb)',
|
|
37
|
+
}, children: [(0, jsx_runtime_1.jsx)("h1", { className: "text-xl font-semibold mb-2", style: { color: 'var(--text-primary, #111827)' }, children: "Temporarily unavailable" }), (0, jsx_runtime_1.jsx)("p", { className: "mb-6", style: { color: 'var(--text-secondary, #6b7280)' }, children: "We could not check your session just now. You are still signed in. Please try again in a moment." }), (0, jsx_runtime_1.jsx)("a", { href: back, className: "inline-block rounded-lg px-4 py-2 font-medium", style: { background: 'var(--color-primary, #10b981)', color: 'white' }, children: "Try again" })] }) }));
|
|
38
|
+
}
|
|
39
|
+
function ServiceUnavailablePage() {
|
|
40
|
+
return ((0, jsx_runtime_1.jsx)(react_1.Suspense, { fallback: null, children: (0, jsx_runtime_1.jsx)(ServiceUnavailableContent, {}) }));
|
|
41
|
+
}
|
|
@@ -28,6 +28,7 @@ export declare function GET(req: NextRequest): Promise<NextResponse<{
|
|
|
28
28
|
needsRefresh: boolean;
|
|
29
29
|
authenticated: boolean;
|
|
30
30
|
revocationStatus: RevocationStatus;
|
|
31
|
+
unavailable: boolean;
|
|
31
32
|
error: string;
|
|
32
33
|
code: string;
|
|
33
34
|
}> | NextResponse<{
|
|
@@ -36,15 +37,6 @@ export declare function GET(req: NextRequest): Promise<NextResponse<{
|
|
|
36
37
|
authenticated: boolean;
|
|
37
38
|
reason: string;
|
|
38
39
|
}> | NextResponse<{
|
|
39
|
-
viable: boolean;
|
|
40
|
-
needsRefresh: boolean;
|
|
41
|
-
expiresIn: number;
|
|
42
|
-
hasRefreshToken: boolean;
|
|
43
|
-
authenticated: boolean;
|
|
44
|
-
sessionTokenId: string | null;
|
|
45
|
-
revocationStatus: RevocationStatus;
|
|
46
|
-
forceInvalid: boolean;
|
|
47
|
-
revoked: boolean;
|
|
48
40
|
tenantRequiresTwoFactor: boolean;
|
|
49
41
|
userHasCompletedTenantTwoFactorRequirements: boolean;
|
|
50
42
|
userStillNeedsTwoFactor: boolean;
|
|
@@ -56,4 +48,22 @@ export declare function GET(req: NextRequest): Promise<NextResponse<{
|
|
|
56
48
|
expiresAt: string;
|
|
57
49
|
roles: string[];
|
|
58
50
|
clientId: string | null;
|
|
51
|
+
forceInvalid: boolean;
|
|
52
|
+
unavailable: boolean;
|
|
53
|
+
revoked: boolean;
|
|
54
|
+
viable: boolean;
|
|
55
|
+
needsRefresh: boolean;
|
|
56
|
+
expiresIn: number;
|
|
57
|
+
hasRefreshToken: boolean;
|
|
58
|
+
authenticated: boolean;
|
|
59
|
+
sessionTokenId: string | null;
|
|
60
|
+
revocationStatus: RevocationStatus;
|
|
61
|
+
}> | NextResponse<{
|
|
62
|
+
viable: boolean;
|
|
63
|
+
needsRefresh: boolean;
|
|
64
|
+
authenticated: boolean;
|
|
65
|
+
revocationStatus: RevocationStatus;
|
|
66
|
+
error: string;
|
|
67
|
+
code: string;
|
|
68
|
+
correlationId: string;
|
|
59
69
|
}>>;
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
18
|
exports.GET = GET;
|
|
19
|
+
const revocation_fields_1 = require("../../auth/revocation-fields");
|
|
19
20
|
const server_1 = require("next/server");
|
|
20
21
|
const auth_1 = require("../../server/auth");
|
|
21
22
|
const session_store_1 = require("../../lib/session-store");
|
|
@@ -150,6 +151,7 @@ async function GET(req) {
|
|
|
150
151
|
needsRefresh: false,
|
|
151
152
|
authenticated: false,
|
|
152
153
|
revocationStatus: 'unknown',
|
|
154
|
+
unavailable: true, // PAY-2339: a reader of this JSON gets the hold signal (the response is non-OK, so the middleware holds as service_error)
|
|
153
155
|
error: 'Session store unavailable',
|
|
154
156
|
code: 'STORE_UNAVAILABLE'
|
|
155
157
|
}, { status: 500 });
|
|
@@ -361,8 +363,7 @@ async function GET(req) {
|
|
|
361
363
|
// Session revocation (PAY-1882 step 4): the four-state status, plus the
|
|
362
364
|
// boolean the middleware's existing force-invalid branch already reads.
|
|
363
365
|
revocationStatus,
|
|
364
|
-
|
|
365
|
-
revoked: revocationStatus === 'revoked',
|
|
366
|
+
...(0, revocation_fields_1.revocationFields)(revocationStatus), // PAY-2339: forceInvalid only for 'revoked'; unavailable:true for 'unknown'
|
|
366
367
|
// Clear names for middleware decision-making
|
|
367
368
|
tenantRequiresTwoFactor,
|
|
368
369
|
userHasCompletedTenantTwoFactorRequirements,
|
|
@@ -436,8 +437,7 @@ async function GET(req) {
|
|
|
436
437
|
reason: 'Token expired',
|
|
437
438
|
// Session revocation (PAY-1882 step 4) - same value on every return.
|
|
438
439
|
revocationStatus,
|
|
439
|
-
|
|
440
|
-
revoked: revocationStatus === 'revoked',
|
|
440
|
+
...(0, revocation_fields_1.revocationFields)(revocationStatus), // PAY-2339: forceInvalid only for 'revoked'; unavailable:true for 'unknown'
|
|
441
441
|
// RBAC fields
|
|
442
442
|
roles: session?.roles || [],
|
|
443
443
|
clientId: resolveClientId(session?.idpClientId),
|
|
@@ -452,8 +452,7 @@ async function GET(req) {
|
|
|
452
452
|
sessionTokenId: sessionToken ? (0, token_log_id_1.tokenLogId)(sessionToken) : null,
|
|
453
453
|
// Session revocation (PAY-1882 step 4) - same value on every return.
|
|
454
454
|
revocationStatus,
|
|
455
|
-
|
|
456
|
-
revoked: revocationStatus === 'revoked',
|
|
455
|
+
...(0, revocation_fields_1.revocationFields)(revocationStatus), // PAY-2339: forceInvalid only for 'revoked'; unavailable:true for 'unknown'
|
|
457
456
|
// Clear names
|
|
458
457
|
tenantRequiresTwoFactor,
|
|
459
458
|
userHasCompletedTenantTwoFactorRequirements,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@payez/next-mvp",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.8.1",
|
|
4
4
|
"sideEffects": false,
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -910,6 +910,11 @@
|
|
|
910
910
|
"require": "./dist/pages/client-admin/index.js",
|
|
911
911
|
"default": "./dist/pages/client-admin/index.js"
|
|
912
912
|
},
|
|
913
|
+
"./pages/service-unavailable": {
|
|
914
|
+
"types": "./dist/pages/service-unavailable/page.d.ts",
|
|
915
|
+
"require": "./dist/pages/service-unavailable/page.js",
|
|
916
|
+
"default": "./dist/pages/service-unavailable/page.js"
|
|
917
|
+
},
|
|
913
918
|
"./pages/coming-soon": {
|
|
914
919
|
"types": "./dist/pages/coming-soon/page.d.ts",
|
|
915
920
|
"require": "./dist/pages/coming-soon/page.js",
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* This API route is called by the middleware to securely check if a session is valid.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { revocationFields } from '../../auth/revocation-fields';
|
|
7
8
|
import { NextRequest, NextResponse } from 'next/server';
|
|
8
9
|
import { getSession as getRedisSession, getBetterAuthSession } from '../../lib/session-store';
|
|
9
10
|
import { resolveSessionState } from '../../server/auth';
|
|
@@ -52,7 +53,7 @@ export async function GET(req: NextRequest) {
|
|
|
52
53
|
if (sessionState.state === 'unknown') {
|
|
53
54
|
console.warn('[VIABILITY] Session store unavailable - session state unknown');
|
|
54
55
|
return NextResponse.json(
|
|
55
|
-
{ authenticated: false, revocationStatus: 'unknown' as RevocationStatus, error: 'Session store unavailable', code: 'STORE_UNAVAILABLE' },
|
|
56
|
+
{ authenticated: false, revocationStatus: 'unknown' as RevocationStatus, unavailable: true, error: 'Session store unavailable', code: 'STORE_UNAVAILABLE' },
|
|
56
57
|
{ status: 500 }
|
|
57
58
|
);
|
|
58
59
|
}
|
|
@@ -192,8 +193,7 @@ export async function GET(req: NextRequest) {
|
|
|
192
193
|
// Session revocation (PAY-1882 step 4): the four-state status, plus the
|
|
193
194
|
// boolean the middleware's existing force-invalid branch already reads.
|
|
194
195
|
revocationStatus,
|
|
195
|
-
|
|
196
|
-
revoked: revocationStatus === 'revoked',
|
|
196
|
+
...revocationFields(revocationStatus), // PAY-2339: forceInvalid only for 'revoked'; unavailable:true for 'unknown'
|
|
197
197
|
// Cards 141688/141741 — the IDP's routing decision, relayed verbatim.
|
|
198
198
|
//
|
|
199
199
|
// THIS IS THE SECOND IMPLEMENTATION OF VIABILITY. The other is
|
|
@@ -22,6 +22,8 @@ export interface AuthContext {
|
|
|
22
22
|
sessionStatus: {
|
|
23
23
|
exists: boolean | null; // null = check failed/timeout
|
|
24
24
|
forceInvalid: boolean | null; // null = check failed/timeout
|
|
25
|
+
/** PAY-2339: the producer said the revocation backend could not answer. makeAuthDecision HOLDS (no sign-out). */
|
|
26
|
+
unavailable?: boolean;
|
|
25
27
|
requires2FA: boolean;
|
|
26
28
|
twoFactorComplete: boolean;
|
|
27
29
|
};
|
|
@@ -34,7 +36,7 @@ export interface AuthContext {
|
|
|
34
36
|
export type AuthAction =
|
|
35
37
|
| { type: 'allow' }
|
|
36
38
|
| { type: 'redirect'; location: string; reason: string; clearCookies?: boolean }
|
|
37
|
-
| { type: 'service_error'; reason: string };
|
|
39
|
+
| { type: 'service_error'; reason: string; check?: 'revocation' | 'viability' };
|
|
38
40
|
|
|
39
41
|
/** PAY-2180: warn once per process, not per request. */
|
|
40
42
|
let deprecationWarned = false;
|
|
@@ -139,6 +141,16 @@ function decide(context: AuthContext): AuthAction {
|
|
|
139
141
|
};
|
|
140
142
|
}
|
|
141
143
|
|
|
144
|
+
// PAY-2339 (BAPert 74633): the revocation backend could not answer. Hold: not a verdict on the session, so no
|
|
145
|
+
// sign-out and no cookie is touched. A caller maps this to its own "temporarily unavailable" answer (the
|
|
146
|
+
// createMvpMiddleware twin sends /service-unavailable or a 503 with unavailable:true, check:'revocation').
|
|
147
|
+
if (sessionStatus.unavailable) {
|
|
148
|
+
if (isLoginPage) {
|
|
149
|
+
return { type: 'allow' };
|
|
150
|
+
}
|
|
151
|
+
return { type: 'service_error', reason: 'revocation_unknown', check: 'revocation' };
|
|
152
|
+
}
|
|
153
|
+
|
|
142
154
|
// Session force invalidated - need fresh login
|
|
143
155
|
if (sessionStatus.forceInvalid) {
|
|
144
156
|
if (isLoginPage) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAY-2339 (BAPert 74633 gap 1): the two revocation fields every viability producer emits, in ONE place so the
|
|
3
|
+
* producers (api-handlers/session/viability.ts and routes/auth/viability.ts, four return sites) cannot drift.
|
|
4
|
+
*
|
|
5
|
+
* forceInvalid - true ONLY for 'revoked'. An 'unknown' answer is no longer a sign-out verdict.
|
|
6
|
+
* unavailable - true when the revocation backend could not answer: 'unknown' and ANY string that is not one of the
|
|
7
|
+
* three recognised states. A consumer that reads this JSON (useSessionCheck, makeAuthDecision) holds
|
|
8
|
+
* instead of signing the member out. `revocationStatus` itself is emitted unchanged.
|
|
9
|
+
*
|
|
10
|
+
* 'not_configured' is a configuration fault, not an outage: neither field is set for it (unchanged).
|
|
11
|
+
*/
|
|
12
|
+
export function revocationFields(status: unknown): { forceInvalid: boolean; unavailable: boolean; revoked: boolean } {
|
|
13
|
+
const revoked = status === 'revoked';
|
|
14
|
+
const unavailable = !(status === 'revoked' || status === 'active' || status === 'not_configured');
|
|
15
|
+
return { forceInvalid: revoked, unavailable, revoked };
|
|
16
|
+
}
|
|
@@ -57,9 +57,14 @@ export interface SessionPointer {
|
|
|
57
57
|
clientId: string;
|
|
58
58
|
}
|
|
59
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
|
+
|
|
60
63
|
export interface SessionStatus {
|
|
61
64
|
exists: boolean | null; // null = check failed/timeout
|
|
62
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;
|
|
63
68
|
requires2FA: boolean;
|
|
64
69
|
twoFactorComplete: boolean;
|
|
65
70
|
}
|
|
@@ -67,7 +72,7 @@ export interface SessionStatus {
|
|
|
67
72
|
export type AuthAction =
|
|
68
73
|
| { type: 'allow' }
|
|
69
74
|
| { type: 'redirect'; location: string; reason: string; clearCookies?: boolean }
|
|
70
|
-
| { type: 'service_error'; reason: string }
|
|
75
|
+
| { type: 'service_error'; reason: string; check?: UnavailableCheck }
|
|
71
76
|
| { type: 'refresh_needed' };
|
|
72
77
|
|
|
73
78
|
// =============================================================================
|
|
@@ -135,29 +140,42 @@ export interface MvpMiddlewareOptions {
|
|
|
135
140
|
*/
|
|
136
141
|
sessionResetRoute?: boolean | string;
|
|
137
142
|
/**
|
|
138
|
-
* PAY-1882 step 4 - how this app treats a producer that reports the session's
|
|
143
|
+
* PAY-1882 step 4 / PAY-2339 - how this app treats a producer that reports the session's
|
|
139
144
|
* revocation status as `'unknown'` (a Vibe read that FAILED: non-OK, timeout,
|
|
140
145
|
* throw - the status could not be determined).
|
|
141
146
|
*
|
|
142
|
-
* '
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
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.
|
|
155
162
|
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
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.
|
|
159
177
|
*/
|
|
160
|
-
|
|
178
|
+
unavailablePage?: string;
|
|
161
179
|
|
|
162
180
|
/**
|
|
163
181
|
* PAY-2302 cut 1c (BAPert 73585): this app has NO revocation source - for example a development environment
|
|
@@ -353,13 +371,35 @@ async function refreshFailureResponse(
|
|
|
353
371
|
* requested path as `callbackUrl` so "Try again" returns the user to it still signed in. The path goes
|
|
354
372
|
* through safeCallbackPath (URL-parser origin check): an off-origin or auth-surface value is dropped.
|
|
355
373
|
*/
|
|
356
|
-
function serviceUnavailableResponse(request: NextRequest): NextResponse {
|
|
374
|
+
function serviceUnavailableResponse(request: NextRequest, check: UnavailableCheck = 'viability', unavailablePage?: string): NextResponse {
|
|
357
375
|
const { pathname, search } = request.nextUrl;
|
|
358
376
|
if (pathname.startsWith('/api/')) {
|
|
359
|
-
|
|
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 });
|
|
360
379
|
}
|
|
361
|
-
const target = new URL('/service-unavailable', request.url);
|
|
362
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);
|
|
363
403
|
if (callback !== '/') target.searchParams.set('callbackUrl', callback);
|
|
364
404
|
return NextResponse.redirect(target);
|
|
365
405
|
}
|
|
@@ -438,6 +478,14 @@ function makeAuthDecision(ctx: AuthContext): AuthAction {
|
|
|
438
478
|
return { type: 'service_error', reason: 'viability_check_failed' };
|
|
439
479
|
}
|
|
440
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
|
+
|
|
441
489
|
// --- Session force-invalidated (admin action, password change, etc.) ---
|
|
442
490
|
if (sessionStatus.forceInvalid) {
|
|
443
491
|
if (onLoginPage) return { type: 'allow' };
|
|
@@ -557,8 +605,9 @@ export function createMvpMiddleware(options: MvpMiddlewareOptions = {}) {
|
|
|
557
605
|
const refreshEndpoint = options.refreshEndpoint || '/api/auth/refresh';
|
|
558
606
|
const bypassPaths = options.bypassPaths || [];
|
|
559
607
|
const rbacExemptPaths = options.rbacExemptPaths || [];
|
|
560
|
-
// PAY-
|
|
561
|
-
|
|
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';
|
|
562
611
|
// PAY-2302 cut 1c: strictly `=== true`, so a truthy string in a config file cannot switch revocation off.
|
|
563
612
|
const noRevocationSource = options.noRevocationSource === true;
|
|
564
613
|
if (noRevocationSource) {
|
|
@@ -693,6 +742,7 @@ export function createMvpMiddleware(options: MvpMiddlewareOptions = {}) {
|
|
|
693
742
|
refreshEndpoint,
|
|
694
743
|
rbacExemptPaths,
|
|
695
744
|
reset: resolveReset(),
|
|
745
|
+
unavailablePage: options.unavailablePage,
|
|
696
746
|
onRefreshSuccess: options.onRefreshSuccess,
|
|
697
747
|
onRefreshFailure: options.onRefreshFailure,
|
|
698
748
|
});
|
|
@@ -711,26 +761,44 @@ export function createMvpMiddleware(options: MvpMiddlewareOptions = {}) {
|
|
|
711
761
|
* 'unknown'/'not_configured'. The booleans are read ONLY when revocationStatus is
|
|
712
762
|
* absent (idealresume.online's older boolean-only route).
|
|
713
763
|
*/
|
|
714
|
-
export
|
|
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(
|
|
715
774
|
data: { revocationStatus?: unknown; forceInvalid?: unknown; revoked?: unknown },
|
|
716
|
-
revocationFailMode: 'closed' | 'open',
|
|
775
|
+
revocationFailMode: 'hold' | 'closed' | 'open',
|
|
717
776
|
noRevocationSource: boolean = false,
|
|
718
|
-
):
|
|
777
|
+
): RevocationOutcome {
|
|
719
778
|
const status = data.revocationStatus;
|
|
720
779
|
if (typeof status === 'string') {
|
|
721
|
-
if (status === 'revoked') return
|
|
722
|
-
if (status === 'active') return
|
|
780
|
+
if (status === 'revoked') return 'invalid';
|
|
781
|
+
if (status === 'active') return 'viable';
|
|
723
782
|
// PAY-2302 cut 1c: an app that declared it has no revocation source. ONLY the exact 'not_configured' string.
|
|
724
|
-
if (status === 'not_configured' && noRevocationSource === true) return
|
|
725
|
-
//
|
|
726
|
-
//
|
|
727
|
-
|
|
728
|
-
//
|
|
729
|
-
// the
|
|
730
|
-
|
|
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';
|
|
731
791
|
}
|
|
732
|
-
// No revocationStatus: an older boolean producer. Absence of both stays
|
|
733
|
-
return data.forceInvalid === true || data.revoked === true;
|
|
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';
|
|
734
802
|
}
|
|
735
803
|
|
|
736
804
|
/**
|
|
@@ -750,7 +818,7 @@ async function checkViability(
|
|
|
750
818
|
request: NextRequest,
|
|
751
819
|
endpoint: string,
|
|
752
820
|
log: MiddlewareLogger,
|
|
753
|
-
revocationFailMode: 'closed' | 'open',
|
|
821
|
+
revocationFailMode: 'hold' | 'closed' | 'open',
|
|
754
822
|
noRevocationSource: boolean = false
|
|
755
823
|
): Promise<{
|
|
756
824
|
sessionStatus: SessionStatus;
|
|
@@ -829,7 +897,13 @@ async function checkViability(
|
|
|
829
897
|
// revocations status, decide from it + failMode and ignore the folded
|
|
830
898
|
// booleans for unknown/not_configured. The booleans are read ONLY when
|
|
831
899
|
// revocationStatus is ABSENT (idealresume.online's older boolean route).
|
|
832
|
-
|
|
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
|
+
}
|
|
833
907
|
// PAY-2216: a STALE session - the cookie resolved to a token, but no store holds the session - used to set
|
|
834
908
|
// pointer.exists=false, so makeAuthDecision took no_session (login, cookie NOT cleared) and its stale_session
|
|
835
909
|
// arm (clearCookies) was unreachable: a dead cookie lingered and re-ran viability on every request.
|
|
@@ -869,6 +943,8 @@ interface ExecuteOptions {
|
|
|
869
943
|
rbacExemptPaths: string[];
|
|
870
944
|
/** PAY-2218: set when the app mounted the session-reset route AND a signing secret exists. */
|
|
871
945
|
reset?: ResetConfig;
|
|
946
|
+
/** PAY-2339: the app's named hold page; absent = the inline 503 HTML. */
|
|
947
|
+
unavailablePage?: string;
|
|
872
948
|
onRefreshSuccess?: () => void;
|
|
873
949
|
onRefreshFailure?: (status: number, isNetworkError: boolean) => void;
|
|
874
950
|
}
|
|
@@ -894,7 +970,7 @@ async function executeDecision(
|
|
|
894
970
|
: redirectTo(request, decision.location);
|
|
895
971
|
|
|
896
972
|
case 'service_error':
|
|
897
|
-
return serviceUnavailableResponse(request);
|
|
973
|
+
return serviceUnavailableResponse(request, decision.check ?? 'viability', opts.unavailablePage);
|
|
898
974
|
|
|
899
975
|
case 'refresh_needed':
|
|
900
976
|
// PAY-2136: a speculative request must not spend (rotate) a single-use refresh token or, on a 401/403,
|
|
@@ -989,7 +1065,7 @@ async function handleRefresh(
|
|
|
989
1065
|
log.warn('Circuit breaker preventing refresh attempt');
|
|
990
1066
|
// PAY-2147: a breaker that merely declines to TRY a refresh has learned nothing about the session; wiping the
|
|
991
1067
|
// cookies here logged out every user on the first request after a blip (HALF_OPEN refuses all but one probe).
|
|
992
|
-
return serviceUnavailableResponse(request);
|
|
1068
|
+
return serviceUnavailableResponse(request, 'viability', opts.unavailablePage);
|
|
993
1069
|
}
|
|
994
1070
|
|
|
995
1071
|
try {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { Suspense } from 'react';
|
|
4
|
+
import { useSearchParams } from 'next/navigation';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* PAY-2339 (option C): the default "temporarily unavailable" page the middleware sends a member to when the revocation
|
|
8
|
+
* check (or the viability read) could not answer. Mount it in one line in the consuming app:
|
|
9
|
+
*
|
|
10
|
+
* // app/service-unavailable/page.tsx
|
|
11
|
+
* export { default } from '@payez/next-mvp/pages/service-unavailable';
|
|
12
|
+
*
|
|
13
|
+
* It signs nobody out and clears nothing: "Try again" goes back to `callbackUrl`, and the member is still signed in. A
|
|
14
|
+
* consumer may replace the page (and the wording) with its own at the same route.
|
|
15
|
+
*
|
|
16
|
+
* `callbackUrl` is untrusted input. Only a same-origin PATH is followed ("/dashboard"); anything else (an absolute URL,
|
|
17
|
+
* a protocol-relative "//host", a "javascript:" value, a backslash trick, nothing at all) falls back to "/".
|
|
18
|
+
*/
|
|
19
|
+
export function safeReturnPath(raw: string | null | undefined): string {
|
|
20
|
+
if (!raw || typeof raw !== 'string') return '/';
|
|
21
|
+
if (!raw.startsWith('/') || raw.startsWith('//') || raw.includes('\\')) return '/';
|
|
22
|
+
if (/[\u0000-\u001f]/.test(raw)) return '/';
|
|
23
|
+
return raw;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function ServiceUnavailableContent() {
|
|
27
|
+
const params = useSearchParams();
|
|
28
|
+
const back = safeReturnPath(params?.get('callbackUrl'));
|
|
29
|
+
return (
|
|
30
|
+
<div className="min-h-screen flex items-center justify-center p-4">
|
|
31
|
+
<div
|
|
32
|
+
role="status"
|
|
33
|
+
className="max-w-md w-full text-center rounded-xl p-8 shadow-lg border"
|
|
34
|
+
style={{
|
|
35
|
+
backgroundColor: 'var(--bg-card, #ffffff)',
|
|
36
|
+
borderColor: 'var(--border-default, #e5e7eb)',
|
|
37
|
+
}}
|
|
38
|
+
>
|
|
39
|
+
<h1 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary, #111827)' }}>
|
|
40
|
+
Temporarily unavailable
|
|
41
|
+
</h1>
|
|
42
|
+
<p className="mb-6" style={{ color: 'var(--text-secondary, #6b7280)' }}>
|
|
43
|
+
We could not check your session just now. You are still signed in. Please try again in a moment.
|
|
44
|
+
</p>
|
|
45
|
+
<a
|
|
46
|
+
href={back}
|
|
47
|
+
className="inline-block rounded-lg px-4 py-2 font-medium"
|
|
48
|
+
style={{ background: 'var(--color-primary, #10b981)', color: 'white' }}
|
|
49
|
+
>
|
|
50
|
+
Try again
|
|
51
|
+
</a>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default function ServiceUnavailablePage() {
|
|
58
|
+
return (
|
|
59
|
+
<Suspense fallback={null}>
|
|
60
|
+
<ServiceUnavailableContent />
|
|
61
|
+
</Suspense>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* @since auth-ready-v2
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { revocationFields } from '../../auth/revocation-fields';
|
|
17
18
|
import { NextRequest, NextResponse } from 'next/server';
|
|
18
19
|
import { resolveSessionState } from '../../server/auth';
|
|
19
20
|
import {
|
|
@@ -157,6 +158,7 @@ export async function GET(req: NextRequest) {
|
|
|
157
158
|
needsRefresh: false,
|
|
158
159
|
authenticated: false,
|
|
159
160
|
revocationStatus: 'unknown' as RevocationStatus,
|
|
161
|
+
unavailable: true, // PAY-2339: a reader of this JSON gets the hold signal (the response is non-OK, so the middleware holds as service_error)
|
|
160
162
|
error: 'Session store unavailable',
|
|
161
163
|
code: 'STORE_UNAVAILABLE'
|
|
162
164
|
}, { status: 500 });
|
|
@@ -383,8 +385,7 @@ export async function GET(req: NextRequest) {
|
|
|
383
385
|
// Session revocation (PAY-1882 step 4): the four-state status, plus the
|
|
384
386
|
// boolean the middleware's existing force-invalid branch already reads.
|
|
385
387
|
revocationStatus,
|
|
386
|
-
|
|
387
|
-
revoked: revocationStatus === 'revoked',
|
|
388
|
+
...revocationFields(revocationStatus), // PAY-2339: forceInvalid only for 'revoked'; unavailable:true for 'unknown'
|
|
388
389
|
// Clear names for middleware decision-making
|
|
389
390
|
tenantRequiresTwoFactor,
|
|
390
391
|
userHasCompletedTenantTwoFactorRequirements,
|
|
@@ -468,8 +469,7 @@ export async function GET(req: NextRequest) {
|
|
|
468
469
|
reason: 'Token expired',
|
|
469
470
|
// Session revocation (PAY-1882 step 4) - same value on every return.
|
|
470
471
|
revocationStatus,
|
|
471
|
-
|
|
472
|
-
revoked: revocationStatus === 'revoked',
|
|
472
|
+
...revocationFields(revocationStatus), // PAY-2339: forceInvalid only for 'revoked'; unavailable:true for 'unknown'
|
|
473
473
|
// RBAC fields
|
|
474
474
|
roles: session?.roles || [],
|
|
475
475
|
clientId: resolveClientId(session?.idpClientId),
|
|
@@ -485,8 +485,7 @@ export async function GET(req: NextRequest) {
|
|
|
485
485
|
sessionTokenId: sessionToken ? tokenLogId(sessionToken) : null,
|
|
486
486
|
// Session revocation (PAY-1882 step 4) - same value on every return.
|
|
487
487
|
revocationStatus,
|
|
488
|
-
|
|
489
|
-
revoked: revocationStatus === 'revoked',
|
|
488
|
+
...revocationFields(revocationStatus), // PAY-2339: forceInvalid only for 'revoked'; unavailable:true for 'unknown'
|
|
490
489
|
// Clear names
|
|
491
490
|
tenantRequiresTwoFactor,
|
|
492
491
|
userHasCompletedTenantTwoFactorRequirements,
|