@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
package/dist/server/auth.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/server/auth.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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.
|
|
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 {
|
|
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
|
-
//
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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);
|