@payez/next-mvp 4.12.0 → 4.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/api-handlers/account/profile.d.ts +2 -39
- package/dist/api-handlers/account/profile.js +5 -2
- package/dist/api-handlers/anon/preferences.d.ts +2 -12
- package/dist/api-handlers/anon/preferences.js +8 -5
- package/dist/api-handlers/session/refresh-viability.d.ts +3 -15
- package/dist/api-handlers/session/refresh-viability.js +5 -2
- package/dist/api-handlers/session/viability.d.ts +3 -8
- package/dist/api-handlers/session/viability.js +5 -2
- package/dist/auth/better-auth.js +4 -1
- package/dist/lib/ensure-fresh-access-token.js +15 -10
- package/dist/lib/no-store.d.ts +13 -0
- package/dist/lib/no-store.js +35 -0
- package/dist/routes/auth/session.d.ts +2 -9
- package/dist/routes/auth/session.js +5 -2
- package/dist/routes/auth/viability.d.ts +3 -54
- package/dist/routes/auth/viability.js +5 -2
- package/dist/routes/mfa/methods.d.ts +2 -2
- package/dist/routes/mfa/methods.js +3 -1
- package/dist/routes/passkeys/list.d.ts +2 -10
- package/dist/routes/passkeys/list.js +4 -2
- package/dist/routes/session/refresh-viability.d.ts +2 -1
- package/dist/routes/session/refresh-viability.js +4 -2
- package/package.json +1 -1
- package/src/api-handlers/account/profile.ts +5 -1
- package/src/api-handlers/anon/preferences.ts +8 -4
- package/src/api-handlers/session/refresh-viability.ts +5 -1
- package/src/api-handlers/session/viability.ts +6 -2
- package/src/auth/better-auth.ts +4 -1
- package/src/lib/ensure-fresh-access-token.ts +15 -10
- package/src/lib/no-store.ts +30 -0
- package/src/routes/auth/session.ts +5 -1
- package/src/routes/auth/viability.ts +6 -2
- package/src/routes/mfa/methods.ts +3 -1
- package/src/routes/passkeys/list.ts +5 -1
- package/src/routes/session/refresh-viability.ts +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog — @payez/next-mvp
|
|
2
2
|
|
|
3
|
+
## 4.12.2 (BACKPORT: to be published under dist-tag `backport` only, never `next` or `latest`; = 4.12.1 + the PAY-2994 no-store commit and nothing else)
|
|
4
|
+
|
|
5
|
+
A patch cut from the 4.12.1 tree (DevOps commit 603bbcd2, whose src equals the published 4.12.1 tarball file for file). Carries ONE change, so the portal can take the no-store fix without 4.13.0's behaviour changes. 4.13.1 carries the same fix on the 4.13.x line.
|
|
6
|
+
|
|
7
|
+
- **FIX (PAY-2994): eight GET routes now answer `Cache-Control: no-store` on every response.** The portal's BFF no-store ratchet found that these re-exported routes sent no Cache-Control, though their answers carry per-user data and some carry tokens, so a browser or an intermediate cache could keep them. New internal `lib/no-store` (`noStore(handler)`): it sets `no-store` on whatever the handler returns (errors and redirects too), REPLACES a looser existing value, rebuilds a Response whose headers are immutable (e.g. `Response.redirect`) with status, body and every `Set-Cookie` intact, passes the handler's arguments through, and hands a non-Response back untouched. Wrapped: `api-handlers/account/profile` GET, `api-handlers/anon/preferences` GET, `auth/better-auth` `createAuthGetHandler`, `routes/auth/session` GET, `routes/mfa/methods` GET, `routes/passkeys/list` GET, `routes/session/refresh-viability` GET, `routes/auth/viability` GET; and, as the same data under a second export path, `api-handlers/session/viability` GET and `api-handlers/session/refresh-viability` GET. No other behaviour changes. Package test `test/next-mvp-pay-2994-no-store.test.cjs` calls the REAL compiled GET of each and a mutant (wrapper removed) goes red.
|
|
8
|
+
- **NOT covered (follow-up):** GET answers in `api-handlers/auth/status`, `api-handlers/auth/validate`, `routes/auth/session-reset`, `routes/auth/settings` and `api-handlers/oauth/resume` (a redirect that sets cookies) still send no `no-store`.
|
|
9
|
+
|
|
10
|
+
## 4.12.1 (to be published under dist-tag `next` only, after 4.12.0; `latest` stays 4.8.9 until Jon says otherwise)
|
|
11
|
+
|
|
12
|
+
A patch cut from the 4.12.0 tree carrying ONE change: the PAY-2425 hygiene fix below (PR 1500).
|
|
13
|
+
|
|
14
|
+
- **CHANGE (PAY-2425, hygiene): the refresh no longer invents authentication claims for an OAuth (Google) session.** `lib/ensure-fresh-access-token` answered an OAuth session whose stored authentication methods were empty with `amr ['pwd','mfa']`, `two_factor_method 'oauth'` and an `acr` raised from '1' to '2' ("OAuth IS a form of multi-factor auth"), and echoed a stored `mfaVerified` as `two_factor_verified` whatever the session's methods said. OAuth is federated sign-in, not a second factor (BAPert 77404). The refresh body now carries only what the session records: empty stays empty, `acr` is the session's own level, no method is named from silence, and `two_factor_verified` is sent only when the session's recorded methods contain `mfa`, `sms`, `totp` or `email`. A session that really attests `mfa` still sends it. The IdP ignores these request fields and mints `amr`/`acr` from the stored refresh row, so this removes a false claim from the wire; it does not establish that any bypass existed or is closed. **What changes for a caller:** a Google user on a `require2FA` client is asked for a code, as before, because the IdP decides that; a Google-only client that wants no second factor sets `require2FA = false` explicitly. An earlier version of this fix on `nextpert/375639-amr-fabrication` (e6e3b6c3, e3b3bb12) was never merged; the refresh path has since moved into this helper. Rows: `test/next-mvp-pay-2425-no-fabricated-mfa.test.cjs` (R1/R2/R3, C1, N1 and four mutants).
|
|
15
|
+
|
|
3
16
|
## 4.12.0 (to be published under dist-tag `next` only, after 4.11.0; `latest` stays 4.8.9 until Jon says otherwise)
|
|
4
17
|
|
|
5
18
|
A minor: PII-in-logs groups 1, 2, 3a and 3b, the GOV-023 signout BREAKING change, the logout-401 FIX (PAY-2396), and the BREAKING site-logger signatures (3b: callers of `siteEvents.loginFailed` / `siteEvents.error` must change).
|
|
@@ -1,41 +1,4 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
-
/**
|
|
3
|
-
|
|
4
|
-
*
|
|
5
|
-
* GET /api/account/profile - Get user profile
|
|
6
|
-
* PUT /api/account/profile - Update user profile
|
|
7
|
-
*
|
|
8
|
-
* WHY THIS WAS REWRITTEN (2026-08-02, QAPert defect #7)
|
|
9
|
-
* Reported as "/api/account/profile 401s on the same cookie masked-info
|
|
10
|
-
* accepts (200)". Two endpoints disagreeing about one cookie meant two auth
|
|
11
|
-
* implementations, and they were:
|
|
12
|
-
*
|
|
13
|
-
* masked-info.ts idpFetchJSON(req, ...) resolves the caller's token
|
|
14
|
-
* from the server-side session
|
|
15
|
-
* profile.ts req.headers.get('authorization') required an INBOUND bearer
|
|
16
|
-
*
|
|
17
|
-
* A browser sends a session COOKIE and no Authorization header, so the old
|
|
18
|
-
* profile.ts 401'd EVERY browser request by construction. Nothing was revoked
|
|
19
|
-
* and no token was stale — the handler demanded a credential the app does not
|
|
20
|
-
* use.
|
|
21
|
-
*
|
|
22
|
-
* THE OLD AUTH CHECK WAS A VENEER. It tested that the header was PRESENT, never
|
|
23
|
-
* that it was valid, then forwarded it upstream unexamined. Any string at all
|
|
24
|
-
* got past it and the IDP did the real work. It looked like enforcement and
|
|
25
|
-
* enforced nothing.
|
|
26
|
-
*
|
|
27
|
-
* WHAT THE WORKAROUND COST US: consumers could not call this with a cookie, so
|
|
28
|
-
* the graft's useProfile hook fetched the raw IDP access token from
|
|
29
|
-
* /api/session/idp-token INTO BROWSER JAVASCRIPT and re-sent it as a bearer.
|
|
30
|
-
* That defeats the reason idpFetchJSON exists — it keeps the token server-side
|
|
31
|
-
* precisely so client-side script can never hold it. The bearer-only handler
|
|
32
|
-
* did not merely fail; it pushed its callers into exposing the token.
|
|
33
|
-
*
|
|
34
|
-
* Switching to idpFetchJSON makes the cookie work, so the hook's token round
|
|
35
|
-
* trip can be deleted and /api/session/idp-token drops to a single deliberate
|
|
36
|
-
* caller: the jwt-inspect debug page.
|
|
37
|
-
*
|
|
38
|
-
* @package @payez/next-mvp
|
|
39
|
-
*/
|
|
40
|
-
export declare function GET(req: NextRequest): Promise<NextResponse<any>>;
|
|
2
|
+
/** PAY-2994: the profile is one person's data; never cached (noStore). */
|
|
3
|
+
export declare const GET: (req: NextRequest) => Promise<Response>;
|
|
41
4
|
export declare function PUT(req: NextRequest): Promise<NextResponse<any>>;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.GET =
|
|
3
|
+
exports.GET = void 0;
|
|
4
4
|
exports.PUT = PUT;
|
|
5
5
|
const server_1 = require("next/server");
|
|
6
6
|
const idp_fetch_1 = require("../../lib/idp-fetch");
|
|
7
7
|
const env_1 = require("../../config/env");
|
|
8
|
+
const no_store_1 = require("../../lib/no-store");
|
|
8
9
|
/**
|
|
9
10
|
* Account Profile API Handler
|
|
10
11
|
*
|
|
@@ -43,7 +44,7 @@ const env_1 = require("../../config/env");
|
|
|
43
44
|
*
|
|
44
45
|
* @package @payez/next-mvp
|
|
45
46
|
*/
|
|
46
|
-
async function
|
|
47
|
+
async function getProfile(req) {
|
|
47
48
|
const result = await (0, idp_fetch_1.idpFetchJSON)(req, `${env_1.ENV_CONFIG.IDP_URL}/api/Account/profile`, {
|
|
48
49
|
method: 'GET',
|
|
49
50
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -61,6 +62,8 @@ async function GET(req) {
|
|
|
61
62
|
}
|
|
62
63
|
return server_1.NextResponse.json(result.json, { status: 200 });
|
|
63
64
|
}
|
|
65
|
+
/** PAY-2994: the profile is one person's data; never cached (noStore). */
|
|
66
|
+
exports.GET = (0, no_store_1.noStore)(getProfile);
|
|
64
67
|
async function PUT(req) {
|
|
65
68
|
let body = '{}';
|
|
66
69
|
try {
|
|
@@ -9,19 +9,9 @@
|
|
|
9
9
|
import { NextRequest, NextResponse } from 'next/server';
|
|
10
10
|
import { AnonSessionPreferences } from '../../lib/anon-session';
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
12
|
+
* POST handler - updates anonymous session preferences
|
|
13
13
|
*/
|
|
14
|
-
export declare
|
|
15
|
-
success: boolean;
|
|
16
|
-
data: {
|
|
17
|
-
id: string;
|
|
18
|
-
preferences: AnonSessionPreferences;
|
|
19
|
-
metrics: import("../../lib/anon-session").AnonSessionMetrics;
|
|
20
|
-
};
|
|
21
|
-
}> | NextResponse<{
|
|
22
|
-
success: boolean;
|
|
23
|
-
error: string;
|
|
24
|
-
}>>;
|
|
14
|
+
export declare const GET: (request: NextRequest) => Promise<Response>;
|
|
25
15
|
/**
|
|
26
16
|
* POST handler - updates anonymous session preferences
|
|
27
17
|
*/
|
|
@@ -8,17 +8,16 @@
|
|
|
8
8
|
* Sets a cookie to track anonymous visitors and stores preferences in Redis.
|
|
9
9
|
*/
|
|
10
10
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
-
exports.GET =
|
|
11
|
+
exports.GET = void 0;
|
|
12
12
|
exports.POST = POST;
|
|
13
13
|
const server_1 = require("next/server");
|
|
14
14
|
const headers_1 = require("next/headers");
|
|
15
15
|
const anon_session_1 = require("../../lib/anon-session");
|
|
16
16
|
const log_safe_1 = require("../../lib/log-safe");
|
|
17
|
+
const no_store_1 = require("../../lib/no-store");
|
|
17
18
|
const COOKIE_MAX_AGE = 90 * 24 * 60 * 60; // 90 days in seconds
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
*/
|
|
21
|
-
async function GET(request) {
|
|
19
|
+
/** Retrieves anonymous session preferences. Exported as GET below, wrapped in noStore (PAY-2994). */
|
|
20
|
+
async function getPreferences(request) {
|
|
22
21
|
try {
|
|
23
22
|
const cookieStore = await (0, headers_1.cookies)();
|
|
24
23
|
let anonId = cookieStore.get(anon_session_1.ANON_COOKIE_NAME)?.value;
|
|
@@ -50,6 +49,10 @@ async function GET(request) {
|
|
|
50
49
|
return server_1.NextResponse.json({ success: false, error: 'Failed to get preferences' }, { status: 500 });
|
|
51
50
|
}
|
|
52
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* POST handler - updates anonymous session preferences
|
|
54
|
+
*/
|
|
55
|
+
exports.GET = (0, no_store_1.noStore)(getPreferences);
|
|
53
56
|
/**
|
|
54
57
|
* POST handler - updates anonymous session preferences
|
|
55
58
|
*/
|
|
@@ -1,15 +1,3 @@
|
|
|
1
|
-
import { NextRequest
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
reason: string;
|
|
5
|
-
error: string;
|
|
6
|
-
code: string;
|
|
7
|
-
}> | NextResponse<{
|
|
8
|
-
canRefresh: boolean;
|
|
9
|
-
reason: string;
|
|
10
|
-
sessionTokenId: string;
|
|
11
|
-
}> | NextResponse<{
|
|
12
|
-
canRefresh: boolean;
|
|
13
|
-
reason: string;
|
|
14
|
-
sessionTokenId: null;
|
|
15
|
-
}>>;
|
|
1
|
+
import { NextRequest } from 'next/server';
|
|
2
|
+
/** PAY-2994 (NightHawk 92964 Low 1): the api-handlers/session/* path is also in the exports map; wrap here too so a consumer importing it is covered. */
|
|
3
|
+
export declare const GET: (req: NextRequest) => Promise<Response>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.GET =
|
|
3
|
+
exports.GET = void 0;
|
|
4
4
|
const server_1 = require("next/server");
|
|
5
5
|
const session_store_1 = require("../../lib/session-store");
|
|
6
6
|
const refresh_token_validator_1 = require("../../lib/refresh-token-validator");
|
|
@@ -8,8 +8,9 @@ const test_aware_get_token_1 = require("../../lib/test-aware-get-token");
|
|
|
8
8
|
const logger_1 = require("../../config/logger");
|
|
9
9
|
const token_log_id_1 = require("../../lib/token-log-id");
|
|
10
10
|
const log_safe_1 = require("../../lib/log-safe");
|
|
11
|
+
const no_store_1 = require("../../lib/no-store");
|
|
11
12
|
const auth_1 = require("../../server/auth");
|
|
12
|
-
async function
|
|
13
|
+
async function getRefreshViability(req) {
|
|
13
14
|
try {
|
|
14
15
|
const sessionToken = req.nextUrl.searchParams.get('token');
|
|
15
16
|
let finalSessionToken = sessionToken;
|
|
@@ -59,3 +60,5 @@ async function GET(req) {
|
|
|
59
60
|
return server_1.NextResponse.json({ canRefresh: false, reason: 'check_error', error: 'Failed to check refresh viability', code: 'INTERNAL_ERROR', correlationId }, { status: 500 });
|
|
60
61
|
}
|
|
61
62
|
}
|
|
63
|
+
/** PAY-2994 (NightHawk 92964 Low 1): the api-handlers/session/* path is also in the exports map; wrap here too so a consumer importing it is covered. */
|
|
64
|
+
exports.GET = (0, no_store_1.noStore)(getRefreshViability);
|
|
@@ -3,11 +3,6 @@
|
|
|
3
3
|
*
|
|
4
4
|
* This API route is called by the middleware to securely check if a session is valid.
|
|
5
5
|
*/
|
|
6
|
-
import { NextRequest
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
message: string;
|
|
10
|
-
code: string;
|
|
11
|
-
}> | NextResponse<{
|
|
12
|
-
authenticated: boolean;
|
|
13
|
-
}>>;
|
|
6
|
+
import { NextRequest } from 'next/server';
|
|
7
|
+
/** PAY-2994 (NightHawk 92964 Low 1): the api-handlers/session/* path is also in the exports map; wrap here too so a consumer importing it is covered. */
|
|
8
|
+
export declare const GET: (req: NextRequest) => Promise<Response>;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* This API route is called by the middleware to securely check if a session is valid.
|
|
6
6
|
*/
|
|
7
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
-
exports.GET =
|
|
8
|
+
exports.GET = void 0;
|
|
9
9
|
const revocation_fields_1 = require("../../auth/revocation-fields");
|
|
10
10
|
const server_1 = require("next/server");
|
|
11
11
|
const session_store_1 = require("../../lib/session-store");
|
|
@@ -15,7 +15,8 @@ const idp_client_config_1 = require("../../lib/idp-client-config");
|
|
|
15
15
|
const token_log_id_1 = require("../../lib/token-log-id");
|
|
16
16
|
const sessions_1 = require("../../vibe/sessions");
|
|
17
17
|
const log_safe_1 = require("../../lib/log-safe");
|
|
18
|
-
|
|
18
|
+
const no_store_1 = require("../../lib/no-store");
|
|
19
|
+
async function getViability(req) {
|
|
19
20
|
try {
|
|
20
21
|
// Ensure initialization is complete (auth signing secret resolved from IDP)
|
|
21
22
|
if (!process.env.BETTER_AUTH_SECRET && !process.env.NEXTAUTH_SECRET) {
|
|
@@ -218,3 +219,5 @@ async function GET(req) {
|
|
|
218
219
|
return server_1.NextResponse.json({ authenticated: false }, { status: 500 });
|
|
219
220
|
}
|
|
220
221
|
}
|
|
222
|
+
/** PAY-2994 (NightHawk 92964 Low 1): the api-handlers/session/* path is also in the exports map; wrap here too so a consumer importing it is covered. */
|
|
223
|
+
exports.GET = (0, no_store_1.noStore)(getViability);
|
package/dist/auth/better-auth.js
CHANGED
|
@@ -77,6 +77,7 @@ const single_flight_1 = require("../lib/single-flight");
|
|
|
77
77
|
const session_store_1 = require("../lib/session-store");
|
|
78
78
|
const token_log_id_1 = require("../lib/token-log-id");
|
|
79
79
|
const log_safe_1 = require("../lib/log-safe");
|
|
80
|
+
const no_store_1 = require("../lib/no-store");
|
|
80
81
|
/**
|
|
81
82
|
* 185121: short sha256 fingerprint for secrets/tokens in log lines — enough
|
|
82
83
|
* to tell "same" from "divergent", never enough to reconstruct. 12 hex chars
|
|
@@ -1395,7 +1396,7 @@ async function abortOAuthSignIn(callbackResponse, sessionToken, cookieHeader, lo
|
|
|
1395
1396
|
* ```
|
|
1396
1397
|
*/
|
|
1397
1398
|
function createAuthGetHandler(loginPath = '/account-auth/login') {
|
|
1398
|
-
|
|
1399
|
+
const get = async function GET(request) {
|
|
1399
1400
|
// Card 138635: establish the request-scoped carrier for the provider's raw
|
|
1400
1401
|
// id_token. It must wrap BOTH ba.GET (which invokes getUserInfo, the only
|
|
1401
1402
|
// hook that sees the token) and the exchange below (which forwards it).
|
|
@@ -1496,4 +1497,6 @@ function createAuthGetHandler(loginPath = '/account-auth/login') {
|
|
|
1496
1497
|
return response;
|
|
1497
1498
|
});
|
|
1498
1499
|
};
|
|
1500
|
+
// PAY-2994: every Better Auth GET answer (session, OAuth callbacks, redirects) is per-user and may set cookies; never cached.
|
|
1501
|
+
return (0, no_store_1.noStore)(get);
|
|
1499
1502
|
}
|
|
@@ -257,17 +257,18 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
257
257
|
}
|
|
258
258
|
catch { /* fall through */ }
|
|
259
259
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
260
|
+
// PAY-2425 (BAPert 77400, NightHawk 77401): THE CLIENT DOES NOT INFER AUTHENTICATION STRENGTH FROM SILENCE. This block used to answer an OAuth
|
|
261
|
+
// (Google) session whose stored methods were EMPTY with amr ['pwd','mfa'], a two-factor method of 'oauth' and an acr raised from '1' to '2' ("OAuth IS
|
|
262
|
+
// a form of multi-factor auth"). That is false: OAuth is federated sign-in, one factor typed at the provider. It fired exactly when the session
|
|
263
|
+
// could tell us nothing and answered with the strongest claim available; on prod (4.10.0) a Google user on a require2FA client was then counted
|
|
264
|
+
// 2FA-complete with no code (RigPert 77398). What the session does not attest, the refresh body does not assert: empty stays empty, the level stays
|
|
265
|
+
// the session's own, and no method is named unless one is recorded. The IdP computes the real amr/acr for the minted token (and mfaVerified is
|
|
266
|
+
// re-derived from THAT token below); a session that really attests 'mfa' still sends it. History: the fix existed on nextpert/375639-amr-fabrication
|
|
267
|
+
// (e6e3b6c3, e3b3bb12) and was never merged.
|
|
264
268
|
const twoFactorMethod = authMethods.find(m => ['sms', 'totp', 'email'].includes(m)) ||
|
|
265
269
|
session.mfaMethod ||
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
if (isOAuthSession && session.mfaVerified && acrValue === '1') {
|
|
269
|
-
acrValue = '2';
|
|
270
|
-
}
|
|
270
|
+
null;
|
|
271
|
+
const acrValue = String(session.authenticationLevel ?? '1');
|
|
271
272
|
// WO-2: send the POST-lock current refresh token, not the pre-lock snapshot.
|
|
272
273
|
// `session` was read before we acquired the lock; a concurrent refresh that
|
|
273
274
|
// completed while we waited rotates the token, so the pre-lock value is stale
|
|
@@ -300,7 +301,11 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
|
|
|
300
301
|
amr: authMethods,
|
|
301
302
|
acr: acrValue,
|
|
302
303
|
};
|
|
303
|
-
|
|
304
|
+
// PAY-2425 (NightHawk 77414): a STORED mfaVerified flag is only echoed when the session's own recorded methods back it. Sessions created while the
|
|
305
|
+
// bug was live carry mfaVerified:true that the bug itself wrote, with no 'mfa' (or sms/totp/email) in their methods; echoing that flag would carry the
|
|
306
|
+
// poison across the upgrade. A genuine second factor leaves 'mfa' and its method in the recorded methods, so it is still sent.
|
|
307
|
+
const methodsAttestSecondFactor = authMethods.some(m => m === 'mfa' || ['sms', 'totp', 'email'].includes(m));
|
|
308
|
+
if (session.mfaVerified && methodsAttestSecondFactor)
|
|
304
309
|
body.two_factor_verified = true;
|
|
305
310
|
if (twoFactorMethod)
|
|
306
311
|
body.two_factor_method = twoFactorMethod;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache-Control: no-store for GET routes whose answer carries a person's, a tenant's or a session's data (PAY-2994).
|
|
3
|
+
*
|
|
4
|
+
* WHY: the portal's BFF no-store ratchet (testing/qa/bff-no-store-ratchet.js) found 8 GET routes re-exported from this package that sent no
|
|
5
|
+
* Cache-Control. Their answers carry per-user data and some carry tokens, so a browser or an intermediate cache must never keep them.
|
|
6
|
+
*
|
|
7
|
+
* `noStore(handler)` wraps a route handler and sets `Cache-Control: no-store` on EVERY response it returns (errors and redirects too: nothing
|
|
8
|
+
* a session route says is worth caching). It REPLACES an existing Cache-Control, never merges, so a looser value cannot survive. A Response
|
|
9
|
+
* whose headers are immutable (a fetch() result, Response.redirect()) is rebuilt with the same status, body and headers (Set-Cookie kept).
|
|
10
|
+
*/
|
|
11
|
+
export declare const NO_STORE_VALUE = "no-store";
|
|
12
|
+
export declare function withNoStoreHeader(res: Response): Response;
|
|
13
|
+
export declare function noStore<A extends unknown[], R extends Response>(handler: (...args: A) => R | Promise<R>): (...args: A) => Promise<Response>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NO_STORE_VALUE = void 0;
|
|
4
|
+
exports.withNoStoreHeader = withNoStoreHeader;
|
|
5
|
+
exports.noStore = noStore;
|
|
6
|
+
/**
|
|
7
|
+
* Cache-Control: no-store for GET routes whose answer carries a person's, a tenant's or a session's data (PAY-2994).
|
|
8
|
+
*
|
|
9
|
+
* WHY: the portal's BFF no-store ratchet (testing/qa/bff-no-store-ratchet.js) found 8 GET routes re-exported from this package that sent no
|
|
10
|
+
* Cache-Control. Their answers carry per-user data and some carry tokens, so a browser or an intermediate cache must never keep them.
|
|
11
|
+
*
|
|
12
|
+
* `noStore(handler)` wraps a route handler and sets `Cache-Control: no-store` on EVERY response it returns (errors and redirects too: nothing
|
|
13
|
+
* a session route says is worth caching). It REPLACES an existing Cache-Control, never merges, so a looser value cannot survive. A Response
|
|
14
|
+
* whose headers are immutable (a fetch() result, Response.redirect()) is rebuilt with the same status, body and headers (Set-Cookie kept).
|
|
15
|
+
*/
|
|
16
|
+
exports.NO_STORE_VALUE = 'no-store';
|
|
17
|
+
function withNoStoreHeader(res) {
|
|
18
|
+
// Not a Response (no headers object): hand it back untouched. A Next route always returns a Response, so this only keeps a hand-made
|
|
19
|
+
// test double from being turned into an empty Response.
|
|
20
|
+
if (!res || typeof res !== 'object' || !res.headers)
|
|
21
|
+
return res;
|
|
22
|
+
try {
|
|
23
|
+
res.headers.set('Cache-Control', exports.NO_STORE_VALUE);
|
|
24
|
+
return res;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Immutable headers (TypeError). Rebuild: same status, statusText, body and every header; Headers copies Set-Cookie entries intact.
|
|
28
|
+
const headers = new Headers(res.headers);
|
|
29
|
+
headers.set('Cache-Control', exports.NO_STORE_VALUE);
|
|
30
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function noStore(handler) {
|
|
34
|
+
return async (...args) => withNoStoreHeader(await handler(...args));
|
|
35
|
+
}
|
|
@@ -13,15 +13,8 @@
|
|
|
13
13
|
* @since auth-ready-v2
|
|
14
14
|
*/
|
|
15
15
|
import { NextRequest, NextResponse } from 'next/server';
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
*
|
|
19
|
-
* Returns the current session information including:
|
|
20
|
-
* - User details
|
|
21
|
-
* - Token expiry status
|
|
22
|
-
* - Session validity
|
|
23
|
-
*/
|
|
24
|
-
export declare function GET(req: NextRequest): Promise<NextResponse<{}>>;
|
|
16
|
+
/** PAY-2994: session state is per-user and some answers carry tokens; never cached (noStore). */
|
|
17
|
+
export declare const GET: (req: NextRequest) => Promise<Response>;
|
|
25
18
|
/**
|
|
26
19
|
* POST /api/auth/session - Update session METADATA
|
|
27
20
|
*
|
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
* @since auth-ready-v2
|
|
15
15
|
*/
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.GET =
|
|
17
|
+
exports.GET = void 0;
|
|
18
18
|
exports.POST = POST;
|
|
19
|
+
const no_store_1 = require("../../lib/no-store");
|
|
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");
|
|
@@ -29,7 +30,7 @@ const token_log_id_1 = require("../../lib/token-log-id");
|
|
|
29
30
|
* - Token expiry status
|
|
30
31
|
* - Session validity
|
|
31
32
|
*/
|
|
32
|
-
async function
|
|
33
|
+
async function getHandler(req) {
|
|
33
34
|
try {
|
|
34
35
|
const authSession = await (0, auth_1.getSession)(req);
|
|
35
36
|
if (!authSession) {
|
|
@@ -116,6 +117,8 @@ async function GET(req) {
|
|
|
116
117
|
}, { status: 500 });
|
|
117
118
|
}
|
|
118
119
|
}
|
|
120
|
+
/** PAY-2994: session state is per-user and some answers carry tokens; never cached (noStore). */
|
|
121
|
+
exports.GET = (0, no_store_1.noStore)(getHandler);
|
|
119
122
|
/**
|
|
120
123
|
* The fields this route USED to accept from the request body and must never accept
|
|
121
124
|
* again. Cards 308833 (critical) / 312092 (remediation spec) / 318272 / 311425.
|
|
@@ -13,57 +13,6 @@
|
|
|
13
13
|
* @version 2.0.0
|
|
14
14
|
* @since auth-ready-v2
|
|
15
15
|
*/
|
|
16
|
-
import { NextRequest
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
* GET /api/session/viability - Check if session is viable
|
|
20
|
-
*
|
|
21
|
-
* Returns:
|
|
22
|
-
* - viable: boolean - Whether the session can be used
|
|
23
|
-
* - needsRefresh: boolean - Whether a refresh is recommended
|
|
24
|
-
* - expiresIn: number - Seconds until token expires
|
|
25
|
-
*/
|
|
26
|
-
export declare function GET(req: NextRequest): Promise<NextResponse<{
|
|
27
|
-
viable: boolean;
|
|
28
|
-
needsRefresh: boolean;
|
|
29
|
-
authenticated: boolean;
|
|
30
|
-
revocationStatus: RevocationStatus;
|
|
31
|
-
unavailable: boolean;
|
|
32
|
-
error: string;
|
|
33
|
-
code: string;
|
|
34
|
-
}> | NextResponse<{
|
|
35
|
-
viable: boolean;
|
|
36
|
-
needsRefresh: boolean;
|
|
37
|
-
authenticated: boolean;
|
|
38
|
-
reason: string;
|
|
39
|
-
}> | NextResponse<{
|
|
40
|
-
tenantRequiresTwoFactor: boolean;
|
|
41
|
-
userHasCompletedTenantTwoFactorRequirements: boolean;
|
|
42
|
-
userStillNeedsTwoFactor: boolean;
|
|
43
|
-
tenantAllowed2FAMethods: string[] | null;
|
|
44
|
-
nextStep: "enroll_mfa" | "mfa_verify" | null | undefined;
|
|
45
|
-
requires2FA: boolean;
|
|
46
|
-
twoFactorComplete: boolean;
|
|
47
|
-
accessTokenExpired: boolean;
|
|
48
|
-
expiresAt: string;
|
|
49
|
-
roles: string[];
|
|
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;
|
|
69
|
-
}>>;
|
|
16
|
+
import { NextRequest } from 'next/server';
|
|
17
|
+
/** PAY-2994: session state is per-user and some answers carry tokens; never cached (noStore). */
|
|
18
|
+
export declare const GET: (req: NextRequest) => Promise<Response>;
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* @since auth-ready-v2
|
|
16
16
|
*/
|
|
17
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
-
exports.GET =
|
|
18
|
+
exports.GET = void 0;
|
|
19
19
|
const revocation_fields_1 = require("../../auth/revocation-fields");
|
|
20
20
|
const server_1 = require("next/server");
|
|
21
21
|
const auth_1 = require("../../server/auth");
|
|
@@ -23,6 +23,7 @@ const session_store_1 = require("../../lib/session-store");
|
|
|
23
23
|
const idp_client_config_1 = require("../../lib/idp-client-config");
|
|
24
24
|
const safe_error_response_1 = require("../../lib/safe-error-response");
|
|
25
25
|
const token_log_id_1 = require("../../lib/token-log-id");
|
|
26
|
+
const no_store_1 = require("../../lib/no-store");
|
|
26
27
|
const sessions_1 = require("../../vibe/sessions");
|
|
27
28
|
/**
|
|
28
29
|
* Get tenant-wide 2FA requirement from cached client config (from broker handshake)
|
|
@@ -135,7 +136,7 @@ function resolveClientId(sessionClientId) {
|
|
|
135
136
|
* - needsRefresh: boolean - Whether a refresh is recommended
|
|
136
137
|
* - expiresIn: number - Seconds until token expires
|
|
137
138
|
*/
|
|
138
|
-
async function
|
|
139
|
+
async function getHandler(req) {
|
|
139
140
|
try {
|
|
140
141
|
// PAY-2216: ask for the session's STATE, not a session-or-null. A null used to cover a store outage, a deleted
|
|
141
142
|
// session and no cookie alike, so this route answered 'No session found' to all three and the middleware could
|
|
@@ -486,3 +487,5 @@ async function GET(req) {
|
|
|
486
487
|
}, { status: 500 });
|
|
487
488
|
}
|
|
488
489
|
}
|
|
490
|
+
/** PAY-2994: session state is per-user and some answers carry tokens; never cached (noStore). */
|
|
491
|
+
exports.GET = (0, no_store_1.noStore)(getHandler);
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* ```
|
|
11
11
|
*/
|
|
12
12
|
import { NextRequest } from 'next/server';
|
|
13
|
-
|
|
14
|
-
export declare const GET:
|
|
13
|
+
/** PAY-2994: the user's MFA methods; never cached (noStore). */
|
|
14
|
+
export declare const GET: (req: NextRequest) => Promise<Response>;
|
|
15
15
|
type Ctx = {
|
|
16
16
|
params: Promise<{
|
|
17
17
|
id: string;
|
|
@@ -3,7 +3,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.GET = void 0;
|
|
4
4
|
exports.DELETE = DELETE;
|
|
5
5
|
const methods_1 = require("../../api-handlers/mfa/methods");
|
|
6
|
-
|
|
6
|
+
const no_store_1 = require("../../lib/no-store");
|
|
7
|
+
/** PAY-2994: the user's MFA methods; never cached (noStore). */
|
|
8
|
+
exports.GET = (0, no_store_1.noStore)(methods_1.GET);
|
|
7
9
|
async function DELETE(req, ctx) {
|
|
8
10
|
const { id } = await ctx.params;
|
|
9
11
|
return (0, methods_1.DELETE)(req, id);
|
|
@@ -1,10 +1,2 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* @example
|
|
5
|
-
* ```typescript
|
|
6
|
-
* // app/api/passkeys/route.ts
|
|
7
|
-
* export { GET } from '@payez/next-mvp/routes/passkeys/list';
|
|
8
|
-
* ```
|
|
9
|
-
*/
|
|
10
|
-
export { GET } from '../../api-handlers/passkeys/list';
|
|
1
|
+
/** PAY-2994: the signed-in user's passkeys; never cached (noStore). */
|
|
2
|
+
export declare const GET: (req: import("next/server").NextRequest) => Promise<Response>;
|
|
@@ -10,5 +10,7 @@ exports.GET = void 0;
|
|
|
10
10
|
* export { GET } from '@payez/next-mvp/routes/passkeys/list';
|
|
11
11
|
* ```
|
|
12
12
|
*/
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
const list_1 = require("../../api-handlers/passkeys/list");
|
|
14
|
+
const no_store_1 = require("../../lib/no-store");
|
|
15
|
+
/** PAY-2994: the signed-in user's passkeys; never cached (noStore). */
|
|
16
|
+
exports.GET = (0, no_store_1.noStore)(list_1.GET);
|
|
@@ -13,4 +13,5 @@
|
|
|
13
13
|
* @version 2.0.0
|
|
14
14
|
* @since auth-ready-v2
|
|
15
15
|
*/
|
|
16
|
-
|
|
16
|
+
/** PAY-2994: refresh-token viability of this session; never cached (noStore). */
|
|
17
|
+
export declare const GET: (req: import("next/server").NextRequest) => Promise<Response>;
|
|
@@ -16,5 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
18
|
exports.GET = void 0;
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
const refresh_viability_1 = require("../../api-handlers/session/refresh-viability");
|
|
20
|
+
const no_store_1 = require("../../lib/no-store");
|
|
21
|
+
/** PAY-2994: refresh-token viability of this session; never cached (noStore). */
|
|
22
|
+
exports.GET = (0, no_store_1.noStore)(refresh_viability_1.GET);
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from 'next/server';
|
|
2
2
|
import { idpFetchJSON } from '../../lib/idp-fetch';
|
|
3
3
|
import { ENV_CONFIG } from '../../config/env';
|
|
4
|
+
import { noStore } from '../../lib/no-store';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Account Profile API Handler
|
|
@@ -41,7 +42,7 @@ import { ENV_CONFIG } from '../../config/env';
|
|
|
41
42
|
* @package @payez/next-mvp
|
|
42
43
|
*/
|
|
43
44
|
|
|
44
|
-
|
|
45
|
+
async function getProfile(req: NextRequest) {
|
|
45
46
|
const result = await idpFetchJSON(req, `${ENV_CONFIG.IDP_URL}/api/Account/profile`, {
|
|
46
47
|
method: 'GET',
|
|
47
48
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -65,6 +66,9 @@ export async function GET(req: NextRequest) {
|
|
|
65
66
|
return NextResponse.json(result.json, { status: 200 });
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
/** PAY-2994: the profile is one person's data; never cached (noStore). */
|
|
70
|
+
export const GET = noStore(getProfile);
|
|
71
|
+
|
|
68
72
|
export async function PUT(req: NextRequest) {
|
|
69
73
|
let body = '{}';
|
|
70
74
|
try {
|
|
@@ -16,13 +16,12 @@ import {
|
|
|
16
16
|
AnonSessionPreferences,
|
|
17
17
|
} from '../../lib/anon-session';
|
|
18
18
|
import { errForLog } from '../../lib/log-safe';
|
|
19
|
+
import { noStore } from '../../lib/no-store';
|
|
19
20
|
|
|
20
21
|
const COOKIE_MAX_AGE = 90 * 24 * 60 * 60; // 90 days in seconds
|
|
21
22
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
-
*/
|
|
25
|
-
export async function GET(request: NextRequest) {
|
|
23
|
+
/** Retrieves anonymous session preferences. Exported as GET below, wrapped in noStore (PAY-2994). */
|
|
24
|
+
async function getPreferences(request: NextRequest) {
|
|
26
25
|
try {
|
|
27
26
|
const cookieStore = await cookies();
|
|
28
27
|
let anonId = cookieStore.get(ANON_COOKIE_NAME)?.value;
|
|
@@ -61,6 +60,11 @@ export async function GET(request: NextRequest) {
|
|
|
61
60
|
}
|
|
62
61
|
}
|
|
63
62
|
|
|
63
|
+
/**
|
|
64
|
+
* POST handler - updates anonymous session preferences
|
|
65
|
+
*/
|
|
66
|
+
export const GET = noStore(getPreferences);
|
|
67
|
+
|
|
64
68
|
/**
|
|
65
69
|
* POST handler - updates anonymous session preferences
|
|
66
70
|
*/
|
|
@@ -5,9 +5,10 @@ import { getTokenTestAware } from '../../lib/test-aware-get-token';
|
|
|
5
5
|
import { logger } from '../../config/logger';
|
|
6
6
|
import { tokenLogId } from '../../lib/token-log-id';
|
|
7
7
|
import { errForLog } from '../../lib/log-safe';
|
|
8
|
+
import { noStore } from '../../lib/no-store';
|
|
8
9
|
import { resolveSessionState } from '../../server/auth';
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
async function getRefreshViability(req: NextRequest) {
|
|
11
12
|
try {
|
|
12
13
|
const sessionToken = req.nextUrl.searchParams.get('token');
|
|
13
14
|
let finalSessionToken = sessionToken;
|
|
@@ -55,3 +56,6 @@ export async function GET(req: NextRequest) {
|
|
|
55
56
|
return NextResponse.json({ canRefresh: false, reason: 'check_error', error: 'Failed to check refresh viability', code: 'INTERNAL_ERROR', correlationId }, { status: 500 });
|
|
56
57
|
}
|
|
57
58
|
}
|
|
59
|
+
|
|
60
|
+
/** PAY-2994 (NightHawk 92964 Low 1): the api-handlers/session/* path is also in the exports map; wrap here too so a consumer importing it is covered. */
|
|
61
|
+
export const GET = noStore(getRefreshViability);
|
|
@@ -13,8 +13,9 @@ import { getIDPClientConfig } from '../../lib/idp-client-config';
|
|
|
13
13
|
import { tokenLogId } from '../../lib/token-log-id';
|
|
14
14
|
import { getRevocationStatus, warnNonNumericUserId, resolveNumericIdpUserId, type RevocationStatus } from '../../vibe/sessions';
|
|
15
15
|
import { errForLog } from '../../lib/log-safe';
|
|
16
|
+
import { noStore } from '../../lib/no-store';
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
async function getViability(req: NextRequest) {
|
|
18
19
|
try {
|
|
19
20
|
// Ensure initialization is complete (auth signing secret resolved from IDP)
|
|
20
21
|
if (!process.env.BETTER_AUTH_SECRET && !process.env.NEXTAUTH_SECRET) {
|
|
@@ -237,4 +238,7 @@ export async function GET(req: NextRequest) {
|
|
|
237
238
|
console.error('[API Viability] Error checking session viability:', errForLog(error));
|
|
238
239
|
return NextResponse.json({ authenticated: false }, { status: 500 });
|
|
239
240
|
}
|
|
240
|
-
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** PAY-2994 (NightHawk 92964 Low 1): the api-handlers/session/* path is also in the exports map; wrap here too so a consumer importing it is covered. */
|
|
244
|
+
export const GET = noStore(getViability);
|
package/src/auth/better-auth.ts
CHANGED
|
@@ -34,6 +34,7 @@ import { createSingleFlight, type SingleFlight } from '../lib/single-flight';
|
|
|
34
34
|
import { SESSION_TTL } from '../lib/session-store';
|
|
35
35
|
import { tokenLogId } from '../lib/token-log-id';
|
|
36
36
|
import { errForLog } from '../lib/log-safe';
|
|
37
|
+
import { noStore } from '../lib/no-store';
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* 185121: short sha256 fingerprint for secrets/tokens in log lines — enough
|
|
@@ -1582,7 +1583,7 @@ async function abortOAuthSignIn(
|
|
|
1582
1583
|
* ```
|
|
1583
1584
|
*/
|
|
1584
1585
|
export function createAuthGetHandler(loginPath: string = '/account-auth/login') {
|
|
1585
|
-
|
|
1586
|
+
const get = async function GET(request: Request): Promise<Response> {
|
|
1586
1587
|
// Card 138635: establish the request-scoped carrier for the provider's raw
|
|
1587
1588
|
// id_token. It must wrap BOTH ba.GET (which invokes getUserInfo, the only
|
|
1588
1589
|
// hook that sees the token) and the exchange below (which forwards it).
|
|
@@ -1686,4 +1687,6 @@ export function createAuthGetHandler(loginPath: string = '/account-auth/login')
|
|
|
1686
1687
|
return response;
|
|
1687
1688
|
});
|
|
1688
1689
|
};
|
|
1690
|
+
// PAY-2994: every Better Auth GET answer (session, OAuth callbacks, redirects) is per-user and may set cookies; never cached.
|
|
1691
|
+
return noStore(get);
|
|
1689
1692
|
}
|
|
@@ -349,19 +349,20 @@ export async function ensureFreshAccessToken(
|
|
|
349
349
|
} else if (typeof session.authenticationMethods === 'string') {
|
|
350
350
|
try { authMethods = JSON.parse(session.authenticationMethods); } catch { /* fall through */ }
|
|
351
351
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
352
|
+
// PAY-2425 (BAPert 77400, NightHawk 77401): THE CLIENT DOES NOT INFER AUTHENTICATION STRENGTH FROM SILENCE. This block used to answer an OAuth
|
|
353
|
+
// (Google) session whose stored methods were EMPTY with amr ['pwd','mfa'], a two-factor method of 'oauth' and an acr raised from '1' to '2' ("OAuth IS
|
|
354
|
+
// a form of multi-factor auth"). That is false: OAuth is federated sign-in, one factor typed at the provider. It fired exactly when the session
|
|
355
|
+
// could tell us nothing and answered with the strongest claim available; on prod (4.10.0) a Google user on a require2FA client was then counted
|
|
356
|
+
// 2FA-complete with no code (RigPert 77398). What the session does not attest, the refresh body does not assert: empty stays empty, the level stays
|
|
357
|
+
// the session's own, and no method is named unless one is recorded. The IdP computes the real amr/acr for the minted token (and mfaVerified is
|
|
358
|
+
// re-derived from THAT token below); a session that really attests 'mfa' still sends it. History: the fix existed on nextpert/375639-amr-fabrication
|
|
359
|
+
// (e6e3b6c3, e3b3bb12) and was never merged.
|
|
356
360
|
const twoFactorMethod =
|
|
357
361
|
authMethods.find(m => ['sms', 'totp', 'email'].includes(m)) ||
|
|
358
362
|
session.mfaMethod ||
|
|
359
|
-
|
|
363
|
+
null;
|
|
360
364
|
|
|
361
|
-
|
|
362
|
-
if (isOAuthSession && session.mfaVerified && acrValue === '1') {
|
|
363
|
-
acrValue = '2';
|
|
364
|
-
}
|
|
365
|
+
const acrValue = String(session.authenticationLevel ?? '1');
|
|
365
366
|
|
|
366
367
|
// WO-2: send the POST-lock current refresh token, not the pre-lock snapshot.
|
|
367
368
|
// `session` was read before we acquired the lock; a concurrent refresh that
|
|
@@ -397,7 +398,11 @@ export async function ensureFreshAccessToken(
|
|
|
397
398
|
amr: authMethods,
|
|
398
399
|
acr: acrValue,
|
|
399
400
|
};
|
|
400
|
-
|
|
401
|
+
// PAY-2425 (NightHawk 77414): a STORED mfaVerified flag is only echoed when the session's own recorded methods back it. Sessions created while the
|
|
402
|
+
// bug was live carry mfaVerified:true that the bug itself wrote, with no 'mfa' (or sms/totp/email) in their methods; echoing that flag would carry the
|
|
403
|
+
// poison across the upgrade. A genuine second factor leaves 'mfa' and its method in the recorded methods, so it is still sent.
|
|
404
|
+
const methodsAttestSecondFactor = authMethods.some(m => m === 'mfa' || ['sms', 'totp', 'email'].includes(m));
|
|
405
|
+
if (session.mfaVerified && methodsAttestSecondFactor) body.two_factor_verified = true;
|
|
401
406
|
if (twoFactorMethod) body.two_factor_method = twoFactorMethod;
|
|
402
407
|
if (session.mfaCompletedAt) body.two_factor_completed_at = new Date(session.mfaCompletedAt).toISOString();
|
|
403
408
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache-Control: no-store for GET routes whose answer carries a person's, a tenant's or a session's data (PAY-2994).
|
|
3
|
+
*
|
|
4
|
+
* WHY: the portal's BFF no-store ratchet (testing/qa/bff-no-store-ratchet.js) found 8 GET routes re-exported from this package that sent no
|
|
5
|
+
* Cache-Control. Their answers carry per-user data and some carry tokens, so a browser or an intermediate cache must never keep them.
|
|
6
|
+
*
|
|
7
|
+
* `noStore(handler)` wraps a route handler and sets `Cache-Control: no-store` on EVERY response it returns (errors and redirects too: nothing
|
|
8
|
+
* a session route says is worth caching). It REPLACES an existing Cache-Control, never merges, so a looser value cannot survive. A Response
|
|
9
|
+
* whose headers are immutable (a fetch() result, Response.redirect()) is rebuilt with the same status, body and headers (Set-Cookie kept).
|
|
10
|
+
*/
|
|
11
|
+
export const NO_STORE_VALUE = 'no-store';
|
|
12
|
+
|
|
13
|
+
export function withNoStoreHeader(res: Response): Response {
|
|
14
|
+
// Not a Response (no headers object): hand it back untouched. A Next route always returns a Response, so this only keeps a hand-made
|
|
15
|
+
// test double from being turned into an empty Response.
|
|
16
|
+
if (!res || typeof res !== 'object' || !(res as { headers?: unknown }).headers) return res;
|
|
17
|
+
try {
|
|
18
|
+
res.headers.set('Cache-Control', NO_STORE_VALUE);
|
|
19
|
+
return res;
|
|
20
|
+
} catch {
|
|
21
|
+
// Immutable headers (TypeError). Rebuild: same status, statusText, body and every header; Headers copies Set-Cookie entries intact.
|
|
22
|
+
const headers = new Headers(res.headers);
|
|
23
|
+
headers.set('Cache-Control', NO_STORE_VALUE);
|
|
24
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function noStore<A extends unknown[], R extends Response>(handler: (...args: A) => R | Promise<R>): (...args: A) => Promise<Response> {
|
|
29
|
+
return async (...args: A) => withNoStoreHeader(await handler(...args));
|
|
30
|
+
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* @since auth-ready-v2
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { noStore } from '../../lib/no-store';
|
|
16
17
|
import { NextRequest, NextResponse } from 'next/server';
|
|
17
18
|
import { getSession as getBetterAuthSession } from '../../server/auth';
|
|
18
19
|
import { getSession as getRedisSession, updateSession } from '../../lib/session-store';
|
|
@@ -27,7 +28,7 @@ import { tokenLogId } from '../../lib/token-log-id';
|
|
|
27
28
|
* - Token expiry status
|
|
28
29
|
* - Session validity
|
|
29
30
|
*/
|
|
30
|
-
|
|
31
|
+
async function getHandler(req: NextRequest) {
|
|
31
32
|
try {
|
|
32
33
|
const authSession = await getBetterAuthSession(req);
|
|
33
34
|
|
|
@@ -121,6 +122,9 @@ export async function GET(req: NextRequest) {
|
|
|
121
122
|
}
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
/** PAY-2994: session state is per-user and some answers carry tokens; never cached (noStore). */
|
|
126
|
+
export const GET = noStore(getHandler);
|
|
127
|
+
|
|
124
128
|
/**
|
|
125
129
|
* The fields this route USED to accept from the request body and must never accept
|
|
126
130
|
* again. Cards 308833 (critical) / 312092 (remediation spec) / 318272 / 311425.
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
import { getIDPClientConfig } from '../../lib/idp-client-config';
|
|
25
25
|
import { logExceptionWithCorrelation } from '../../lib/safe-error-response';
|
|
26
26
|
import { tokenLogId } from '../../lib/token-log-id';
|
|
27
|
+
import { noStore } from '../../lib/no-store';
|
|
27
28
|
import { getRevocationStatus, warnNonNumericUserId, resolveNumericIdpUserId, type RevocationStatus } from '../../vibe/sessions';
|
|
28
29
|
|
|
29
30
|
/**
|
|
@@ -141,7 +142,7 @@ function resolveClientId(sessionClientId: string | undefined): string | null {
|
|
|
141
142
|
* - needsRefresh: boolean - Whether a refresh is recommended
|
|
142
143
|
* - expiresIn: number - Seconds until token expires
|
|
143
144
|
*/
|
|
144
|
-
|
|
145
|
+
async function getHandler(req: NextRequest) {
|
|
145
146
|
try {
|
|
146
147
|
// PAY-2216: ask for the session's STATE, not a session-or-null. A null used to cover a store outage, a deleted
|
|
147
148
|
// session and no cookie alike, so this route answered 'No session found' to all three and the middleware could
|
|
@@ -517,4 +518,7 @@ export async function GET(req: NextRequest) {
|
|
|
517
518
|
correlationId
|
|
518
519
|
}, { status: 500 });
|
|
519
520
|
}
|
|
520
|
-
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** PAY-2994: session state is per-user and some answers carry tokens; never cached (noStore). */
|
|
524
|
+
export const GET = noStore(getHandler);
|
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { NextRequest } from 'next/server';
|
|
13
13
|
import { GET as listMethods, DELETE as deleteMethod } from '../../api-handlers/mfa/methods';
|
|
14
|
+
import { noStore } from '../../lib/no-store';
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
/** PAY-2994: the user's MFA methods; never cached (noStore). */
|
|
17
|
+
export const GET = noStore(listMethods);
|
|
16
18
|
|
|
17
19
|
type Ctx = { params: Promise<{ id: string }> };
|
|
18
20
|
|
|
@@ -7,4 +7,8 @@
|
|
|
7
7
|
* export { GET } from '@payez/next-mvp/routes/passkeys/list';
|
|
8
8
|
* ```
|
|
9
9
|
*/
|
|
10
|
-
|
|
10
|
+
import { GET as listPasskeys } from '../../api-handlers/passkeys/list';
|
|
11
|
+
import { noStore } from '../../lib/no-store';
|
|
12
|
+
|
|
13
|
+
/** PAY-2994: the signed-in user's passkeys; never cached (noStore). */
|
|
14
|
+
export const GET = noStore(listPasskeys);
|
|
@@ -14,4 +14,8 @@
|
|
|
14
14
|
* @since auth-ready-v2
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
import { GET as refreshViability } from '../../api-handlers/session/refresh-viability';
|
|
18
|
+
import { noStore } from '../../lib/no-store';
|
|
19
|
+
|
|
20
|
+
/** PAY-2994: refresh-token viability of this session; never cached (noStore). */
|
|
21
|
+
export const GET = noStore(refreshViability);
|