@payez/next-mvp 4.5.5 → 4.5.7

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.
@@ -78,8 +78,46 @@ export declare function getAuthInstance(): Promise<any>;
78
78
  * getFreshIdpToken → NO_SESSION (Better Auth cache miss)
79
79
  *
80
80
  * Returns the session object or null if not authenticated per Redis.
81
+ *
82
+ * PAY-2216: this is now a thin wrapper over {@link resolveSessionState} and returns the session ONLY for `live`.
83
+ * Its signature and every answer it gives are unchanged; callers that must tell a dead session from an
84
+ * unreachable store call resolveSessionState directly.
81
85
  */
82
86
  export declare function getSession(request?: Request): Promise<any>;
87
+ /**
88
+ * PAY-2216: what the session store said about this request's cookie. `getSession()` collapses all three
89
+ * non-live answers to `null`, and that collapse is the defect this type exists to undo.
90
+ *
91
+ * MEASURED (PAY-2216, NextPert 2026-10-11, portal fe547fa4 / next-mvp 4.5.4 / ioredis 5.3.2, real server):
92
+ * a Redis OUTAGE and a really DELETED key both came out of getSession() as `null`, so viability answered the
93
+ * identical `200 No session found` for both. Clearing on that shape would log every live user out on an
94
+ * outage (the PAY-2147 class), so nothing cleared at all and a dead cookie lingered.
95
+ *
96
+ * live - the session resolved. `session` is exactly what getSession() returns.
97
+ * missing - a cookie whose signature VERIFIED (HMAC or JWS, resolveVerifiedSessionTokenFromRequest) and BOTH
98
+ * store reads (canonical `{slug}:sess:{t}` and Better Auth's `ba:{slug}:{t}`) COMPLETED and found
99
+ * nothing. The store answered; the session is gone. Clearing the cookie is correct. This includes
100
+ * a Redis that restarted EMPTY: those sessions really are gone (BAPert 72093, gate row 6).
101
+ * unknown - a store read THREW (connection refused, commandTimeout, max retries). The store did not answer,
102
+ * so nothing is known about the session. NEVER clear on this. Callers answer 5xx.
103
+ * none - no session cookie, or a cookie whose signature does not verify, with the store not failing.
104
+ * Nothing to clear on our authority: a forged cookie never yields `missing`.
105
+ *
106
+ * The rule the whole type exists for: `missing` REQUIRES a verified cookie AND reads that completed. Weaken
107
+ * either and an outage or a forged cookie becomes a clear.
108
+ */
109
+ export type SessionState = {
110
+ state: 'live';
111
+ session: any;
112
+ } | {
113
+ state: 'missing';
114
+ sessionToken: string;
115
+ } | {
116
+ state: 'unknown';
117
+ } | {
118
+ state: 'none';
119
+ };
120
+ export declare function resolveSessionState(request?: Request): Promise<SessionState>;
83
121
  /**
84
122
  * Get normalized session data for the current request.
85
123
  *
@@ -41,6 +41,7 @@ var __importStar = (this && this.__importStar) || (function () {
41
41
  Object.defineProperty(exports, "__esModule", { value: true });
42
42
  exports.getAuthInstance = getAuthInstance;
43
43
  exports.getSession = getSession;
44
+ exports.resolveSessionState = resolveSessionState;
44
45
  exports.getSessionData = getSessionData;
45
46
  exports.getIdpToken = getIdpToken;
46
47
  exports.getFreshIdpToken = getFreshIdpToken;
@@ -230,18 +231,32 @@ function logNoSessionWithKeys(origin, sessionToken) {
230
231
  * getFreshIdpToken → NO_SESSION (Better Auth cache miss)
231
232
  *
232
233
  * Returns the session object or null if not authenticated per Redis.
234
+ *
235
+ * PAY-2216: this is now a thin wrapper over {@link resolveSessionState} and returns the session ONLY for `live`.
236
+ * Its signature and every answer it gives are unchanged; callers that must tell a dead session from an
237
+ * unreachable store call resolveSessionState directly.
233
238
  */
234
239
  async function getSession(request) {
240
+ const state = await resolveSessionState(request);
241
+ return state.state === 'live' ? state.session : null;
242
+ }
243
+ async function resolveSessionState(request) {
235
244
  const auth = await getAuthInstance();
236
245
  if (!request)
237
- return null;
246
+ return { state: 'none' };
238
247
  // Path A — Better Auth primary path (cookie cache → secondary storage).
239
248
  // Fast happy-path when both Better Auth and Redis agree.
249
+ //
250
+ // PAY-2216: secondaryStorage.get RETHROWS a Redis failure on purpose (170033), so a throw here means "the store
251
+ // could not be asked". It is remembered: if Path B then has no verified token to read with, the honest answer is
252
+ // `unknown`, not `none`.
240
253
  let betterAuthSession = null;
254
+ let primaryThrew = false;
241
255
  try {
242
256
  betterAuthSession = await auth.api.getSession({ headers: request.headers });
243
257
  }
244
258
  catch (err) {
259
+ primaryThrew = true;
245
260
  logSessionReadSwallowed('primary', err); // fall through to canonical Redis path
246
261
  }
247
262
  if (betterAuthSession?.session?.token && betterAuthSession?.user) {
@@ -257,7 +272,7 @@ async function getSession(request) {
257
272
  }
258
273
  if (!sessionData)
259
274
  sessionData = buildSessionDataFromAuthSession(betterAuthSession);
260
- return attachSessionData(betterAuthSession, sessionData, sessionToken);
275
+ return { state: 'live', session: attachSessionData(betterAuthSession, sessionData, sessionToken) };
261
276
  }
262
277
  // Path B — Better Auth said null/incomplete. Fall back to a VERIFIED cookie
263
278
  // decode + Redis-canonical liveness check. This catches cases where the
@@ -271,8 +286,12 @@ async function getSession(request) {
271
286
  // the signature position, and a forged JWS too. The token now comes only from
272
287
  // the shared verified resolver (HMAC or JWS signature checked first).
273
288
  const sessionToken = await (0, verified_session_token_1.resolveVerifiedSessionTokenFromRequest)(request);
289
+ // No verified token. If Better Auth's read THREW, the store was failing, so we cannot say the cookie is
290
+ // unverifiable rather than unreadable: `unknown`. Otherwise there is nothing to act on: `none`.
274
291
  if (!sessionToken)
275
- return null;
292
+ return primaryThrew ? { state: 'unknown' } : { state: 'none' };
293
+ // PAY-2216: both reads must COMPLETE for a miss to mean anything. Either one throwing is `unknown`, which
294
+ // nobody may clear on. These two functions do a bare redis.get and do not swallow (verified at 4.5.4).
276
295
  let sessionData = null;
277
296
  try {
278
297
  sessionData = await (0, session_store_1.getSession)(sessionToken);
@@ -281,9 +300,10 @@ async function getSession(request) {
281
300
  }
282
301
  catch (err) {
283
302
  logSessionReadSwallowed('B', err); // Redis unavailable
303
+ return { state: 'unknown' };
284
304
  }
285
305
  if (!sessionData)
286
- return null;
306
+ return { state: 'missing', sessionToken };
287
307
  // Synthesize a minimal session shape compatible with downstream callers.
288
308
  // The `session.token` field is what `getFreshIdpToken` and other callers
289
309
  // read to drive `ensureFreshAccessToken` + Redis lookups, so it MUST be
@@ -296,7 +316,7 @@ async function getSession(request) {
296
316
  roles: sessionData.roles,
297
317
  },
298
318
  };
299
- return attachSessionData(synthetic, sessionData, sessionToken);
319
+ return { state: 'live', session: attachSessionData(synthetic, sessionData, sessionToken) };
300
320
  }
301
321
  /**
302
322
  * Get normalized session data for the current request.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payez/next-mvp",
3
- "version": "4.5.5",
3
+ "version": "4.5.7",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -700,6 +700,11 @@
700
700
  "require": "./dist/routes/auth/logout.js",
701
701
  "default": "./dist/routes/auth/logout.js"
702
702
  },
703
+ "./routes/auth/session-reset": {
704
+ "types": "./dist/routes/auth/session-reset.d.ts",
705
+ "require": "./dist/routes/auth/session-reset.js",
706
+ "default": "./dist/routes/auth/session-reset.js"
707
+ },
703
708
  "./routes/auth/viability": {
704
709
  "types": "./dist/routes/auth/viability.d.ts",
705
710
  "require": "./dist/routes/auth/viability.js",
@@ -4,12 +4,29 @@ import { checkRefreshViability } from '../../lib/refresh-token-validator';
4
4
  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
+ import { resolveSessionState } from '../../server/auth';
7
8
 
8
9
  export async function GET(req: NextRequest) {
9
10
  try {
10
11
  const sessionToken = req.nextUrl.searchParams.get('token');
11
12
  let finalSessionToken = sessionToken;
12
- if (!finalSessionToken) {
13
+ if (!finalSessionToken && process.env.TEST_MODE !== 'true') {
14
+ // PAY-2216: outside TEST_MODE getTokenTestAware is getSession(), which answered null for a store outage AND
15
+ // for a deleted session, so this route said not_logged_in to both and its own session_not_found arm (below)
16
+ // was unreachable from a cookie. Ask for the state instead. Same rules as routes/auth/viability.ts:
17
+ // 'unknown' never reads as logged out or stale; 'missing' requires a verified cookie.
18
+ const sessionState = await resolveSessionState(req);
19
+ if (sessionState.state === 'unknown') {
20
+ logger.warn('[REFRESH-VIABILITY] Session store unavailable - session state unknown');
21
+ return NextResponse.json({ canRefresh: false, reason: 'store_unavailable', error: 'Session store unavailable', code: 'STORE_UNAVAILABLE' }, { status: 500 });
22
+ }
23
+ if (sessionState.state === 'missing') {
24
+ return NextResponse.json({ canRefresh: false, reason: 'session_not_found', sessionTokenId: tokenLogId(sessionState.sessionToken) }, { status: 200 });
25
+ }
26
+ if (sessionState.state === 'live') {
27
+ finalSessionToken = sessionState.session?.session?.token ?? null;
28
+ }
29
+ } else if (!finalSessionToken) {
13
30
  const token = await getTokenTestAware(req);
14
31
  // Support both field names: sessionToken (auth.ts JWT) and redisSessionId (legacy)
15
32
  finalSessionToken = (token?.sessionToken || token?.redisSessionId) as string;
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { NextRequest, NextResponse } from 'next/server';
8
8
  import { getSession as getRedisSession, getBetterAuthSession } from '../../lib/session-store';
9
- import { getSession } from '../../server/auth';
9
+ import { resolveSessionState } from '../../server/auth';
10
10
  import { isInitializationFailed, ensureInitialized } from '../../lib/startup-init';
11
11
  import { getIDPClientConfig } from '../../lib/idp-client-config';
12
12
  import { tokenLogId } from '../../lib/token-log-id';
@@ -45,8 +45,25 @@ export async function GET(req: NextRequest) {
45
45
  );
46
46
  }
47
47
 
48
- // Get session from Better Auth
49
- const betterAuthSession = await getSession(req);
48
+ // PAY-2216: the TWIN of routes/auth/viability.ts - same tri-state, same answers, or an app mounting this export
49
+ // keeps the defect. 'unknown' (store did not answer) is a 5xx and never stale; 'missing' (verified cookie, store
50
+ // answered "not here") is the stale shape with a token id; 'none' stays { authenticated: false }.
51
+ const sessionState = await resolveSessionState(req);
52
+ if (sessionState.state === 'unknown') {
53
+ console.warn('[VIABILITY] Session store unavailable - session state unknown');
54
+ return NextResponse.json(
55
+ { authenticated: false, revocationStatus: 'unknown' as RevocationStatus, error: 'Session store unavailable', code: 'STORE_UNAVAILABLE' },
56
+ { status: 500 }
57
+ );
58
+ }
59
+ if (sessionState.state === 'missing') {
60
+ console.warn('[VIABILITY] Stale cookie detected - verified cookie, session not in Redis');
61
+ return NextResponse.json({
62
+ authenticated: false,
63
+ sessionTokenId: tokenLogId(sessionState.sessionToken) // PAY-1481: correlation id, never the token
64
+ });
65
+ }
66
+ const betterAuthSession = sessionState.state === 'live' ? sessionState.session : null;
50
67
 
51
68
  // Debug logging
52
69
  if (!betterAuthSession) {
@@ -95,6 +95,11 @@ export function safeCallbackPath(raw: unknown, fallback: string = DEFAULT_CALLBA
95
95
  // Loopbacks into the auth surface cause redirect cycles.
96
96
  if (url.pathname.startsWith(AUTH_PREFIX)) return fallback;
97
97
 
98
+ // PAY-2218: `_rsc` is Next's flight cache-buster. Once skipProxyUrlNormalize lets it reach middleware it leaks
99
+ // into return targets (`callbackUrl=/overview?_rsc=abc12`, NightHawk 72013); it is never a meaningful part of a
100
+ // destination. Only touched when present, so every other query keeps its exact spelling.
101
+ if (url.searchParams.has('_rsc')) url.searchParams.delete('_rsc');
102
+
98
103
  // The parser's normalised output - control characters already stripped.
99
104
  const candidate = url.pathname + url.search + url.hash;
100
105
 
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The ONE body that writes the session-cookie expiry Set-Cookie headers. Shared by the middleware
3
+ * (create-middleware.ts clearSessionCookies, behind its prefetch guard) and the PAY-2218 session-reset route, so
4
+ * the two clearing sites cannot drift apart on which cookies they expire or with which attributes.
5
+ */
6
+ import type { NextResponse } from 'next/server';
7
+ import { getSessionCookieName, getSecureSessionCookieName } from '../lib/app-slug';
8
+
9
+ export function expireSessionCookies(response: NextResponse, requestCookieNames: Iterable<string>): void {
10
+ const sessionCookie = getSessionCookieName();
11
+ const secureCookie = getSecureSessionCookieName();
12
+
13
+ // Clear main cookies
14
+ response.cookies.set(sessionCookie, '', { path: '/', expires: new Date(0), httpOnly: true, sameSite: 'lax' });
15
+ response.cookies.set(secureCookie, '', { path: '/', expires: new Date(0), httpOnly: true, sameSite: 'lax', secure: true });
16
+
17
+ // Clear any chunked cookies (NextAuth chunks large cookies)
18
+ for (const name of requestCookieNames) {
19
+ if (name.startsWith(`${sessionCookie}.`) || name.startsWith(`${secureCookie}.`)) {
20
+ const isSecure = name.startsWith('__Secure-');
21
+ response.cookies.set(name, '', {
22
+ path: '/',
23
+ expires: new Date(0),
24
+ httpOnly: true,
25
+ sameSite: 'lax',
26
+ ...(isSecure && { secure: true })
27
+ });
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * PAY-2218 PR 2: the hand-off between the middleware and the session-reset route.
3
+ *
4
+ * WHY A ROUTE AT ALL. Next 15.5.x and 16.x (16.2.x without `skipProxyUrlNormalize`) strip every router flight
5
+ * header (`rsc`, `next-router-prefetch`, `next-router-segment-prefetch`, `next-router-state-tree`) from the request
6
+ * handed to middleware, so middleware cannot tell a router prefetch from a real navigation and its prefetch guard is
7
+ * dead. A ROUTE HANDLER does see them, on 15.5.9, 16.1.5 and 16.2.12 (measured with a real Chromium against real
8
+ * `next start` servers, PAY-2218 comments 2026-10-11). So the middleware no longer writes the clearing Set-Cookie
9
+ * itself: it redirects to the reset route, and the route decides.
10
+ *
11
+ * WHY THE ROUTE ANSWERS A PREFETCH WITH 204, NOT A REDIRECT. On 15.5.9 the router CACHES a prefetched redirect chain
12
+ * and the later click reuses it without calling middleware or the route again - so "decline on the prefetch, clear
13
+ * on the real navigation" never clears there. A 204 + no-store gives the router nothing to cache, so the real click
14
+ * reaches the route and clears (measured 2/2 on 15.5.9 and 16.2.12; the 307 mutant never clears on 15.5.9).
15
+ *
16
+ * WHY SIGNED. The route clears cookies on a GET. Unsigned, any page could link a signed-in user to it and log them
17
+ * out. The middleware signs (callbackUrl, reason, error, exp, session binding); the route clears only on a valid,
18
+ * unexpired signature and otherwise just sends the user to the login page with the cookies untouched.
19
+ *
20
+ * KEY SEPARATION (BAPert 72219). The signing key is DERIVED - HMAC(authSecret, 'next-mvp/session-reset/v1') - never
21
+ * the raw auth secret, so no value the app signs with that secret for any other purpose can pass as a reset token.
22
+ *
23
+ * SESSION BINDING (BAPert 72219). The signature covers SHA-256 of the session cookie the redirect was issued FOR. The
24
+ * route recomputes it from ITS OWN request's cookie and clears only on a match, so a token captured from one user
25
+ * clears nothing when sent to another (logout-CSRF), and a second use is a no-op (the first use cleared the cookie it
26
+ * was bound to). The hash never travels in the URL; it is only an input to the signature.
27
+ *
28
+ * WHY THE ROUTE REBUILDS THE LOGIN URL. The route never redirects to a URL it was handed. It takes `callbackUrl`
29
+ * (through safeCallbackPath), `reason` and `error` (allowlisted) and builds `${LOGIN_PAGE}?...` itself, so it cannot
30
+ * become an open redirect whatever the query says.
31
+ *
32
+ * Edge-safe: Web Crypto only (middleware runs on the edge runtime on 15.5.x).
33
+ */
34
+ import { safeCallbackPath } from './safe-callback';
35
+
36
+ export const LOGIN_PAGE = '/account-auth/login';
37
+
38
+ /** The mount point consumers use: `app/api/auth/session-reset/route.ts` (under /api/auth/, which middleware bypasses). */
39
+ export const DEFAULT_SESSION_RESET_ROUTE = '/api/auth/session-reset';
40
+
41
+ /** How long a signed reset link stays valid. A real navigation follows the 307 at once; this only bounds replay. */
42
+ export const RESET_LINK_TTL_SECONDS = 300;
43
+
44
+ /** The purpose label for the derived signing key. Changing it invalidates every outstanding reset link. */
45
+ export const RESET_KEY_PURPOSE = 'next-mvp/session-reset/v1';
46
+
47
+ /** The only `reason` / `error` values the middleware emits on a clearing redirect. Anything else is dropped. */
48
+ export const RESET_REASONS: readonly string[] = ['stale', 'invalidated'];
49
+ export const RESET_ERRORS: readonly string[] = ['SessionExpired', 'TwoFactorTimeout'];
50
+
51
+ export interface ResetParams {
52
+ callbackUrl: string;
53
+ reason?: string;
54
+ error?: string;
55
+ }
56
+
57
+ const encoder = new TextEncoder();
58
+ const FIELD_SEPARATOR = String.fromCharCode(10);
59
+
60
+ function b64url(bytes: Uint8Array): string {
61
+ return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
62
+ }
63
+
64
+ async function hmacBytes(key: Uint8Array, message: string): Promise<Uint8Array> {
65
+ const k = await crypto.subtle.importKey('raw', key as BufferSource, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
66
+ return new Uint8Array(await crypto.subtle.sign('HMAC', k, encoder.encode(message)));
67
+ }
68
+
69
+ /** Sign with the DERIVED key: HMAC(HMAC(authSecret, purpose), message). Never with the raw auth secret. */
70
+ async function sign(secret: string, message: string): Promise<string> {
71
+ const derived = await hmacBytes(encoder.encode(secret), RESET_KEY_PURPOSE);
72
+ return b64url(await hmacBytes(derived, message));
73
+ }
74
+
75
+ /** SHA-256 of the session cookie value, b64url. No session cookie binds to the empty string. */
76
+ async function sessionBinding(sessionCookieValue: string | undefined): Promise<string> {
77
+ if (!sessionCookieValue) return '';
78
+ return b64url(new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(sessionCookieValue))));
79
+ }
80
+
81
+ /** Constant-time over equal lengths, and unequal lengths never match. */
82
+ function sameString(a: string, b: string): boolean {
83
+ if (a.length !== b.length) return false;
84
+ let diff = 0;
85
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
86
+ return diff === 0;
87
+ }
88
+
89
+ /** The secret the app already signs its session cookie with. Undefined means "cannot sign": callers fall back. */
90
+ export function resetSecret(): string | undefined {
91
+ return process.env.BETTER_AUTH_SECRET || process.env.NEXTAUTH_SECRET || undefined;
92
+ }
93
+
94
+ /** The session cookie a reset is bound to: the __Secure- one when present, else the plain one. */
95
+ export function boundSessionCookie(get: (name: string) => string | undefined, plainName: string, secureName: string): string | undefined {
96
+ return get(secureName) || get(plainName) || undefined;
97
+ }
98
+
99
+ /** Normalise to exactly what the route will honour, so what is signed is what is verified. */
100
+ export function normaliseResetParams(raw: { callbackUrl?: string | null; reason?: string | null; error?: string | null }): ResetParams {
101
+ const out: ResetParams = { callbackUrl: safeCallbackPath(raw.callbackUrl ?? '') };
102
+ if (raw.reason && RESET_REASONS.includes(raw.reason)) out.reason = raw.reason;
103
+ if (raw.error && RESET_ERRORS.includes(raw.error)) out.error = raw.error;
104
+ return out;
105
+ }
106
+
107
+ /** What the signature covers. Exported for the key-separation row, which signs it with the RAW secret. */
108
+ export function resetPayload(p: ResetParams, exp: number, binding: string): string {
109
+ return ['v2', p.callbackUrl, p.reason ?? '', p.error ?? '', String(exp), binding].join(FIELD_SEPARATOR);
110
+ }
111
+
112
+ /** The login URL for these params, in the exact shape the middleware has always emitted. */
113
+ export function loginLocation(p: ResetParams): string {
114
+ let loc = `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(p.callbackUrl)}`;
115
+ if (p.reason) loc += `&reason=${p.reason}`;
116
+ if (p.error) loc += `&error=${p.error}`;
117
+ return loc;
118
+ }
119
+
120
+ /**
121
+ * Path + query for the reset route, bound to `sessionCookieValue` (the session cookie on the request being ended).
122
+ * `nowMs` is injectable for tests.
123
+ */
124
+ export async function signedResetHref(
125
+ route: string,
126
+ p: ResetParams,
127
+ secret: string,
128
+ sessionCookieValue: string | undefined,
129
+ nowMs: number = Date.now()
130
+ ): Promise<string> {
131
+ const exp = Math.floor(nowMs / 1000) + RESET_LINK_TTL_SECONDS;
132
+ const q = new URLSearchParams({ callbackUrl: p.callbackUrl });
133
+ if (p.reason) q.set('reason', p.reason);
134
+ if (p.error) q.set('error', p.error);
135
+ q.set('exp', String(exp));
136
+ q.set('sig', await sign(secret, resetPayload(p, exp, await sessionBinding(sessionCookieValue))));
137
+ return `${route}?${q.toString()}`;
138
+ }
139
+
140
+ /**
141
+ * Read a reset request's query. `signed` is true only for a valid, unexpired signature over the normalised params AND
142
+ * this request's own session cookie (`sessionCookieValue`). With no session cookie there is nothing to clear: false.
143
+ * The params are returned either way: an unsigned request still gets a sensible login redirect, just no clearing.
144
+ */
145
+ export async function readResetRequest(
146
+ search: URLSearchParams,
147
+ secret: string | undefined,
148
+ sessionCookieValue: string | undefined,
149
+ nowMs: number = Date.now()
150
+ ): Promise<{ params: ResetParams; signed: boolean }> {
151
+ const params = normaliseResetParams({ callbackUrl: search.get('callbackUrl'), reason: search.get('reason'), error: search.get('error') });
152
+ const sig = search.get('sig');
153
+ const exp = Number(search.get('exp'));
154
+ if (!secret || !sig || !sessionCookieValue || !Number.isInteger(exp) || exp < Math.floor(nowMs / 1000)) return { params, signed: false };
155
+ // The signature covers the NORMALISED params and THIS request's session, so a tampered value, or the token arriving
156
+ // with a different session cookie (another user, or the same user after signing in again), fails here.
157
+ const want = await sign(secret, resetPayload(params, exp, await sessionBinding(sessionCookieValue)));
158
+ return { params, signed: sameString(sig, want) };
159
+ }
@@ -24,6 +24,14 @@ import { getInternalApiUrl } from '../edge/internal-api-url';
24
24
  import { getSessionCookieName, getSecureSessionCookieName } from '../lib/app-slug';
25
25
  import { checkPagePermission, isRBACEnabled } from './rbac-check';
26
26
  import { tokenLogId } from '../lib/token-log-id';
27
+ import { expireSessionCookies } from '../auth/session-cookie-clear';
28
+ import {
29
+ DEFAULT_SESSION_RESET_ROUTE,
30
+ boundSessionCookie,
31
+ normaliseResetParams,
32
+ resetSecret,
33
+ signedResetHref,
34
+ } from '../auth/session-reset-link';
27
35
 
28
36
  // =============================================================================
29
37
  // TYPES
@@ -113,6 +121,19 @@ export interface MvpMiddlewareOptions {
113
121
  bypassPaths?: string[];
114
122
  /** Paths exempt from RBAC checks (auth still enforced, just no page-permission check) */
115
123
  rbacExemptPaths?: string[];
124
+ /**
125
+ * PAY-2218 - send a page's session-ending redirect through the session-reset ROUTE instead of clearing the
126
+ * cookies in the middleware response. Next strips the router's prefetch headers before middleware runs
127
+ * (15.5.x and 16.x; 16.2.x restores them only with `skipProxyUrlNormalize: true`), so a middleware clear also
128
+ * fires on a router PREFETCH and signs the user out of a page they never opened. The route sees those headers.
129
+ *
130
+ * Requires the app to mount the route: app/api/auth/session-reset/route.ts
131
+ * export { GET } from '@payez/next-mvp/routes/auth/session-reset';
132
+ * `true` uses that default path; a string names another mount point under /api/auth/ (middleware bypasses it).
133
+ * Needs BETTER_AUTH_SECRET (or NEXTAUTH_SECRET) to sign the hand-off; without one the direct clear is kept and
134
+ * an error is logged once. Default: off (the direct clear), because an unmounted route would 404 every sign-out.
135
+ */
136
+ sessionResetRoute?: boolean | string;
116
137
  /**
117
138
  * PAY-1882 step 4 - how this app treats a producer that reports the session's
118
139
  * revocation status as `'unknown'` (a Vibe read that FAILED: non-OK, timeout,
@@ -221,26 +242,7 @@ export function isPrefetchRequest(request: NextRequest): boolean {
221
242
  */
222
243
  function clearSessionCookies(response: NextResponse, request: NextRequest): void {
223
244
  if (isPrefetchRequest(request)) return;
224
- const sessionCookie = getSessionCookieName();
225
- const secureCookie = getSecureSessionCookieName();
226
-
227
- // Clear main cookies
228
- response.cookies.set(sessionCookie, '', { path: '/', expires: new Date(0), httpOnly: true, sameSite: 'lax' });
229
- response.cookies.set(secureCookie, '', { path: '/', expires: new Date(0), httpOnly: true, sameSite: 'lax', secure: true });
230
-
231
- // Clear any chunked cookies (NextAuth chunks large cookies)
232
- for (const [name] of request.cookies) {
233
- if (name.startsWith(`${sessionCookie}.`) || name.startsWith(`${secureCookie}.`)) {
234
- const isSecure = name.startsWith('__Secure-');
235
- response.cookies.set(name, '', {
236
- path: '/',
237
- expires: new Date(0),
238
- httpOnly: true,
239
- sameSite: 'lax',
240
- ...(isSecure && { secure: true })
241
- });
242
- }
243
- }
245
+ expireSessionCookies(response, Array.from(request.cookies, ([name]) => name));
244
246
  }
245
247
 
246
248
  /** Create redirect response, optionally clearing cookies */
@@ -256,6 +258,35 @@ function redirectTo(
256
258
  return response;
257
259
  }
258
260
 
261
+ /** PAY-2218: where a page's clearing redirect goes when the app has mounted the session-reset route. */
262
+ interface ResetConfig {
263
+ route: string;
264
+ secret: string;
265
+ }
266
+
267
+ /**
268
+ * A redirect that must also END the session. With the reset route configured, a page request is sent through it
269
+ * WITHOUT any Set-Cookie here: the route sees the flight headers this middleware cannot (Next strips them before
270
+ * middleware), answers a prefetch 204 + no-store, and clears on the real navigation. `/api/` requests and apps
271
+ * without the route keep the direct clear exactly as before.
272
+ */
273
+ async function clearingRedirect(request: NextRequest, location: string, reset?: ResetConfig): Promise<NextResponse> {
274
+ if (!reset || request.nextUrl.pathname.startsWith('/api/')) {
275
+ return redirectTo(request, location, true);
276
+ }
277
+ const target = new URL(location, request.url);
278
+ const params = normaliseResetParams({
279
+ callbackUrl: target.searchParams.get('callbackUrl'),
280
+ reason: target.searchParams.get('reason'),
281
+ error: target.searchParams.get('error'),
282
+ });
283
+ // Bound to THIS request's session cookie: the route clears only when its own request carries the same one.
284
+ const bound = boundSessionCookie((n) => request.cookies.get(n)?.value, getSessionCookieName(), getSecureSessionCookieName());
285
+ const response = NextResponse.redirect(new URL(await signedResetHref(reset.route, params, reset.secret, bound), request.url));
286
+ response.headers.set('cache-control', 'no-store');
287
+ return response;
288
+ }
289
+
259
290
  /**
260
291
  * Terminal response for a FAILED token refresh.
261
292
  *
@@ -274,12 +305,13 @@ function redirectTo(
274
305
  * higher would intercept 'refresh_needed' and disable middleware token refresh across
275
306
  * the whole API surface (card 340024 C1).
276
307
  */
277
- function refreshFailureResponse(
308
+ async function refreshFailureResponse(
278
309
  request: NextRequest,
279
310
  location: string,
280
311
  errorCode: string,
281
- clearCookies = false
282
- ): NextResponse {
312
+ clearCookies = false,
313
+ reset?: ResetConfig
314
+ ): Promise<NextResponse> {
283
315
  if (request.nextUrl.pathname.startsWith('/api/')) {
284
316
  const response = NextResponse.json({ error: errorCode }, { status: 401 });
285
317
  if (clearCookies) {
@@ -287,7 +319,7 @@ function refreshFailureResponse(
287
319
  }
288
320
  return response;
289
321
  }
290
- return redirectTo(request, location, clearCookies);
322
+ return clearCookies ? clearingRedirect(request, location, reset) : redirectTo(request, location);
291
323
  }
292
324
 
293
325
  /**
@@ -497,6 +529,9 @@ function redirect2FA(callbackUrl: string): AuthAction {
497
529
  // MIDDLEWARE FACTORY
498
530
  // =============================================================================
499
531
 
532
+ /** PAY-2218: the stripped-flight-headers warning is logged once per process, not once per request. */
533
+ let flightHeadersStrippedWarned = false;
534
+
500
535
  export function createMvpMiddleware(options: MvpMiddlewareOptions = {}) {
501
536
  // Resolve options with defaults
502
537
  const cb = options.circuitBreaker || defaultCircuitBreaker;
@@ -507,6 +542,21 @@ export function createMvpMiddleware(options: MvpMiddlewareOptions = {}) {
507
542
  const rbacExemptPaths = options.rbacExemptPaths || [];
508
543
  // PAY-1882 step 4: default 'closed' (Jon's option A / BAPert 66316).
509
544
  const revocationFailMode = options.revocationFailMode || 'closed';
545
+ const sessionResetRoute =
546
+ options.sessionResetRoute === true ? DEFAULT_SESSION_RESET_ROUTE
547
+ : typeof options.sessionResetRoute === 'string' && options.sessionResetRoute ? options.sessionResetRoute
548
+ : undefined;
549
+ let resetSecretMissingLogged = false;
550
+ const resolveReset = (): ResetConfig | undefined => {
551
+ if (!sessionResetRoute) return undefined;
552
+ const secret = resetSecret();
553
+ if (secret) return { route: sessionResetRoute, secret };
554
+ if (!resetSecretMissingLogged) {
555
+ resetSecretMissingLogged = true;
556
+ log.error('sessionResetRoute is set but no BETTER_AUTH_SECRET/NEXTAUTH_SECRET is available to sign the hand-off; clearing in middleware instead', { sessionResetRoute });
557
+ }
558
+ return undefined;
559
+ };
510
560
 
511
561
  return async function middleware(request: NextRequest): Promise<NextResponse> {
512
562
  const { pathname, searchParams } = request.nextUrl;
@@ -529,6 +579,13 @@ export function createMvpMiddleware(options: MvpMiddlewareOptions = {}) {
529
579
  }
530
580
  }
531
581
 
582
+ // PAY-2218: a router request (it carries `next-url`) that arrives WITHOUT `rsc` had its flight headers stripped
583
+ // by Next before middleware ran, so isPrefetchRequest() cannot see a router prefetch on this app. Say so once.
584
+ if (!sessionResetRoute && !flightHeadersStrippedWarned && request.headers.has('next-url') && !request.headers.has('rsc')) {
585
+ flightHeadersStrippedWarned = true;
586
+ log.warn('Next strips router prefetch headers before middleware on this app, so a session-ending redirect can fire on a prefetch. Mount @payez/next-mvp/routes/auth/session-reset and set sessionResetRoute (PAY-2218).', { pathname });
587
+ }
588
+
532
589
  const isPublic = isUnauthenticatedRoute(pathname);
533
590
  const isLogin = isLoginPage(pathname);
534
591
 
@@ -608,6 +665,7 @@ export function createMvpMiddleware(options: MvpMiddlewareOptions = {}) {
608
665
  logger: log,
609
666
  refreshEndpoint,
610
667
  rbacExemptPaths,
668
+ reset: resolveReset(),
611
669
  onRefreshSuccess: options.onRefreshSuccess,
612
670
  onRefreshFailure: options.onRefreshFailure,
613
671
  });
@@ -778,6 +836,8 @@ interface ExecuteOptions {
778
836
  logger: MiddlewareLogger;
779
837
  refreshEndpoint: string;
780
838
  rbacExemptPaths: string[];
839
+ /** PAY-2218: set when the app mounted the session-reset route AND a signing secret exists. */
840
+ reset?: ResetConfig;
781
841
  onRefreshSuccess?: () => void;
782
842
  onRefreshFailure?: (status: number, isNetworkError: boolean) => void;
783
843
  }
@@ -798,7 +858,9 @@ async function executeDecision(
798
858
  return handleAllow(request, pathname, sessionPointer, sessionStatus, opts.rbacExemptPaths);
799
859
 
800
860
  case 'redirect':
801
- return redirectTo(request, decision.location, decision.clearCookies);
861
+ return decision.clearCookies
862
+ ? clearingRedirect(request, decision.location, opts.reset)
863
+ : redirectTo(request, decision.location);
802
864
 
803
865
  case 'service_error':
804
866
  return serviceUnavailableResponse(request);
@@ -943,7 +1005,8 @@ async function handleRefresh(
943
1005
  request,
944
1006
  `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&error=${errorParam}`,
945
1007
  errorParam,
946
- isPermanentFailure
1008
+ isPermanentFailure,
1009
+ opts.reset
947
1010
  );
948
1011
  } catch (error: any) {
949
1012
  const isNetwork = cb.isNetworkError(error);