@payez/next-mvp 4.5.5 → 4.5.6
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/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 +1 -1
- package/src/api-handlers/session/refresh-viability.ts +18 -1
- package/src/api-handlers/session/viability.ts +20 -3
- 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');
|
|
@@ -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
|
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
|
@@ -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) {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { NextRequest, NextResponse } from 'next/server';
|
|
18
|
-
import {
|
|
18
|
+
import { resolveSessionState } from '../../server/auth';
|
|
19
19
|
import {
|
|
20
20
|
getSession as getRedisSession,
|
|
21
21
|
getBetterAuthSession as getBetterAuthRedisSession,
|
|
@@ -142,9 +142,40 @@ function resolveClientId(sessionClientId: string | undefined): string | null {
|
|
|
142
142
|
*/
|
|
143
143
|
export async function GET(req: NextRequest) {
|
|
144
144
|
try {
|
|
145
|
-
|
|
145
|
+
// PAY-2216: ask for the session's STATE, not a session-or-null. A null used to cover a store outage, a deleted
|
|
146
|
+
// session and no cookie alike, so this route answered 'No session found' to all three and the middleware could
|
|
147
|
+
// never clear a dead cookie (it must not clear on an outage). See SessionState in server/auth.
|
|
148
|
+
const sessionState = await resolveSessionState(req);
|
|
146
149
|
|
|
147
|
-
if (
|
|
150
|
+
if (sessionState.state === 'unknown') {
|
|
151
|
+
// The store did not answer. NOT a statement about the session: never stale, never 'No session found'.
|
|
152
|
+
// Same shape as this route's catch (PAY-1882 step 4): 'unknown' revocation -> the middleware's fail-closed
|
|
153
|
+
// path, a non-OK answer -> service_error, which never clears cookies.
|
|
154
|
+
console.warn('[VIABILITY] Session store unavailable - session state unknown');
|
|
155
|
+
return NextResponse.json({
|
|
156
|
+
viable: false,
|
|
157
|
+
needsRefresh: false,
|
|
158
|
+
authenticated: false,
|
|
159
|
+
revocationStatus: 'unknown' as RevocationStatus,
|
|
160
|
+
error: 'Session store unavailable',
|
|
161
|
+
code: 'STORE_UNAVAILABLE'
|
|
162
|
+
}, { status: 500 });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (sessionState.state === 'missing') {
|
|
166
|
+
// A VERIFIED cookie and a store that answered "not here". The existing stale shape, so the middleware's
|
|
167
|
+
// stale_session arm (PAY-2216 / 4.5.4) clears it.
|
|
168
|
+
console.warn('[VIABILITY] Stale cookie detected - verified cookie, session not in Redis');
|
|
169
|
+
return NextResponse.json({
|
|
170
|
+
viable: false,
|
|
171
|
+
needsRefresh: false,
|
|
172
|
+
authenticated: false,
|
|
173
|
+
sessionTokenId: tokenLogId(sessionState.sessionToken), // PAY-1481: a correlation id, never the token
|
|
174
|
+
reason: 'Stale session - cookie exists but session not found in Redis'
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (sessionState.state === 'none') {
|
|
148
179
|
return NextResponse.json({
|
|
149
180
|
viable: false,
|
|
150
181
|
needsRefresh: false,
|
|
@@ -153,6 +184,8 @@ export async function GET(req: NextRequest) {
|
|
|
153
184
|
});
|
|
154
185
|
}
|
|
155
186
|
|
|
187
|
+
const baSession = sessionState.session;
|
|
188
|
+
|
|
156
189
|
const token = baSession as any;
|
|
157
190
|
const sessionToken = baSession.session?.token;
|
|
158
191
|
|
package/src/server/auth.ts
CHANGED
|
@@ -221,17 +221,60 @@ function logNoSessionWithKeys(origin: 'b' | 'c', sessionToken: string): void {
|
|
|
221
221
|
* getFreshIdpToken → NO_SESSION (Better Auth cache miss)
|
|
222
222
|
*
|
|
223
223
|
* Returns the session object or null if not authenticated per Redis.
|
|
224
|
+
*
|
|
225
|
+
* PAY-2216: this is now a thin wrapper over {@link resolveSessionState} and returns the session ONLY for `live`.
|
|
226
|
+
* Its signature and every answer it gives are unchanged; callers that must tell a dead session from an
|
|
227
|
+
* unreachable store call resolveSessionState directly.
|
|
224
228
|
*/
|
|
225
229
|
export async function getSession(request?: Request): Promise<any> {
|
|
230
|
+
const state = await resolveSessionState(request);
|
|
231
|
+
return state.state === 'live' ? state.session : null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* PAY-2216: what the session store said about this request's cookie. `getSession()` collapses all three
|
|
236
|
+
* non-live answers to `null`, and that collapse is the defect this type exists to undo.
|
|
237
|
+
*
|
|
238
|
+
* MEASURED (PAY-2216, NextPert 2026-10-11, portal fe547fa4 / next-mvp 4.5.4 / ioredis 5.3.2, real server):
|
|
239
|
+
* a Redis OUTAGE and a really DELETED key both came out of getSession() as `null`, so viability answered the
|
|
240
|
+
* identical `200 No session found` for both. Clearing on that shape would log every live user out on an
|
|
241
|
+
* outage (the PAY-2147 class), so nothing cleared at all and a dead cookie lingered.
|
|
242
|
+
*
|
|
243
|
+
* live - the session resolved. `session` is exactly what getSession() returns.
|
|
244
|
+
* missing - a cookie whose signature VERIFIED (HMAC or JWS, resolveVerifiedSessionTokenFromRequest) and BOTH
|
|
245
|
+
* store reads (canonical `{slug}:sess:{t}` and Better Auth's `ba:{slug}:{t}`) COMPLETED and found
|
|
246
|
+
* nothing. The store answered; the session is gone. Clearing the cookie is correct. This includes
|
|
247
|
+
* a Redis that restarted EMPTY: those sessions really are gone (BAPert 72093, gate row 6).
|
|
248
|
+
* unknown - a store read THREW (connection refused, commandTimeout, max retries). The store did not answer,
|
|
249
|
+
* so nothing is known about the session. NEVER clear on this. Callers answer 5xx.
|
|
250
|
+
* none - no session cookie, or a cookie whose signature does not verify, with the store not failing.
|
|
251
|
+
* Nothing to clear on our authority: a forged cookie never yields `missing`.
|
|
252
|
+
*
|
|
253
|
+
* The rule the whole type exists for: `missing` REQUIRES a verified cookie AND reads that completed. Weaken
|
|
254
|
+
* either and an outage or a forged cookie becomes a clear.
|
|
255
|
+
*/
|
|
256
|
+
export type SessionState =
|
|
257
|
+
| { state: 'live'; session: any }
|
|
258
|
+
| { state: 'missing'; sessionToken: string }
|
|
259
|
+
| { state: 'unknown' }
|
|
260
|
+
| { state: 'none' };
|
|
261
|
+
|
|
262
|
+
export async function resolveSessionState(request?: Request): Promise<SessionState> {
|
|
226
263
|
const auth = await getAuthInstance();
|
|
227
|
-
if (!request) return
|
|
264
|
+
if (!request) return { state: 'none' };
|
|
228
265
|
|
|
229
266
|
// Path A — Better Auth primary path (cookie cache → secondary storage).
|
|
230
267
|
// Fast happy-path when both Better Auth and Redis agree.
|
|
268
|
+
//
|
|
269
|
+
// PAY-2216: secondaryStorage.get RETHROWS a Redis failure on purpose (170033), so a throw here means "the store
|
|
270
|
+
// could not be asked". It is remembered: if Path B then has no verified token to read with, the honest answer is
|
|
271
|
+
// `unknown`, not `none`.
|
|
231
272
|
let betterAuthSession: any = null;
|
|
273
|
+
let primaryThrew = false;
|
|
232
274
|
try {
|
|
233
275
|
betterAuthSession = await auth.api.getSession({ headers: request.headers });
|
|
234
276
|
} catch (err) {
|
|
277
|
+
primaryThrew = true;
|
|
235
278
|
logSessionReadSwallowed('primary', err); // fall through to canonical Redis path
|
|
236
279
|
}
|
|
237
280
|
|
|
@@ -245,7 +288,7 @@ export async function getSession(request?: Request): Promise<any> {
|
|
|
245
288
|
logSessionReadSwallowed('A', err); // Redis unavailable
|
|
246
289
|
}
|
|
247
290
|
if (!sessionData) sessionData = buildSessionDataFromAuthSession(betterAuthSession);
|
|
248
|
-
return attachSessionData(betterAuthSession, sessionData, sessionToken);
|
|
291
|
+
return { state: 'live', session: attachSessionData(betterAuthSession, sessionData, sessionToken) };
|
|
249
292
|
}
|
|
250
293
|
|
|
251
294
|
// Path B — Better Auth said null/incomplete. Fall back to a VERIFIED cookie
|
|
@@ -260,16 +303,21 @@ export async function getSession(request?: Request): Promise<any> {
|
|
|
260
303
|
// the signature position, and a forged JWS too. The token now comes only from
|
|
261
304
|
// the shared verified resolver (HMAC or JWS signature checked first).
|
|
262
305
|
const sessionToken = await resolveVerifiedSessionTokenFromRequest(request);
|
|
263
|
-
|
|
306
|
+
// No verified token. If Better Auth's read THREW, the store was failing, so we cannot say the cookie is
|
|
307
|
+
// unverifiable rather than unreadable: `unknown`. Otherwise there is nothing to act on: `none`.
|
|
308
|
+
if (!sessionToken) return primaryThrew ? { state: 'unknown' } : { state: 'none' };
|
|
264
309
|
|
|
310
|
+
// PAY-2216: both reads must COMPLETE for a miss to mean anything. Either one throwing is `unknown`, which
|
|
311
|
+
// nobody may clear on. These two functions do a bare redis.get and do not swallow (verified at 4.5.4).
|
|
265
312
|
let sessionData: SessionData | null = null;
|
|
266
313
|
try {
|
|
267
314
|
sessionData = await getRedisSession(sessionToken);
|
|
268
315
|
if (!sessionData) sessionData = await getBetterAuthRedisSession(sessionToken);
|
|
269
316
|
} catch (err) {
|
|
270
317
|
logSessionReadSwallowed('B', err); // Redis unavailable
|
|
318
|
+
return { state: 'unknown' };
|
|
271
319
|
}
|
|
272
|
-
if (!sessionData) return
|
|
320
|
+
if (!sessionData) return { state: 'missing', sessionToken };
|
|
273
321
|
|
|
274
322
|
// Synthesize a minimal session shape compatible with downstream callers.
|
|
275
323
|
// The `session.token` field is what `getFreshIdpToken` and other callers
|
|
@@ -283,7 +331,7 @@ export async function getSession(request?: Request): Promise<any> {
|
|
|
283
331
|
roles: sessionData.roles,
|
|
284
332
|
},
|
|
285
333
|
};
|
|
286
|
-
return attachSessionData(synthetic, sessionData, sessionToken);
|
|
334
|
+
return { state: 'live', session: attachSessionData(synthetic, sessionData, sessionToken) };
|
|
287
335
|
}
|
|
288
336
|
|
|
289
337
|
/**
|