@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.
@@ -2,7 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
2
2
  export declare function GET(req: NextRequest): Promise<NextResponse<{
3
3
  canRefresh: boolean;
4
4
  reason: string;
5
- sessionTokenId: null;
5
+ error: string;
6
+ code: string;
6
7
  }> | NextResponse<{
7
8
  canRefresh: boolean;
8
9
  reason: string;
@@ -10,7 +11,5 @@ export declare function GET(req: NextRequest): Promise<NextResponse<{
10
11
  }> | NextResponse<{
11
12
  canRefresh: boolean;
12
13
  reason: string;
13
- error: string;
14
- code: string;
15
- correlationId: string;
14
+ sessionTokenId: null;
16
15
  }>>;
@@ -7,11 +7,29 @@ const refresh_token_validator_1 = require("../../lib/refresh-token-validator");
7
7
  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
+ const auth_1 = require("../../server/auth");
10
11
  async function GET(req) {
11
12
  try {
12
13
  const sessionToken = req.nextUrl.searchParams.get('token');
13
14
  let finalSessionToken = sessionToken;
14
- if (!finalSessionToken) {
15
+ if (!finalSessionToken && process.env.TEST_MODE !== 'true') {
16
+ // PAY-2216: outside TEST_MODE getTokenTestAware is getSession(), which answered null for a store outage AND
17
+ // for a deleted session, so this route said not_logged_in to both and its own session_not_found arm (below)
18
+ // was unreachable from a cookie. Ask for the state instead. Same rules as routes/auth/viability.ts:
19
+ // 'unknown' never reads as logged out or stale; 'missing' requires a verified cookie.
20
+ const sessionState = await (0, auth_1.resolveSessionState)(req);
21
+ if (sessionState.state === 'unknown') {
22
+ logger_1.logger.warn('[REFRESH-VIABILITY] Session store unavailable - session state unknown');
23
+ return server_1.NextResponse.json({ canRefresh: false, reason: 'store_unavailable', error: 'Session store unavailable', code: 'STORE_UNAVAILABLE' }, { status: 500 });
24
+ }
25
+ if (sessionState.state === 'missing') {
26
+ return server_1.NextResponse.json({ canRefresh: false, reason: 'session_not_found', sessionTokenId: (0, token_log_id_1.tokenLogId)(sessionState.sessionToken) }, { status: 200 });
27
+ }
28
+ if (sessionState.state === 'live') {
29
+ finalSessionToken = sessionState.session?.session?.token ?? null;
30
+ }
31
+ }
32
+ else if (!finalSessionToken) {
15
33
  const token = await (0, test_aware_get_token_1.getTokenTestAware)(req);
16
34
  // Support both field names: sessionToken (auth.ts JWT) and redisSessionId (legacy)
17
35
  finalSessionToken = (token?.sessionToken || token?.redisSessionId);
@@ -39,8 +39,22 @@ async function GET(req) {
39
39
  code: 'AUTH_NOT_INITIALIZED'
40
40
  }, { status: 503 });
41
41
  }
42
- // Get session from Better Auth
43
- const betterAuthSession = await (0, auth_1.getSession)(req);
42
+ // PAY-2216: the TWIN of routes/auth/viability.ts - same tri-state, same answers, or an app mounting this export
43
+ // keeps the defect. 'unknown' (store did not answer) is a 5xx and never stale; 'missing' (verified cookie, store
44
+ // answered "not here") is the stale shape with a token id; 'none' stays { authenticated: false }.
45
+ const sessionState = await (0, auth_1.resolveSessionState)(req);
46
+ if (sessionState.state === 'unknown') {
47
+ 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
+ }
50
+ if (sessionState.state === 'missing') {
51
+ console.warn('[VIABILITY] Stale cookie detected - verified cookie, session not in Redis');
52
+ return server_1.NextResponse.json({
53
+ authenticated: false,
54
+ sessionTokenId: (0, token_log_id_1.tokenLogId)(sessionState.sessionToken) // PAY-1481: correlation id, never the token
55
+ });
56
+ }
57
+ const betterAuthSession = sessionState.state === 'live' ? sessionState.session : null;
44
58
  // Debug logging
45
59
  if (!betterAuthSession) {
46
60
  console.warn('[VIABILITY] getSession returned null');
@@ -97,6 +97,11 @@ function safeCallbackPath(raw, fallback = exports.DEFAULT_CALLBACK) {
97
97
  // Loopbacks into the auth surface cause redirect cycles.
98
98
  if (url.pathname.startsWith(AUTH_PREFIX))
99
99
  return fallback;
100
+ // PAY-2218: `_rsc` is Next's flight cache-buster. Once skipProxyUrlNormalize lets it reach middleware it leaks
101
+ // into return targets (`callbackUrl=/overview?_rsc=abc12`, NightHawk 72013); it is never a meaningful part of a
102
+ // destination. Only touched when present, so every other query keeps its exact spelling.
103
+ if (url.searchParams.has('_rsc'))
104
+ url.searchParams.delete('_rsc');
100
105
  // The parser's normalised output - control characters already stripped.
101
106
  const candidate = url.pathname + url.search + url.hash;
102
107
  // *** SECOND RESOLUTION. DO NOT DELETE THIS AS REDUNDANT - IT IS NOT. ***
@@ -0,0 +1,7 @@
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
+ export declare function expireSessionCookies(response: NextResponse, requestCookieNames: Iterable<string>): void;
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.expireSessionCookies = expireSessionCookies;
4
+ const app_slug_1 = require("../lib/app-slug");
5
+ function expireSessionCookies(response, requestCookieNames) {
6
+ const sessionCookie = (0, app_slug_1.getSessionCookieName)();
7
+ const secureCookie = (0, app_slug_1.getSecureSessionCookieName)();
8
+ // Clear main cookies
9
+ response.cookies.set(sessionCookie, '', { path: '/', expires: new Date(0), httpOnly: true, sameSite: 'lax' });
10
+ response.cookies.set(secureCookie, '', { path: '/', expires: new Date(0), httpOnly: true, sameSite: 'lax', secure: true });
11
+ // Clear any chunked cookies (NextAuth chunks large cookies)
12
+ for (const name of requestCookieNames) {
13
+ if (name.startsWith(`${sessionCookie}.`) || name.startsWith(`${secureCookie}.`)) {
14
+ const isSecure = name.startsWith('__Secure-');
15
+ response.cookies.set(name, '', {
16
+ path: '/',
17
+ expires: new Date(0),
18
+ httpOnly: true,
19
+ sameSite: 'lax',
20
+ ...(isSecure && { secure: true })
21
+ });
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,43 @@
1
+ export declare const LOGIN_PAGE = "/account-auth/login";
2
+ /** The mount point consumers use: `app/api/auth/session-reset/route.ts` (under /api/auth/, which middleware bypasses). */
3
+ export declare const DEFAULT_SESSION_RESET_ROUTE = "/api/auth/session-reset";
4
+ /** How long a signed reset link stays valid. A real navigation follows the 307 at once; this only bounds replay. */
5
+ export declare const RESET_LINK_TTL_SECONDS = 300;
6
+ /** The purpose label for the derived signing key. Changing it invalidates every outstanding reset link. */
7
+ export declare const RESET_KEY_PURPOSE = "next-mvp/session-reset/v1";
8
+ /** The only `reason` / `error` values the middleware emits on a clearing redirect. Anything else is dropped. */
9
+ export declare const RESET_REASONS: readonly string[];
10
+ export declare const RESET_ERRORS: readonly string[];
11
+ export interface ResetParams {
12
+ callbackUrl: string;
13
+ reason?: string;
14
+ error?: string;
15
+ }
16
+ /** The secret the app already signs its session cookie with. Undefined means "cannot sign": callers fall back. */
17
+ export declare function resetSecret(): string | undefined;
18
+ /** The session cookie a reset is bound to: the __Secure- one when present, else the plain one. */
19
+ export declare function boundSessionCookie(get: (name: string) => string | undefined, plainName: string, secureName: string): string | undefined;
20
+ /** Normalise to exactly what the route will honour, so what is signed is what is verified. */
21
+ export declare function normaliseResetParams(raw: {
22
+ callbackUrl?: string | null;
23
+ reason?: string | null;
24
+ error?: string | null;
25
+ }): ResetParams;
26
+ /** What the signature covers. Exported for the key-separation row, which signs it with the RAW secret. */
27
+ export declare function resetPayload(p: ResetParams, exp: number, binding: string): string;
28
+ /** The login URL for these params, in the exact shape the middleware has always emitted. */
29
+ export declare function loginLocation(p: ResetParams): string;
30
+ /**
31
+ * Path + query for the reset route, bound to `sessionCookieValue` (the session cookie on the request being ended).
32
+ * `nowMs` is injectable for tests.
33
+ */
34
+ export declare function signedResetHref(route: string, p: ResetParams, secret: string, sessionCookieValue: string | undefined, nowMs?: number): Promise<string>;
35
+ /**
36
+ * Read a reset request's query. `signed` is true only for a valid, unexpired signature over the normalised params AND
37
+ * this request's own session cookie (`sessionCookieValue`). With no session cookie there is nothing to clear: false.
38
+ * The params are returned either way: an unsigned request still gets a sensible login redirect, just no clearing.
39
+ */
40
+ export declare function readResetRequest(search: URLSearchParams, secret: string | undefined, sessionCookieValue: string | undefined, nowMs?: number): Promise<{
41
+ params: ResetParams;
42
+ signed: boolean;
43
+ }>;
@@ -0,0 +1,144 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RESET_ERRORS = exports.RESET_REASONS = exports.RESET_KEY_PURPOSE = exports.RESET_LINK_TTL_SECONDS = exports.DEFAULT_SESSION_RESET_ROUTE = exports.LOGIN_PAGE = void 0;
4
+ exports.resetSecret = resetSecret;
5
+ exports.boundSessionCookie = boundSessionCookie;
6
+ exports.normaliseResetParams = normaliseResetParams;
7
+ exports.resetPayload = resetPayload;
8
+ exports.loginLocation = loginLocation;
9
+ exports.signedResetHref = signedResetHref;
10
+ exports.readResetRequest = readResetRequest;
11
+ /**
12
+ * PAY-2218 PR 2: the hand-off between the middleware and the session-reset route.
13
+ *
14
+ * WHY A ROUTE AT ALL. Next 15.5.x and 16.x (16.2.x without `skipProxyUrlNormalize`) strip every router flight
15
+ * header (`rsc`, `next-router-prefetch`, `next-router-segment-prefetch`, `next-router-state-tree`) from the request
16
+ * handed to middleware, so middleware cannot tell a router prefetch from a real navigation and its prefetch guard is
17
+ * 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
18
+ * `next start` servers, PAY-2218 comments 2026-10-11). So the middleware no longer writes the clearing Set-Cookie
19
+ * itself: it redirects to the reset route, and the route decides.
20
+ *
21
+ * WHY THE ROUTE ANSWERS A PREFETCH WITH 204, NOT A REDIRECT. On 15.5.9 the router CACHES a prefetched redirect chain
22
+ * and the later click reuses it without calling middleware or the route again - so "decline on the prefetch, clear
23
+ * on the real navigation" never clears there. A 204 + no-store gives the router nothing to cache, so the real click
24
+ * 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).
25
+ *
26
+ * WHY SIGNED. The route clears cookies on a GET. Unsigned, any page could link a signed-in user to it and log them
27
+ * out. The middleware signs (callbackUrl, reason, error, exp, session binding); the route clears only on a valid,
28
+ * unexpired signature and otherwise just sends the user to the login page with the cookies untouched.
29
+ *
30
+ * KEY SEPARATION (BAPert 72219). The signing key is DERIVED - HMAC(authSecret, 'next-mvp/session-reset/v1') - never
31
+ * the raw auth secret, so no value the app signs with that secret for any other purpose can pass as a reset token.
32
+ *
33
+ * SESSION BINDING (BAPert 72219). The signature covers SHA-256 of the session cookie the redirect was issued FOR. The
34
+ * route recomputes it from ITS OWN request's cookie and clears only on a match, so a token captured from one user
35
+ * clears nothing when sent to another (logout-CSRF), and a second use is a no-op (the first use cleared the cookie it
36
+ * was bound to). The hash never travels in the URL; it is only an input to the signature.
37
+ *
38
+ * WHY THE ROUTE REBUILDS THE LOGIN URL. The route never redirects to a URL it was handed. It takes `callbackUrl`
39
+ * (through safeCallbackPath), `reason` and `error` (allowlisted) and builds `${LOGIN_PAGE}?...` itself, so it cannot
40
+ * become an open redirect whatever the query says.
41
+ *
42
+ * Edge-safe: Web Crypto only (middleware runs on the edge runtime on 15.5.x).
43
+ */
44
+ const safe_callback_1 = require("./safe-callback");
45
+ exports.LOGIN_PAGE = '/account-auth/login';
46
+ /** The mount point consumers use: `app/api/auth/session-reset/route.ts` (under /api/auth/, which middleware bypasses). */
47
+ exports.DEFAULT_SESSION_RESET_ROUTE = '/api/auth/session-reset';
48
+ /** How long a signed reset link stays valid. A real navigation follows the 307 at once; this only bounds replay. */
49
+ exports.RESET_LINK_TTL_SECONDS = 300;
50
+ /** The purpose label for the derived signing key. Changing it invalidates every outstanding reset link. */
51
+ exports.RESET_KEY_PURPOSE = 'next-mvp/session-reset/v1';
52
+ /** The only `reason` / `error` values the middleware emits on a clearing redirect. Anything else is dropped. */
53
+ exports.RESET_REASONS = ['stale', 'invalidated'];
54
+ exports.RESET_ERRORS = ['SessionExpired', 'TwoFactorTimeout'];
55
+ const encoder = new TextEncoder();
56
+ const FIELD_SEPARATOR = String.fromCharCode(10);
57
+ function b64url(bytes) {
58
+ return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
59
+ }
60
+ async function hmacBytes(key, message) {
61
+ const k = await crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
62
+ return new Uint8Array(await crypto.subtle.sign('HMAC', k, encoder.encode(message)));
63
+ }
64
+ /** Sign with the DERIVED key: HMAC(HMAC(authSecret, purpose), message). Never with the raw auth secret. */
65
+ async function sign(secret, message) {
66
+ const derived = await hmacBytes(encoder.encode(secret), exports.RESET_KEY_PURPOSE);
67
+ return b64url(await hmacBytes(derived, message));
68
+ }
69
+ /** SHA-256 of the session cookie value, b64url. No session cookie binds to the empty string. */
70
+ async function sessionBinding(sessionCookieValue) {
71
+ if (!sessionCookieValue)
72
+ return '';
73
+ return b64url(new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(sessionCookieValue))));
74
+ }
75
+ /** Constant-time over equal lengths, and unequal lengths never match. */
76
+ function sameString(a, b) {
77
+ if (a.length !== b.length)
78
+ return false;
79
+ let diff = 0;
80
+ for (let i = 0; i < a.length; i++)
81
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
82
+ return diff === 0;
83
+ }
84
+ /** The secret the app already signs its session cookie with. Undefined means "cannot sign": callers fall back. */
85
+ function resetSecret() {
86
+ return process.env.BETTER_AUTH_SECRET || process.env.NEXTAUTH_SECRET || undefined;
87
+ }
88
+ /** The session cookie a reset is bound to: the __Secure- one when present, else the plain one. */
89
+ function boundSessionCookie(get, plainName, secureName) {
90
+ return get(secureName) || get(plainName) || undefined;
91
+ }
92
+ /** Normalise to exactly what the route will honour, so what is signed is what is verified. */
93
+ function normaliseResetParams(raw) {
94
+ const out = { callbackUrl: (0, safe_callback_1.safeCallbackPath)(raw.callbackUrl ?? '') };
95
+ if (raw.reason && exports.RESET_REASONS.includes(raw.reason))
96
+ out.reason = raw.reason;
97
+ if (raw.error && exports.RESET_ERRORS.includes(raw.error))
98
+ out.error = raw.error;
99
+ return out;
100
+ }
101
+ /** What the signature covers. Exported for the key-separation row, which signs it with the RAW secret. */
102
+ function resetPayload(p, exp, binding) {
103
+ return ['v2', p.callbackUrl, p.reason ?? '', p.error ?? '', String(exp), binding].join(FIELD_SEPARATOR);
104
+ }
105
+ /** The login URL for these params, in the exact shape the middleware has always emitted. */
106
+ function loginLocation(p) {
107
+ let loc = `${exports.LOGIN_PAGE}?callbackUrl=${encodeURIComponent(p.callbackUrl)}`;
108
+ if (p.reason)
109
+ loc += `&reason=${p.reason}`;
110
+ if (p.error)
111
+ loc += `&error=${p.error}`;
112
+ return loc;
113
+ }
114
+ /**
115
+ * Path + query for the reset route, bound to `sessionCookieValue` (the session cookie on the request being ended).
116
+ * `nowMs` is injectable for tests.
117
+ */
118
+ async function signedResetHref(route, p, secret, sessionCookieValue, nowMs = Date.now()) {
119
+ const exp = Math.floor(nowMs / 1000) + exports.RESET_LINK_TTL_SECONDS;
120
+ const q = new URLSearchParams({ callbackUrl: p.callbackUrl });
121
+ if (p.reason)
122
+ q.set('reason', p.reason);
123
+ if (p.error)
124
+ q.set('error', p.error);
125
+ q.set('exp', String(exp));
126
+ q.set('sig', await sign(secret, resetPayload(p, exp, await sessionBinding(sessionCookieValue))));
127
+ return `${route}?${q.toString()}`;
128
+ }
129
+ /**
130
+ * Read a reset request's query. `signed` is true only for a valid, unexpired signature over the normalised params AND
131
+ * this request's own session cookie (`sessionCookieValue`). With no session cookie there is nothing to clear: false.
132
+ * The params are returned either way: an unsigned request still gets a sensible login redirect, just no clearing.
133
+ */
134
+ async function readResetRequest(search, secret, sessionCookieValue, nowMs = Date.now()) {
135
+ const params = normaliseResetParams({ callbackUrl: search.get('callbackUrl'), reason: search.get('reason'), error: search.get('error') });
136
+ const sig = search.get('sig');
137
+ const exp = Number(search.get('exp'));
138
+ if (!secret || !sig || !sessionCookieValue || !Number.isInteger(exp) || exp < Math.floor(nowMs / 1000))
139
+ return { params, signed: false };
140
+ // The signature covers the NORMALISED params and THIS request's session, so a tampered value, or the token arriving
141
+ // with a different session cookie (another user, or the same user after signing in again), fails here.
142
+ const want = await sign(secret, resetPayload(params, exp, await sessionBinding(sessionCookieValue)));
143
+ return { params, signed: sameString(sig, want) };
144
+ }
@@ -99,6 +99,19 @@ export interface MvpMiddlewareOptions {
99
99
  bypassPaths?: string[];
100
100
  /** Paths exempt from RBAC checks (auth still enforced, just no page-permission check) */
101
101
  rbacExemptPaths?: string[];
102
+ /**
103
+ * PAY-2218 - send a page's session-ending redirect through the session-reset ROUTE instead of clearing the
104
+ * cookies in the middleware response. Next strips the router's prefetch headers before middleware runs
105
+ * (15.5.x and 16.x; 16.2.x restores them only with `skipProxyUrlNormalize: true`), so a middleware clear also
106
+ * fires on a router PREFETCH and signs the user out of a page they never opened. The route sees those headers.
107
+ *
108
+ * Requires the app to mount the route: app/api/auth/session-reset/route.ts
109
+ * export { GET } from '@payez/next-mvp/routes/auth/session-reset';
110
+ * `true` uses that default path; a string names another mount point under /api/auth/ (middleware bypasses it).
111
+ * Needs BETTER_AUTH_SECRET (or NEXTAUTH_SECRET) to sign the hand-off; without one the direct clear is kept and
112
+ * an error is logged once. Default: off (the direct clear), because an unmounted route would 404 every sign-out.
113
+ */
114
+ sessionResetRoute?: boolean | string;
102
115
  /**
103
116
  * PAY-1882 step 4 - how this app treats a producer that reports the session's
104
117
  * revocation status as `'unknown'` (a Vibe read that FAILED: non-OK, timeout,
@@ -28,6 +28,8 @@ const safe_callback_1 = require("../auth/safe-callback");
28
28
  const internal_api_url_1 = require("../edge/internal-api-url");
29
29
  const app_slug_1 = require("../lib/app-slug");
30
30
  const rbac_check_1 = require("./rbac-check");
31
+ const session_cookie_clear_1 = require("../auth/session-cookie-clear");
32
+ const session_reset_link_1 = require("../auth/session-reset-link");
31
33
  // =============================================================================
32
34
  // DEFAULT IMPLEMENTATIONS
33
35
  // =============================================================================
@@ -105,24 +107,7 @@ function isPrefetchRequest(request) {
105
107
  function clearSessionCookies(response, request) {
106
108
  if (isPrefetchRequest(request))
107
109
  return;
108
- const sessionCookie = (0, app_slug_1.getSessionCookieName)();
109
- const secureCookie = (0, app_slug_1.getSecureSessionCookieName)();
110
- // Clear main cookies
111
- response.cookies.set(sessionCookie, '', { path: '/', expires: new Date(0), httpOnly: true, sameSite: 'lax' });
112
- response.cookies.set(secureCookie, '', { path: '/', expires: new Date(0), httpOnly: true, sameSite: 'lax', secure: true });
113
- // Clear any chunked cookies (NextAuth chunks large cookies)
114
- for (const [name] of request.cookies) {
115
- if (name.startsWith(`${sessionCookie}.`) || name.startsWith(`${secureCookie}.`)) {
116
- const isSecure = name.startsWith('__Secure-');
117
- response.cookies.set(name, '', {
118
- path: '/',
119
- expires: new Date(0),
120
- httpOnly: true,
121
- sameSite: 'lax',
122
- ...(isSecure && { secure: true })
123
- });
124
- }
125
- }
110
+ (0, session_cookie_clear_1.expireSessionCookies)(response, Array.from(request.cookies, ([name]) => name));
126
111
  }
127
112
  /** Create redirect response, optionally clearing cookies */
128
113
  function redirectTo(request, location, clearCookies = false) {
@@ -132,6 +117,28 @@ function redirectTo(request, location, clearCookies = false) {
132
117
  }
133
118
  return response;
134
119
  }
120
+ /**
121
+ * A redirect that must also END the session. With the reset route configured, a page request is sent through it
122
+ * WITHOUT any Set-Cookie here: the route sees the flight headers this middleware cannot (Next strips them before
123
+ * middleware), answers a prefetch 204 + no-store, and clears on the real navigation. `/api/` requests and apps
124
+ * without the route keep the direct clear exactly as before.
125
+ */
126
+ async function clearingRedirect(request, location, reset) {
127
+ if (!reset || request.nextUrl.pathname.startsWith('/api/')) {
128
+ return redirectTo(request, location, true);
129
+ }
130
+ const target = new URL(location, request.url);
131
+ const params = (0, session_reset_link_1.normaliseResetParams)({
132
+ callbackUrl: target.searchParams.get('callbackUrl'),
133
+ reason: target.searchParams.get('reason'),
134
+ error: target.searchParams.get('error'),
135
+ });
136
+ // Bound to THIS request's session cookie: the route clears only when its own request carries the same one.
137
+ const bound = (0, session_reset_link_1.boundSessionCookie)((n) => request.cookies.get(n)?.value, (0, app_slug_1.getSessionCookieName)(), (0, app_slug_1.getSecureSessionCookieName)());
138
+ const response = server_1.NextResponse.redirect(new URL(await (0, session_reset_link_1.signedResetHref)(reset.route, params, reset.secret, bound), request.url));
139
+ response.headers.set('cache-control', 'no-store');
140
+ return response;
141
+ }
135
142
  /**
136
143
  * Terminal response for a FAILED token refresh.
137
144
  *
@@ -150,7 +157,7 @@ function redirectTo(request, location, clearCookies = false) {
150
157
  * higher would intercept 'refresh_needed' and disable middleware token refresh across
151
158
  * the whole API surface (card 340024 C1).
152
159
  */
153
- function refreshFailureResponse(request, location, errorCode, clearCookies = false) {
160
+ async function refreshFailureResponse(request, location, errorCode, clearCookies = false, reset) {
154
161
  if (request.nextUrl.pathname.startsWith('/api/')) {
155
162
  const response = server_1.NextResponse.json({ error: errorCode }, { status: 401 });
156
163
  if (clearCookies) {
@@ -158,7 +165,7 @@ function refreshFailureResponse(request, location, errorCode, clearCookies = fal
158
165
  }
159
166
  return response;
160
167
  }
161
- return redirectTo(request, location, clearCookies);
168
+ return clearCookies ? clearingRedirect(request, location, reset) : redirectTo(request, location);
162
169
  }
163
170
  /**
164
171
  * PAY-2147: the terminal answer when the middleware CANNOT TELL whether the session is good
@@ -359,6 +366,8 @@ function redirect2FA(callbackUrl) {
359
366
  // =============================================================================
360
367
  // MIDDLEWARE FACTORY
361
368
  // =============================================================================
369
+ /** PAY-2218: the stripped-flight-headers warning is logged once per process, not once per request. */
370
+ let flightHeadersStrippedWarned = false;
362
371
  function createMvpMiddleware(options = {}) {
363
372
  // Resolve options with defaults
364
373
  const cb = options.circuitBreaker || defaultCircuitBreaker;
@@ -369,6 +378,22 @@ function createMvpMiddleware(options = {}) {
369
378
  const rbacExemptPaths = options.rbacExemptPaths || [];
370
379
  // PAY-1882 step 4: default 'closed' (Jon's option A / BAPert 66316).
371
380
  const revocationFailMode = options.revocationFailMode || 'closed';
381
+ const sessionResetRoute = options.sessionResetRoute === true ? session_reset_link_1.DEFAULT_SESSION_RESET_ROUTE
382
+ : typeof options.sessionResetRoute === 'string' && options.sessionResetRoute ? options.sessionResetRoute
383
+ : undefined;
384
+ let resetSecretMissingLogged = false;
385
+ const resolveReset = () => {
386
+ if (!sessionResetRoute)
387
+ return undefined;
388
+ const secret = (0, session_reset_link_1.resetSecret)();
389
+ if (secret)
390
+ return { route: sessionResetRoute, secret };
391
+ if (!resetSecretMissingLogged) {
392
+ resetSecretMissingLogged = true;
393
+ log.error('sessionResetRoute is set but no BETTER_AUTH_SECRET/NEXTAUTH_SECRET is available to sign the hand-off; clearing in middleware instead', { sessionResetRoute });
394
+ }
395
+ return undefined;
396
+ };
372
397
  return async function middleware(request) {
373
398
  const { pathname, searchParams } = request.nextUrl;
374
399
  // =========================================================================
@@ -387,6 +412,12 @@ function createMvpMiddleware(options = {}) {
387
412
  return server_1.NextResponse.next();
388
413
  }
389
414
  }
415
+ // PAY-2218: a router request (it carries `next-url`) that arrives WITHOUT `rsc` had its flight headers stripped
416
+ // by Next before middleware ran, so isPrefetchRequest() cannot see a router prefetch on this app. Say so once.
417
+ if (!sessionResetRoute && !flightHeadersStrippedWarned && request.headers.has('next-url') && !request.headers.has('rsc')) {
418
+ flightHeadersStrippedWarned = true;
419
+ 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 });
420
+ }
390
421
  const isPublic = (0, route_config_1.isUnauthenticatedRoute)(pathname);
391
422
  const isLogin = isLoginPage(pathname);
392
423
  // --- Optimization: Skip viability check for unauthenticated public browsing ---
@@ -456,6 +487,7 @@ function createMvpMiddleware(options = {}) {
456
487
  logger: log,
457
488
  refreshEndpoint,
458
489
  rbacExemptPaths,
490
+ reset: resolveReset(),
459
491
  onRefreshSuccess: options.onRefreshSuccess,
460
492
  onRefreshFailure: options.onRefreshFailure,
461
493
  });
@@ -613,7 +645,9 @@ async function executeDecision(request, decision, pathname, sessionPointer, sess
613
645
  case 'allow':
614
646
  return handleAllow(request, pathname, sessionPointer, sessionStatus, opts.rbacExemptPaths);
615
647
  case 'redirect':
616
- return redirectTo(request, decision.location, decision.clearCookies);
648
+ return decision.clearCookies
649
+ ? clearingRedirect(request, decision.location, opts.reset)
650
+ : redirectTo(request, decision.location);
617
651
  case 'service_error':
618
652
  return serviceUnavailableResponse(request);
619
653
  case 'refresh_needed':
@@ -719,7 +753,7 @@ async function handleRefresh(request, safeCallback, opts) {
719
753
  clearingCookies: isPermanentFailure
720
754
  });
721
755
  onRefreshFailure?.(response.status, false);
722
- return refreshFailureResponse(request, `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&error=${errorParam}`, errorParam, isPermanentFailure);
756
+ return refreshFailureResponse(request, `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}&error=${errorParam}`, errorParam, isPermanentFailure, opts.reset);
723
757
  }
724
758
  catch (error) {
725
759
  const isNetwork = cb.isNetworkError(error);
@@ -0,0 +1,21 @@
1
+ /**
2
+ * PAY-2218 PR 2: the session-reset route. Mount it at app/api/auth/session-reset/route.ts:
3
+ *
4
+ * export { GET } from '@payez/next-mvp/routes/auth/session-reset';
5
+ *
6
+ * and turn it on with createMvpMiddleware({ sessionResetRoute: true }). The middleware then sends a page's
7
+ * session-ending redirect (stale session, force-invalidated, pre-2FA timeout, refresh 401/403) here instead of
8
+ * clearing the cookies itself. See src/auth/session-reset-link.ts for the measurements behind each rule.
9
+ *
10
+ * - Router PREFETCH (the route sees `next-router-prefetch`, which Next strips before middleware): 204 + no-store,
11
+ * no redirect, no Set-Cookie. A redirect here would be cached by the 15.5.x router and reused by the click, so
12
+ * the real navigation would never come back to clear.
13
+ * - Real navigation with a valid signed hand-off: expire the session cookies and 307 to the login page.
14
+ * - Anything unsigned, tampered, expired, or bound to a DIFFERENT session than this request's cookie: 307 to the
15
+ * login page and touch NO cookie (nobody can link a signed-in user here to log them out; a second use is a no-op).
16
+ *
17
+ * The login URL is always rebuilt from allowlisted parts; the route never redirects to a URL it was given.
18
+ */
19
+ import { NextRequest, NextResponse } from 'next/server';
20
+ export declare const dynamic = "force-dynamic";
21
+ export declare function GET(request: NextRequest): Promise<NextResponse>;
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.dynamic = void 0;
4
+ exports.GET = GET;
5
+ /**
6
+ * PAY-2218 PR 2: the session-reset route. Mount it at app/api/auth/session-reset/route.ts:
7
+ *
8
+ * export { GET } from '@payez/next-mvp/routes/auth/session-reset';
9
+ *
10
+ * and turn it on with createMvpMiddleware({ sessionResetRoute: true }). The middleware then sends a page's
11
+ * session-ending redirect (stale session, force-invalidated, pre-2FA timeout, refresh 401/403) here instead of
12
+ * clearing the cookies itself. See src/auth/session-reset-link.ts for the measurements behind each rule.
13
+ *
14
+ * - Router PREFETCH (the route sees `next-router-prefetch`, which Next strips before middleware): 204 + no-store,
15
+ * no redirect, no Set-Cookie. A redirect here would be cached by the 15.5.x router and reused by the click, so
16
+ * the real navigation would never come back to clear.
17
+ * - Real navigation with a valid signed hand-off: expire the session cookies and 307 to the login page.
18
+ * - Anything unsigned, tampered, expired, or bound to a DIFFERENT session than this request's cookie: 307 to the
19
+ * login page and touch NO cookie (nobody can link a signed-in user here to log them out; a second use is a no-op).
20
+ *
21
+ * The login URL is always rebuilt from allowlisted parts; the route never redirects to a URL it was given.
22
+ */
23
+ const server_1 = require("next/server");
24
+ const create_middleware_1 = require("../../middleware/create-middleware");
25
+ const session_cookie_clear_1 = require("../../auth/session-cookie-clear");
26
+ const app_slug_1 = require("../../lib/app-slug");
27
+ const session_reset_link_1 = require("../../auth/session-reset-link");
28
+ exports.dynamic = 'force-dynamic';
29
+ async function GET(request) {
30
+ if ((0, create_middleware_1.isPrefetchRequest)(request)) {
31
+ return new server_1.NextResponse(null, { status: 204, headers: { 'cache-control': 'no-store' } });
32
+ }
33
+ const bound = (0, session_reset_link_1.boundSessionCookie)((n) => request.cookies.get(n)?.value, (0, app_slug_1.getSessionCookieName)(), (0, app_slug_1.getSecureSessionCookieName)());
34
+ const { params, signed } = await (0, session_reset_link_1.readResetRequest)(request.nextUrl.searchParams, (0, session_reset_link_1.resetSecret)(), bound);
35
+ const response = server_1.NextResponse.redirect(new URL((0, session_reset_link_1.loginLocation)(params), request.url), 307);
36
+ response.headers.set('cache-control', 'no-store');
37
+ if (signed)
38
+ (0, session_cookie_clear_1.expireSessionCookies)(response, Array.from(request.cookies, ([name]) => name));
39
+ return response;
40
+ }
@@ -24,6 +24,13 @@ import { type RevocationStatus } from '../../vibe/sessions';
24
24
  * - expiresIn: number - Seconds until token expires
25
25
  */
26
26
  export declare function GET(req: NextRequest): Promise<NextResponse<{
27
+ viable: boolean;
28
+ needsRefresh: boolean;
29
+ authenticated: boolean;
30
+ revocationStatus: RevocationStatus;
31
+ error: string;
32
+ code: string;
33
+ }> | NextResponse<{
27
34
  viable: boolean;
28
35
  needsRefresh: boolean;
29
36
  authenticated: boolean;
@@ -49,12 +56,4 @@ export declare function GET(req: NextRequest): Promise<NextResponse<{
49
56
  expiresAt: string;
50
57
  roles: string[];
51
58
  clientId: string | null;
52
- }> | NextResponse<{
53
- viable: boolean;
54
- needsRefresh: boolean;
55
- authenticated: boolean;
56
- revocationStatus: RevocationStatus;
57
- error: string;
58
- code: string;
59
- correlationId: string;
60
59
  }>>;
@@ -136,8 +136,37 @@ function resolveClientId(sessionClientId) {
136
136
  */
137
137
  async function GET(req) {
138
138
  try {
139
- const baSession = await (0, auth_1.getSession)(req);
140
- if (!baSession) {
139
+ // PAY-2216: ask for the session's STATE, not a session-or-null. A null used to cover a store outage, a deleted
140
+ // session and no cookie alike, so this route answered 'No session found' to all three and the middleware could
141
+ // never clear a dead cookie (it must not clear on an outage). See SessionState in server/auth.
142
+ const sessionState = await (0, auth_1.resolveSessionState)(req);
143
+ if (sessionState.state === 'unknown') {
144
+ // The store did not answer. NOT a statement about the session: never stale, never 'No session found'.
145
+ // Same shape as this route's catch (PAY-1882 step 4): 'unknown' revocation -> the middleware's fail-closed
146
+ // path, a non-OK answer -> service_error, which never clears cookies.
147
+ console.warn('[VIABILITY] Session store unavailable - session state unknown');
148
+ return server_1.NextResponse.json({
149
+ viable: false,
150
+ needsRefresh: false,
151
+ authenticated: false,
152
+ revocationStatus: 'unknown',
153
+ error: 'Session store unavailable',
154
+ code: 'STORE_UNAVAILABLE'
155
+ }, { status: 500 });
156
+ }
157
+ if (sessionState.state === 'missing') {
158
+ // A VERIFIED cookie and a store that answered "not here". The existing stale shape, so the middleware's
159
+ // stale_session arm (PAY-2216 / 4.5.4) clears it.
160
+ console.warn('[VIABILITY] Stale cookie detected - verified cookie, session not in Redis');
161
+ return server_1.NextResponse.json({
162
+ viable: false,
163
+ needsRefresh: false,
164
+ authenticated: false,
165
+ sessionTokenId: (0, token_log_id_1.tokenLogId)(sessionState.sessionToken), // PAY-1481: a correlation id, never the token
166
+ reason: 'Stale session - cookie exists but session not found in Redis'
167
+ });
168
+ }
169
+ if (sessionState.state === 'none') {
141
170
  return server_1.NextResponse.json({
142
171
  viable: false,
143
172
  needsRefresh: false,
@@ -145,6 +174,7 @@ async function GET(req) {
145
174
  reason: 'No session found'
146
175
  });
147
176
  }
177
+ const baSession = sessionState.session;
148
178
  const token = baSession;
149
179
  const sessionToken = baSession.session?.token;
150
180
  // Try the canonical session-store first; fall back to the Better-Auth-keyed