@payez/next-mvp 4.5.0 → 4.5.1
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/auth/auth-decision.d.ts +5 -1
- package/dist/auth/auth-decision.js +11 -1
- package/dist/edge/middleware.d.ts +1 -1
- package/dist/edge/middleware.js +2 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -1
- package/dist/lib/session-store.d.ts +17 -0
- package/dist/lib/session-store.js +33 -0
- package/dist/middleware/create-middleware.d.ts +10 -0
- package/dist/middleware/create-middleware.js +30 -1
- package/dist/server/auth.js +87 -3
- package/dist/vibe/sessions.d.ts +10 -22
- package/dist/vibe/sessions.js +159 -24
- package/package.json +1 -1
- package/src/auth/auth-decision.ts +14 -1
- package/src/edge/middleware.ts +1 -1
- package/src/index.ts +1 -1
- package/src/lib/session-store.ts +42 -0
- package/src/middleware/create-middleware.ts +26 -1
- package/src/server/auth.ts +90 -3
- package/src/vibe/document-unwrap.ts +103 -103
- package/src/vibe/sessions.ts +172 -27
package/dist/vibe/sessions.js
CHANGED
|
@@ -46,10 +46,14 @@ exports.hashSessionToken = hashSessionToken;
|
|
|
46
46
|
exports.resolveNumericIdpUserId = resolveNumericIdpUserId;
|
|
47
47
|
exports.warnNonNumericUserId = warnNonNumericUserId;
|
|
48
48
|
exports.readSessionRevocationHmac = readSessionRevocationHmac;
|
|
49
|
-
exports.
|
|
49
|
+
exports.isConcurrentLimitBody = isConcurrentLimitBody;
|
|
50
|
+
exports.revocationRetryDelayMs = revocationRetryDelayMs;
|
|
50
51
|
exports.getSessionStats = getSessionStats;
|
|
52
|
+
exports.resetRevocationCache = resetRevocationCache;
|
|
53
|
+
exports.getRevocationStatus = getRevocationStatus;
|
|
51
54
|
const crypto_1 = __importDefault(require("crypto"));
|
|
52
55
|
const token_utils_1 = require("../auth/utils/token-utils");
|
|
56
|
+
const token_log_id_1 = require("../lib/token-log-id");
|
|
53
57
|
// -----------------------------------------------------------------------------
|
|
54
58
|
// CONFIGURATION
|
|
55
59
|
// -----------------------------------------------------------------------------
|
|
@@ -457,21 +461,22 @@ async function readSessionRevocationHmac(idpUserId) {
|
|
|
457
461
|
const signingKey = getEnv('VIBE_HMAC_KEY');
|
|
458
462
|
if (!idpUrl || !clientId || !signingKey) {
|
|
459
463
|
// Caller distinguishes this from a read failure; report ok:false, not [].
|
|
460
|
-
return { ok: false, sessions: [] };
|
|
464
|
+
return { ok: false, sessions: [], failure: 'misconfigured' };
|
|
461
465
|
}
|
|
462
466
|
const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/query`;
|
|
463
467
|
const proxyUrl = `${idpUrl}/api/vibe/proxy`;
|
|
464
|
-
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
'
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
468
|
+
// Signed per ATTEMPT: the retry below must not reuse a timestamp that has aged.
|
|
469
|
+
const send = () => {
|
|
470
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
471
|
+
const signature = generateHmacSignature(signingKey, timestamp, 'POST', endpoint);
|
|
472
|
+
// NOTE the absence of Authorization. This is the twin's credential.
|
|
473
|
+
const headers = {
|
|
474
|
+
'Content-Type': 'application/json',
|
|
475
|
+
'X-Vibe-Client-Id': clientId,
|
|
476
|
+
'X-Vibe-Timestamp': String(timestamp),
|
|
477
|
+
'X-Vibe-Signature': signature,
|
|
478
|
+
};
|
|
479
|
+
return fetch(proxyUrl, {
|
|
475
480
|
method: 'POST',
|
|
476
481
|
headers,
|
|
477
482
|
body: JSON.stringify({
|
|
@@ -485,10 +490,21 @@ async function readSessionRevocationHmac(idpUserId) {
|
|
|
485
490
|
},
|
|
486
491
|
}),
|
|
487
492
|
});
|
|
493
|
+
};
|
|
494
|
+
try {
|
|
495
|
+
let response = await send();
|
|
496
|
+
// PAY-2136: ONE retry, and only for the transient class. The portal's own prefetch burst exceeds the free
|
|
497
|
+
// tier's concurrency (CONCURRENT_LIMIT, HTTP 429). Keyed on the BODY code, never on Retry-After: the IdP proxy
|
|
498
|
+
// relays body and status but drops that header (PAY-2143). AUTH_BLOCKED / other 429s and every 5xx are NOT
|
|
499
|
+
// retried (a block is not transient; a retry only adds a counted failure).
|
|
500
|
+
if (response.status === 429 && isConcurrentLimitBody(await safeBodyText(response))) {
|
|
501
|
+
await new Promise((resolve) => setTimeout(resolve, revocationRetryDelayMs(response)));
|
|
502
|
+
response = await send();
|
|
503
|
+
}
|
|
488
504
|
if (!response.ok) {
|
|
489
505
|
// PAY-1427: never the raw proxy body - an error page can echo the request.
|
|
490
506
|
console.error(`[vibe-sessions] Revocation read proxy error ${response.status}`);
|
|
491
|
-
return { ok: false, sessions: [] };
|
|
507
|
+
return { ok: false, sessions: [], failure: String(response.status) }; // PAY-2136: status only, for the reason log
|
|
492
508
|
}
|
|
493
509
|
const data = await response.json();
|
|
494
510
|
const unwrapped = (data?.data ?? data);
|
|
@@ -496,8 +512,38 @@ async function readSessionRevocationHmac(idpUserId) {
|
|
|
496
512
|
}
|
|
497
513
|
catch (error) {
|
|
498
514
|
console.error('[vibe-sessions] Revocation read failed:', error);
|
|
499
|
-
return { ok: false, sessions: [] };
|
|
515
|
+
return { ok: false, sessions: [], failure: 'exception' };
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
/** Body text of a response WITHOUT consuming the caller's copy; '' on any failure. */
|
|
519
|
+
async function safeBodyText(response) {
|
|
520
|
+
try {
|
|
521
|
+
return await response.clone().text();
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
return '';
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
/** True only for the exact code CONCURRENT_LIMIT (nested error.code, top-level code/errorCode); a non-JSON body falls back to the literal. */
|
|
528
|
+
function isConcurrentLimitBody(bodyText) {
|
|
529
|
+
try {
|
|
530
|
+
const b = JSON.parse(bodyText);
|
|
531
|
+
const nested = b?.error?.code;
|
|
532
|
+
const code = [nested, b?.code, b?.errorCode].find((c) => typeof c === 'string');
|
|
533
|
+
if (typeof code === 'string')
|
|
534
|
+
return code === 'CONCURRENT_LIMIT';
|
|
535
|
+
return false;
|
|
500
536
|
}
|
|
537
|
+
catch {
|
|
538
|
+
return /CONCURRENT_LIMIT/.test(bodyText);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
/** Retry-After seconds when the proxy kept it (capped at 2 s: this runs in the request path), else 200-400 ms jitter. */
|
|
542
|
+
function revocationRetryDelayMs(response) {
|
|
543
|
+
const ra = Number(response.headers?.get?.('retry-after'));
|
|
544
|
+
if (Number.isFinite(ra) && ra > 0)
|
|
545
|
+
return Math.min(ra * 1000, 2000);
|
|
546
|
+
return 200 + Math.floor(Math.random() * 200);
|
|
501
547
|
}
|
|
502
548
|
/**
|
|
503
549
|
* THE one revocation decision (PAY-2037 step 1, BAPert 69150): hash-first, per session.
|
|
@@ -509,12 +555,15 @@ async function readSessionRevocationHmac(idpUserId) {
|
|
|
509
555
|
* rule apply: no active row -> the most recently revoked (none revoked: not revoked); else any revoked inside 2h.
|
|
510
556
|
*/
|
|
511
557
|
function findRevokingSession(sessions, sessionTokenHash) {
|
|
558
|
+
return findRevokingSessionWithRule(sessions, sessionTokenHash).row;
|
|
559
|
+
}
|
|
560
|
+
function findRevokingSessionWithRule(sessions, sessionTokenHash) {
|
|
512
561
|
if (sessions.length === 0)
|
|
513
|
-
return null;
|
|
562
|
+
return { row: null, rule: 'none' };
|
|
514
563
|
if (sessionTokenHash) {
|
|
515
564
|
const matching = sessions.find(s => s.session_token_hash === sessionTokenHash);
|
|
516
565
|
if (matching)
|
|
517
|
-
return matching.status === 'revoked' ? matching : null;
|
|
566
|
+
return matching.status === 'revoked' ? { row: matching, rule: 'own_row_revoked' } : { row: null, rule: 'none' };
|
|
518
567
|
}
|
|
519
568
|
// Hash not found (a legacy row minted before hashing) or no hash given: idealresume's user-wide
|
|
520
569
|
// rules, lib/vibe-sessions.ts:594-651 (BAPert 69189).
|
|
@@ -525,11 +574,30 @@ function findRevokingSession(sessions, sessionTokenHash) {
|
|
|
525
574
|
if (!sessions.some(s => s.status === 'active')) {
|
|
526
575
|
const revoked = sessions.filter(s => s.status === 'revoked' && s.revoked_at).sort(newestFirst);
|
|
527
576
|
if (revoked.length > 0)
|
|
528
|
-
return revoked[0];
|
|
577
|
+
return { row: revoked[0], rule: 'legacy_no_active' };
|
|
529
578
|
}
|
|
530
579
|
// Check 2: any row revoked in the last TWO hours (idealresume's window; 5-minute polling needs the time).
|
|
531
580
|
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
|
|
532
|
-
|
|
581
|
+
const recent = sessions.filter(s => s.status === 'revoked' && revokedAt(s) > twoHoursAgo).sort(newestFirst)[0] ?? null;
|
|
582
|
+
return recent ? { row: recent, rule: 'legacy_recent_2h' } : { row: null, rule: 'none' };
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* PAY-2136: one shape-only line per NON-viable revocation verdict, naming WHICH rule fired. Never a token, userId,
|
|
586
|
+
* hash, or row content: the rule label, a read-failure status code, a row COUNT and the session's tokenLogId.
|
|
587
|
+
* Must never throw into the viability path.
|
|
588
|
+
*/
|
|
589
|
+
function logRevocationReason(verdict, reason, sessionToken, extra = {}) {
|
|
590
|
+
try {
|
|
591
|
+
console.warn('[REVOCATION] verdict', {
|
|
592
|
+
verdict,
|
|
593
|
+
reason,
|
|
594
|
+
sessionTokenId: sessionToken ? (0, token_log_id_1.tokenLogId)(sessionToken) : null,
|
|
595
|
+
...extra,
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
/* diagnostic only */
|
|
600
|
+
}
|
|
533
601
|
}
|
|
534
602
|
/**
|
|
535
603
|
* Determine whether a session is revoked, with an explicit UNKNOWN state
|
|
@@ -552,7 +620,7 @@ function findRevokingSession(sessions, sessionTokenHash) {
|
|
|
552
620
|
* @param sessionToken - The live session token; hashed to match the stored row.
|
|
553
621
|
* When absent, the user-wide check applies (legacy path).
|
|
554
622
|
*/
|
|
555
|
-
async function
|
|
623
|
+
async function readRevocationStatus(idpUserId, sessionToken) {
|
|
556
624
|
const idpUrl = getEnv('IDP_URL');
|
|
557
625
|
const clientId = getEnv('VIBE_CLIENT_ID');
|
|
558
626
|
const signingKey = getEnv('VIBE_HMAC_KEY');
|
|
@@ -573,16 +641,21 @@ async function getRevocationStatus(idpUserId, sessionToken) {
|
|
|
573
641
|
// do NOT report "no sessions". 'unknown' is the fail-closed state.
|
|
574
642
|
if (!idpUrl || !signingKey) {
|
|
575
643
|
console.warn('[vibe-sessions] Revocation check MISCONFIGURED (VIBE_CLIENT_ID set but IDP_URL/VIBE_HMAC_KEY missing) - reporting unknown (fail closed)');
|
|
644
|
+
logRevocationReason('unknown', 'misconfigured', sessionToken);
|
|
576
645
|
return 'unknown';
|
|
577
646
|
}
|
|
578
|
-
const { ok, sessions } = await readSessionRevocationHmac(idpUserId);
|
|
647
|
+
const { ok, sessions, failure } = await readSessionRevocationHmac(idpUserId);
|
|
579
648
|
if (!ok) {
|
|
580
649
|
console.warn('[vibe-sessions] Revocation read failed - reporting unknown');
|
|
650
|
+
logRevocationReason('unknown', `read_failed:${failure ?? 'unknown'}`, sessionToken);
|
|
581
651
|
return 'unknown';
|
|
582
652
|
}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
:
|
|
653
|
+
const { row, rule } = findRevokingSessionWithRule(sessions, sessionToken ? hashSessionToken(sessionToken) : undefined);
|
|
654
|
+
if (row) {
|
|
655
|
+
logRevocationReason('revoked', rule, sessionToken, { rows: sessions.length });
|
|
656
|
+
return 'revoked';
|
|
657
|
+
}
|
|
658
|
+
return 'active';
|
|
586
659
|
}
|
|
587
660
|
// -----------------------------------------------------------------------------
|
|
588
661
|
// STATISTICS
|
|
@@ -631,3 +704,65 @@ async function getSessionStats(accessToken) {
|
|
|
631
704
|
recentLogins: stats.recentLogins,
|
|
632
705
|
};
|
|
633
706
|
}
|
|
707
|
+
// -----------------------------------------------------------------------------
|
|
708
|
+
// PAY-2136: SINGLE-FLIGHT + SHORT RESULT CACHE for the revocation read
|
|
709
|
+
// -----------------------------------------------------------------------------
|
|
710
|
+
// Every middleware viability check does one revocation read through the IdP proxy for the SAME Vibe client. A page
|
|
711
|
+
// whose chrome prefetches ~4 links makes ~8 checks inside a second; the free tier allows 2 concurrent queries, so
|
|
712
|
+
// the 3rd+ read was a 429 CONCURRENT_LIMIT and (before this change) signed the user out. Now concurrent callers for
|
|
713
|
+
// ONE session share ONE in-flight read, and a CONFIRMED verdict is reused for a few seconds.
|
|
714
|
+
// - keyed by userId + the token HASH: never shared across sessions or users;
|
|
715
|
+
// - only 'active' / 'revoked' are cached: a failure ('unknown') is shared while in flight but never remembered, so
|
|
716
|
+
// the next request reads again; a revoked session is honoured at most REVOCATION_CACHE_MS late;
|
|
717
|
+
// - REVOCATION_CACHE_MS=0 disables the cache (single-flight still applies).
|
|
718
|
+
const DEFAULT_REVOCATION_CACHE_MS = 5000;
|
|
719
|
+
const MAX_REVOCATION_CACHE_ENTRIES = 2000;
|
|
720
|
+
const revocationInflight = new Map();
|
|
721
|
+
const revocationVerdicts = new Map();
|
|
722
|
+
function revocationCacheMs() {
|
|
723
|
+
const raw = process.env.REVOCATION_CACHE_MS;
|
|
724
|
+
if (raw === undefined || raw === '')
|
|
725
|
+
return DEFAULT_REVOCATION_CACHE_MS;
|
|
726
|
+
const n = Number(raw);
|
|
727
|
+
return Number.isFinite(n) && n >= 0 ? Math.min(n, 30_000) : DEFAULT_REVOCATION_CACHE_MS;
|
|
728
|
+
}
|
|
729
|
+
/** Test/ops hook: forget every cached verdict and in-flight read. */
|
|
730
|
+
function resetRevocationCache() {
|
|
731
|
+
revocationInflight.clear();
|
|
732
|
+
revocationVerdicts.clear();
|
|
733
|
+
}
|
|
734
|
+
async function getRevocationStatus(idpUserId, sessionToken) {
|
|
735
|
+
// No token = the user-wide legacy path: nothing session-specific to key on, so no sharing.
|
|
736
|
+
if (!sessionToken)
|
|
737
|
+
return readRevocationStatus(idpUserId, sessionToken);
|
|
738
|
+
const key = `${idpUserId}:${hashSessionToken(sessionToken)}`;
|
|
739
|
+
const ttl = revocationCacheMs();
|
|
740
|
+
const now = Date.now();
|
|
741
|
+
const hit = revocationVerdicts.get(key);
|
|
742
|
+
if (hit && ttl > 0 && now - hit.at < ttl)
|
|
743
|
+
return hit.status;
|
|
744
|
+
if (hit)
|
|
745
|
+
revocationVerdicts.delete(key);
|
|
746
|
+
const existing = revocationInflight.get(key);
|
|
747
|
+
if (existing)
|
|
748
|
+
return existing;
|
|
749
|
+
const read = readRevocationStatus(idpUserId, sessionToken)
|
|
750
|
+
.then((status) => {
|
|
751
|
+
if (ttl > 0 && (status === 'active' || status === 'revoked')) {
|
|
752
|
+
if (revocationVerdicts.size >= MAX_REVOCATION_CACHE_ENTRIES) {
|
|
753
|
+
for (const [k, v] of revocationVerdicts)
|
|
754
|
+
if (now - v.at >= ttl)
|
|
755
|
+
revocationVerdicts.delete(k);
|
|
756
|
+
if (revocationVerdicts.size >= MAX_REVOCATION_CACHE_ENTRIES)
|
|
757
|
+
revocationVerdicts.clear();
|
|
758
|
+
}
|
|
759
|
+
revocationVerdicts.set(key, { status, at: Date.now() });
|
|
760
|
+
}
|
|
761
|
+
return status;
|
|
762
|
+
})
|
|
763
|
+
.finally(() => {
|
|
764
|
+
revocationInflight.delete(key);
|
|
765
|
+
});
|
|
766
|
+
revocationInflight.set(key, read);
|
|
767
|
+
return read;
|
|
768
|
+
}
|
package/package.json
CHANGED
|
@@ -26,6 +26,8 @@ export interface AuthContext {
|
|
|
26
26
|
twoFactorComplete: boolean;
|
|
27
27
|
};
|
|
28
28
|
circuitBreakerOpen: boolean;
|
|
29
|
+
/** PAY-2136: the request is a router prefetch. A prefetch never clears cookies (see isPrefetchRequest). */
|
|
30
|
+
isPrefetch?: boolean;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
// Output types for the decision engine
|
|
@@ -35,9 +37,20 @@ export type AuthAction =
|
|
|
35
37
|
| { type: 'service_error'; reason: string };
|
|
36
38
|
|
|
37
39
|
/**
|
|
38
|
-
* Main decision function - pure, testable, deterministic
|
|
40
|
+
* Main decision function - pure, testable, deterministic.
|
|
41
|
+
* PAY-2136: when `context.isPrefetch` is set, no redirect it returns asks for cookies to be cleared; the
|
|
42
|
+
* redirect itself is kept, and the next real navigation reaches the same decision and clears then.
|
|
39
43
|
*/
|
|
40
44
|
export function makeAuthDecision(context: AuthContext): AuthAction {
|
|
45
|
+
const action = decide(context);
|
|
46
|
+
if (context.isPrefetch && action.type === 'redirect' && action.clearCookies) {
|
|
47
|
+
const { clearCookies: _dropped, ...rest } = action;
|
|
48
|
+
return rest;
|
|
49
|
+
}
|
|
50
|
+
return action;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function decide(context: AuthContext): AuthAction {
|
|
41
54
|
const {
|
|
42
55
|
pathname,
|
|
43
56
|
isPublicRoute,
|
package/src/edge/middleware.ts
CHANGED
|
@@ -16,7 +16,7 @@ export {
|
|
|
16
16
|
get2FABypassConfig
|
|
17
17
|
} from '../auth/route-config';
|
|
18
18
|
export type { UnauthenticatedRouteConfig, ProtectedRouteConfig } from '../auth/route-config';
|
|
19
|
-
export { createMvpMiddleware } from '../middleware/create-middleware';
|
|
19
|
+
export { createMvpMiddleware, isPrefetchRequest } from '../middleware/create-middleware';
|
|
20
20
|
export type {
|
|
21
21
|
MvpMiddlewareOptions,
|
|
22
22
|
CircuitBreakerProvider,
|
package/src/index.ts
CHANGED
|
@@ -27,7 +27,7 @@ export { isUnauthenticatedRoute, configurePublicRoutes, getRouteConfig } from '.
|
|
|
27
27
|
// The ONE callback-target guard (N7 / PAY-1989): apps import this instead of
|
|
28
28
|
// copying it. Exposed from the main index AND as the subpath ./auth/safe-callback.
|
|
29
29
|
export { safeCallbackPath, isSafeCallbackPath, resolveCallbackDestination, DEFAULT_CALLBACK } from './auth/safe-callback';
|
|
30
|
-
export { createMvpMiddleware } from './middleware/create-middleware';
|
|
30
|
+
export { createMvpMiddleware, isPrefetchRequest } from './middleware/create-middleware';
|
|
31
31
|
|
|
32
32
|
// Account Components
|
|
33
33
|
export { UserAvatarMenu, MobileNavDrawer } from './components/account';
|
package/src/lib/session-store.ts
CHANGED
|
@@ -1048,3 +1048,45 @@ export async function cleanupRefreshLocks(): Promise<number> {
|
|
|
1048
1048
|
console.warn('[SESSION-STORE] cleanupRefreshLocks called - scanning for expired locks');
|
|
1049
1049
|
return 0;
|
|
1050
1050
|
}
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* PAY-2136 DIAGNOSTIC (BAPert 70873): which of the session keys EXIST for a token, right now.
|
|
1054
|
+
*
|
|
1055
|
+
* Names/presence only - this returns booleans and the key PREFIX (an app slug, not a secret), never a value and
|
|
1056
|
+
* never any part of the token. It exists so a NO_SESSION can be read for what it was: canonical row gone with the
|
|
1057
|
+
* ba: record alive (the :330 asymmetry), both gone (expired/deleted), or the store could not be asked ('error').
|
|
1058
|
+
*
|
|
1059
|
+
* EXISTS, not GET: it cannot parse, cannot throw on a malformed blob, and cannot return a credential. Each key is
|
|
1060
|
+
* asked separately so one timeout does not hide the other two. Never throws.
|
|
1061
|
+
*/
|
|
1062
|
+
export type KeyPresence = boolean | 'error';
|
|
1063
|
+
export async function describeSessionKeyPresence(
|
|
1064
|
+
sessionToken: string
|
|
1065
|
+
): Promise<{ prefix: string; canonical: KeyPresence; ba: KeyPresence; ver: KeyPresence }> {
|
|
1066
|
+
const ask = async (key: string): Promise<KeyPresence> => {
|
|
1067
|
+
try {
|
|
1068
|
+
return (await redis.exists(key)) > 0;
|
|
1069
|
+
} catch {
|
|
1070
|
+
return 'error';
|
|
1071
|
+
}
|
|
1072
|
+
};
|
|
1073
|
+
let prefix = '';
|
|
1074
|
+
try {
|
|
1075
|
+
prefix = getSessionPrefix();
|
|
1076
|
+
} catch {
|
|
1077
|
+
prefix = '';
|
|
1078
|
+
}
|
|
1079
|
+
if (!sessionToken) return { prefix, canonical: false, ba: false, ver: false };
|
|
1080
|
+
let canonicalKey = '';
|
|
1081
|
+
let baKey = '';
|
|
1082
|
+
let verKey = '';
|
|
1083
|
+
try {
|
|
1084
|
+
canonicalKey = getSessionKey(sessionToken);
|
|
1085
|
+
baKey = getBetterAuthSessionKey(sessionToken, getAppSlug());
|
|
1086
|
+
verKey = getSessionVersionKey(sessionToken);
|
|
1087
|
+
} catch {
|
|
1088
|
+
return { prefix, canonical: 'error', ba: 'error', ver: 'error' };
|
|
1089
|
+
}
|
|
1090
|
+
const [canonical, ba, ver] = await Promise.all([ask(canonicalKey), ask(baKey), ask(verKey)]);
|
|
1091
|
+
return { prefix, canonical, ba, ver };
|
|
1092
|
+
}
|
|
@@ -197,8 +197,30 @@ function getSafeCallbackUrl(pathname: string, searchParams?: URLSearchParams): s
|
|
|
197
197
|
return resolveCallbackDestination(pathname, searchParams);
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
-
/**
|
|
200
|
+
/**
|
|
201
|
+
* PAY-2136 Rule A: a speculative request (router prefetch) must NEVER mutate cookies.
|
|
202
|
+
* A prefetch of /account/security was answered 307 + Set-Cookie (session cookies expired), the browser
|
|
203
|
+
* applied it, and the user's next real request (e.g. Approve) was signed out. Detected by any of:
|
|
204
|
+
* `Next-Router-Prefetch: 1|2` (Next sets it on EVERY Link prefetch; 2 = runtime prefetch) or `Sec-Purpose`/`Purpose`
|
|
205
|
+
* containing "prefetch".
|
|
206
|
+
* NOT `RSC: 1` + `_rsc`: a real client-side navigation carries both too (Nextpert-Scout 70976), so that shape
|
|
207
|
+
* would swallow in-app navigations and stop the middleware refreshing/clearing for them.
|
|
208
|
+
*/
|
|
209
|
+
export function isPrefetchRequest(request: NextRequest): boolean {
|
|
210
|
+
const h = request.headers;
|
|
211
|
+
const nrp = h.get('next-router-prefetch');
|
|
212
|
+
if (nrp === '1' || nrp === '2') return true;
|
|
213
|
+
if (/prefetch/i.test(h.get('sec-purpose') ?? '') || /prefetch/i.test(h.get('purpose') ?? '')) return true;
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Clear all session cookies on a response. The ONE site that writes the expiry Set-Cookie for the middleware,
|
|
219
|
+
* so the prefetch guard lives here: on a prefetch the redirect is still returned (routing unchanged) but
|
|
220
|
+
* carries no Set-Cookie; the next real navigation reaches the same decision and clears then.
|
|
221
|
+
*/
|
|
201
222
|
function clearSessionCookies(response: NextResponse, request: NextRequest): void {
|
|
223
|
+
if (isPrefetchRequest(request)) return;
|
|
202
224
|
const sessionCookie = getSessionCookieName();
|
|
203
225
|
const secureCookie = getSecureSessionCookieName();
|
|
204
226
|
|
|
@@ -736,6 +758,9 @@ async function executeDecision(
|
|
|
736
758
|
return NextResponse.redirect(new URL('/service-unavailable', request.url));
|
|
737
759
|
|
|
738
760
|
case 'refresh_needed':
|
|
761
|
+
// PAY-2136: a speculative request must not spend (rotate) a single-use refresh token or, on a 401/403,
|
|
762
|
+
// clear the session. Let it through; the real navigation refreshes.
|
|
763
|
+
if (isPrefetchRequest(request)) return NextResponse.next();
|
|
739
764
|
return handleRefresh(request, safeCallback, opts);
|
|
740
765
|
}
|
|
741
766
|
}
|
package/src/server/auth.ts
CHANGED
|
@@ -10,8 +10,10 @@ import { getBetterAuthInstance } from '../auth/better-auth';
|
|
|
10
10
|
import {
|
|
11
11
|
getSession as getRedisSession,
|
|
12
12
|
getBetterAuthSession as getBetterAuthRedisSession,
|
|
13
|
+
describeSessionKeyPresence,
|
|
13
14
|
type SessionData,
|
|
14
15
|
} from '../lib/session-store';
|
|
16
|
+
import { tokenLogId } from '../lib/token-log-id';
|
|
15
17
|
import { resolveVerifiedSessionTokenFromRequest } from './verified-session-token';
|
|
16
18
|
|
|
17
19
|
export type IdpTokenResult =
|
|
@@ -125,6 +127,82 @@ export async function getAuthInstance() {
|
|
|
125
127
|
}
|
|
126
128
|
|
|
127
129
|
|
|
130
|
+
/* ----------------------------------------------------------------------------------------------------------------
|
|
131
|
+
* PAY-2136 DIAGNOSTICS (BAPert 70873, Nextpert-Scout 70872/70875) - LOG ONLY, NO BEHAVIOUR CHANGE.
|
|
132
|
+
*
|
|
133
|
+
* A getFreshIdpToken NO_SESSION used to carry no origin: three different returns produced the same
|
|
134
|
+
* `{error:'NO_SESSION'}`, and getSession() swallowed every Redis error into a bare `catch {}`. These helpers name
|
|
135
|
+
* the origin and make a swallowed read audible. Hard rules: every helper is wrapped so it can NEVER throw or change
|
|
136
|
+
* a return value; nothing here ever logs a token, a cookie VALUE or a Redis value - only a tokenLogId() fingerprint,
|
|
137
|
+
* cookie NAMES, booleans, and an error name/message with any long opaque run redacted.
|
|
138
|
+
* -------------------------------------------------------------------------------------------------------------- */
|
|
139
|
+
|
|
140
|
+
/** An error name/message, first 80 chars, with any 24+ char opaque run (a token, a key) replaced. */
|
|
141
|
+
function scrubForLog(v: unknown): string {
|
|
142
|
+
const raw = v instanceof Error ? v.message : String(v ?? '');
|
|
143
|
+
return raw.replace(/[A-Za-z0-9_\-.+/=]{24,}/g, '[redacted]').slice(0, 80);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `catch {}` sites on the session read path: still swallowed, now audible. */
|
|
147
|
+
function logSessionReadSwallowed(path: 'primary' | 'A' | 'B', err: unknown): void {
|
|
148
|
+
try {
|
|
149
|
+
const name = scrubForLog(err instanceof Error ? err.name : typeof err);
|
|
150
|
+
console.warn(`[server-auth] SESSION_READ_SWALLOWED path=${path} err=${name} msg=${scrubForLog(err)}`);
|
|
151
|
+
} catch {
|
|
152
|
+
/* a diagnostic must never change the outcome */
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Origin a: getSession(request) produced no token. Cookie NAMES only, never a value. */
|
|
157
|
+
function logNoSessionOriginA(request: Request | undefined): void {
|
|
158
|
+
try {
|
|
159
|
+
const header = request?.headers?.get('cookie') ?? '';
|
|
160
|
+
// Only segments of the form name=value contribute a NAME. A segment with no '=' is not a cookie pair, and
|
|
161
|
+
// logging it whole could emit a bare token (Nextpert-Scout 70901 finding 1).
|
|
162
|
+
const cookieNames = header
|
|
163
|
+
.split(';')
|
|
164
|
+
// Structural, alphabet-independent (Nextpert-Scout 70945): base64 padding can only sit at the END of a bare
|
|
165
|
+
// value, so a bare padded token splits as name=<token>, value='' or '='. A real pair has a non-empty value that
|
|
166
|
+
// is not only '=' characters. (The old includes('=') let 74% of base64url tokens through as a "name".)
|
|
167
|
+
.filter((c) => {
|
|
168
|
+
const i = c.indexOf('=');
|
|
169
|
+
if (i < 0) return false;
|
|
170
|
+
const v = c.slice(i + 1);
|
|
171
|
+
return v !== '' && !/^=+$/.test(v);
|
|
172
|
+
})
|
|
173
|
+
.map((c) => c.split('=')[0].trim())
|
|
174
|
+
.filter((n) => n !== '')
|
|
175
|
+
// A bare token that merely ENDS in base64 padding ('=') still passes the filter above and its "name" would be
|
|
176
|
+
// the token itself (Nextpert-Scout 70913). Real cookie names carry a separator ('.', '-', '_'); a long name with
|
|
177
|
+
// none is opaque, so it is logged as a placeholder, never as itself.
|
|
178
|
+
.map((n) => (n.length >= 24 && !/[._-]/.test(n) ? '[opaque-name]' : n))
|
|
179
|
+
.slice(0, 20);
|
|
180
|
+
console.warn('[server-auth] NO_SESSION origin=a', { hasCookieHeader: header !== '', cookieNames });
|
|
181
|
+
} catch {
|
|
182
|
+
/* a diagnostic must never change the outcome */
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Origins b and c: a token WAS resolved. Log which of its three keys exist (presence only), fire-and-forget. */
|
|
187
|
+
function logNoSessionWithKeys(origin: 'b' | 'c', sessionToken: string): void {
|
|
188
|
+
try {
|
|
189
|
+
void describeSessionKeyPresence(sessionToken).then(
|
|
190
|
+
(keys) => {
|
|
191
|
+
try {
|
|
192
|
+
console.warn(`[server-auth] NO_SESSION origin=${origin}`, { token: tokenLogId(sessionToken), ...keys });
|
|
193
|
+
} catch {
|
|
194
|
+
/* never throws */
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
() => {
|
|
198
|
+
/* never throws */
|
|
199
|
+
}
|
|
200
|
+
);
|
|
201
|
+
} catch {
|
|
202
|
+
/* a diagnostic must never change the outcome */
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
128
206
|
/**
|
|
129
207
|
* Get the current session from a request.
|
|
130
208
|
*
|
|
@@ -153,7 +231,9 @@ export async function getSession(request?: Request): Promise<any> {
|
|
|
153
231
|
let betterAuthSession: any = null;
|
|
154
232
|
try {
|
|
155
233
|
betterAuthSession = await auth.api.getSession({ headers: request.headers });
|
|
156
|
-
} catch {
|
|
234
|
+
} catch (err) {
|
|
235
|
+
logSessionReadSwallowed('primary', err); // fall through to canonical Redis path
|
|
236
|
+
}
|
|
157
237
|
|
|
158
238
|
if (betterAuthSession?.session?.token && betterAuthSession?.user) {
|
|
159
239
|
const sessionToken = betterAuthSession.session.token as string;
|
|
@@ -161,7 +241,9 @@ export async function getSession(request?: Request): Promise<any> {
|
|
|
161
241
|
try {
|
|
162
242
|
sessionData = await getRedisSession(sessionToken);
|
|
163
243
|
if (!sessionData) sessionData = await getBetterAuthRedisSession(sessionToken);
|
|
164
|
-
} catch {
|
|
244
|
+
} catch (err) {
|
|
245
|
+
logSessionReadSwallowed('A', err); // Redis unavailable
|
|
246
|
+
}
|
|
165
247
|
if (!sessionData) sessionData = buildSessionDataFromAuthSession(betterAuthSession);
|
|
166
248
|
return attachSessionData(betterAuthSession, sessionData, sessionToken);
|
|
167
249
|
}
|
|
@@ -184,7 +266,9 @@ export async function getSession(request?: Request): Promise<any> {
|
|
|
184
266
|
try {
|
|
185
267
|
sessionData = await getRedisSession(sessionToken);
|
|
186
268
|
if (!sessionData) sessionData = await getBetterAuthRedisSession(sessionToken);
|
|
187
|
-
} catch {
|
|
269
|
+
} catch (err) {
|
|
270
|
+
logSessionReadSwallowed('B', err); // Redis unavailable
|
|
271
|
+
}
|
|
188
272
|
if (!sessionData) return null;
|
|
189
273
|
|
|
190
274
|
// Synthesize a minimal session shape compatible with downstream callers.
|
|
@@ -296,6 +380,7 @@ export async function getFreshIdpToken(
|
|
|
296
380
|
const session = await getSession(request);
|
|
297
381
|
const sessionToken = session?.session?.token as string | undefined;
|
|
298
382
|
if (!sessionToken) {
|
|
383
|
+
logNoSessionOriginA(request);
|
|
299
384
|
return { success: false, error: 'NO_SESSION', status: 401, terminal: true };
|
|
300
385
|
}
|
|
301
386
|
|
|
@@ -314,6 +399,7 @@ export async function getFreshIdpToken(
|
|
|
314
399
|
);
|
|
315
400
|
|
|
316
401
|
if (!result.ok) {
|
|
402
|
+
if (result.code === 'NO_SESSION') logNoSessionWithKeys('b', sessionToken);
|
|
317
403
|
return {
|
|
318
404
|
success: false,
|
|
319
405
|
error: result.code,
|
|
@@ -327,6 +413,7 @@ export async function getFreshIdpToken(
|
|
|
327
413
|
|
|
328
414
|
const sessionData = await getRedisSession(sessionToken);
|
|
329
415
|
if (!sessionData) {
|
|
416
|
+
logNoSessionWithKeys('c', sessionToken);
|
|
330
417
|
return { success: false, error: 'NO_SESSION', status: 401, terminal: true };
|
|
331
418
|
}
|
|
332
419
|
|