@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.
- package/dist/api-handlers/session/refresh-viability.d.ts +3 -4
- package/dist/api-handlers/session/refresh-viability.js +19 -1
- package/dist/api-handlers/session/viability.js +16 -2
- package/dist/auth/safe-callback.js +5 -0
- package/dist/auth/session-cookie-clear.d.ts +7 -0
- package/dist/auth/session-cookie-clear.js +24 -0
- package/dist/auth/session-reset-link.d.ts +43 -0
- package/dist/auth/session-reset-link.js +144 -0
- package/dist/middleware/create-middleware.d.ts +13 -0
- package/dist/middleware/create-middleware.js +56 -22
- package/dist/routes/auth/session-reset.d.ts +21 -0
- package/dist/routes/auth/session-reset.js +40 -0
- package/dist/routes/auth/viability.d.ts +7 -8
- package/dist/routes/auth/viability.js +32 -2
- package/dist/server/auth.d.ts +38 -0
- package/dist/server/auth.js +25 -5
- package/package.json +6 -1
- package/src/api-handlers/session/refresh-viability.ts +18 -1
- package/src/api-handlers/session/viability.ts +20 -3
- package/src/auth/safe-callback.ts +5 -0
- package/src/auth/session-cookie-clear.ts +30 -0
- package/src/auth/session-reset-link.ts +159 -0
- package/src/middleware/create-middleware.ts +89 -26
- package/src/routes/auth/session-reset.ts +37 -0
- package/src/routes/auth/viability.ts +36 -3
- package/src/server/auth.ts +53 -5
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
43
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
140
|
-
|
|
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
|