@payez/next-mvp 4.4.1 → 4.4.3
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/auth-handler.js +13 -4
- package/dist/api-handlers/mfa/totp-verify.d.ts +3 -1
- package/dist/api-handlers/mfa/totp-verify.js +22 -3
- package/dist/api-handlers/session/refresh-viability.js +2 -2
- package/dist/api-handlers/session/viability.js +1 -1
- package/dist/auth/auth-decision.js +27 -8
- package/dist/auth/better-auth.js +123 -1
- package/dist/auth/safe-callback.d.ts +92 -0
- package/dist/auth/safe-callback.js +183 -0
- package/dist/hooks/useProfile.d.ts +0 -1
- package/dist/lib/acr.d.ts +64 -0
- package/dist/lib/acr.js +109 -0
- package/dist/lib/session-store.js +56 -7
- package/dist/middleware/create-middleware.js +45 -13
- package/dist/pages/profile/EnhancedProfilePage.js +38 -13
- package/dist/pages/verify-code/page.js +26 -1
- package/dist/routes/auth/viability.d.ts +2 -2
- package/dist/routes/auth/viability.js +2 -2
- package/dist/server/auth-guard.js +26 -1
- package/package.json +2 -1
- package/scripts/module-reach.mjs +280 -0
- package/src/api/auth-handler.ts +19 -4
- package/src/api-handlers/mfa/totp-verify.ts +24 -3
- package/src/api-handlers/session/refresh-viability.ts +2 -2
- package/src/api-handlers/session/viability.ts +1 -1
- package/src/auth/auth-decision.ts +28 -8
- package/src/auth/better-auth.ts +128 -1
- package/src/auth/safe-callback.ts +185 -0
- package/src/hooks/useProfile.ts +8 -1
- package/src/lib/acr.ts +109 -0
- package/src/lib/session-store.ts +66 -7
- package/src/middleware/create-middleware.ts +45 -13
- package/src/pages/profile/EnhancedProfilePage.tsx +43 -14
- package/src/pages/verify-code/page.tsx +26 -1
- package/src/routes/auth/viability.ts +2 -2
- package/src/server/auth-guard.ts +27 -1
- package/dist/middleware/auth-decision.d.ts +0 -33
- package/dist/middleware/auth-decision.js +0 -65
- package/src/middleware/auth-decision.ts +0 -43
package/dist/api/auth-handler.js
CHANGED
|
@@ -17,6 +17,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
17
17
|
exports.createAuthHandler = createAuthHandler;
|
|
18
18
|
const server_1 = require("next/server");
|
|
19
19
|
const auth_1 = require("../server/auth");
|
|
20
|
+
const acr_1 = require("../lib/acr");
|
|
20
21
|
const nanoid_1 = require("nanoid");
|
|
21
22
|
const session_store_1 = require("../lib/session-store");
|
|
22
23
|
/**
|
|
@@ -158,10 +159,18 @@ function createAuthHandler(options = {}) {
|
|
|
158
159
|
console.error('[AUTH_HANDLER] No refresh token available');
|
|
159
160
|
return { success: false, error: 'No refresh token' };
|
|
160
161
|
}
|
|
161
|
-
// Extract authentication methods from session
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
162
|
+
// Extract authentication methods from session.
|
|
163
|
+
//
|
|
164
|
+
// 375639: these two lines used to read
|
|
165
|
+
// authMethods = ... : ['pwd', 'mfa']
|
|
166
|
+
// authLevel = String(... || ... || '2')
|
|
167
|
+
// so a session that knew NOTHING about how the user authenticated told
|
|
168
|
+
// the IDP that a second factor had happened AND named 'mfa' as the
|
|
169
|
+
// method. The two fabrications corroborated each other, so a downstream
|
|
170
|
+
// check cross-validating amr against acr would have accepted the pair.
|
|
171
|
+
// Absent is not satisfied; both now resolve fail-closed, in one place.
|
|
172
|
+
const authMethods = (0, acr_1.resolveAuthMethods)(currentSession.authMethods, currentSession.token?.amr);
|
|
173
|
+
const authLevel = (0, acr_1.resolveAcrLevel)(currentSession.authenticationLevel, currentSession.token?.acr);
|
|
165
174
|
const twoFactorMethod = currentSession.twoFactorMethod || 'authenticator';
|
|
166
175
|
// Build refresh request body
|
|
167
176
|
const refreshRequestBody = {
|
|
@@ -40,8 +40,10 @@ export declare function POST(req: NextRequest): Promise<NextResponse<{
|
|
|
40
40
|
attemptedRefresh: boolean;
|
|
41
41
|
};
|
|
42
42
|
}> | NextResponse<{
|
|
43
|
+
message: any;
|
|
44
|
+
requiresChallenge?: boolean | undefined;
|
|
45
|
+
nextStep?: "mfa_verify" | undefined;
|
|
43
46
|
success: boolean;
|
|
44
47
|
verificationSuccessful: boolean;
|
|
45
48
|
twoFactorSessionVerified: boolean;
|
|
46
|
-
message: any;
|
|
47
49
|
}>>;
|
|
@@ -68,6 +68,10 @@ async function POST(req) {
|
|
|
68
68
|
// session. No tokens => no transition: we must not mark 2FA complete on the
|
|
69
69
|
// strength of a 200 alone.
|
|
70
70
|
let sessionTransitioned = false;
|
|
71
|
+
// R908(e): "we could not establish MFA" is a THIRD outcome, distinct from
|
|
72
|
+
// both success and failure. It must reach the client as a challenge, not as
|
|
73
|
+
// a silent success (fail-open) and not as a rejection (fail-closed).
|
|
74
|
+
let requiresChallenge = false;
|
|
71
75
|
if (unwrappedData.access_token && unwrappedData.refresh_token) {
|
|
72
76
|
const token = await (0, test_aware_get_token_1.getTokenTestAware)(req);
|
|
73
77
|
const sessionToken = (token?.sessionToken || token?.redisSessionId);
|
|
@@ -90,15 +94,27 @@ async function POST(req) {
|
|
|
90
94
|
catch {
|
|
91
95
|
// Refresh token may carry no exp claim; default stands.
|
|
92
96
|
}
|
|
93
|
-
|
|
97
|
+
// R908(e): the transition can now decline to grant. Read what was
|
|
98
|
+
// actually written instead of assuming the call granted - the store
|
|
99
|
+
// withholds mfaVerified when the stepped-up token carries no
|
|
100
|
+
// mfa_expires, and reports the re-challenge via nextStep.
|
|
101
|
+
const transitioned = await (0, session_store_1.transitionTo2FASession)(sessionToken, {
|
|
94
102
|
accessToken: unwrappedData.access_token,
|
|
95
103
|
refreshToken: unwrappedData.refresh_token,
|
|
96
104
|
accessTokenExpires,
|
|
97
105
|
refreshTokenExpires,
|
|
98
106
|
}, 'totp' // 2FA method, used by the refresh-token flow
|
|
99
107
|
);
|
|
100
|
-
sessionTransitioned = true;
|
|
101
|
-
|
|
108
|
+
sessionTransitioned = transitioned?.mfaVerified === true;
|
|
109
|
+
requiresChallenge = transitioned?.nextStep === 'mfa_verify';
|
|
110
|
+
if (sessionTransitioned) {
|
|
111
|
+
console.log('[VERIFY_TOTP] Session transitioned to 2FA-verified');
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
console.warn('[VERIFY_TOTP] R908(e): IDP verified the code but the session was NOT ' +
|
|
115
|
+
'marked MFA-verified (no mfa_expires on the returned token). Reporting ' +
|
|
116
|
+
'requiresChallenge so the client re-challenges rather than proceeding.');
|
|
117
|
+
}
|
|
102
118
|
}
|
|
103
119
|
else {
|
|
104
120
|
console.warn('[VERIFY_TOTP] IDP verified the code but no session token was resolvable — session NOT transitioned');
|
|
@@ -120,6 +136,9 @@ async function POST(req) {
|
|
|
120
136
|
success: true,
|
|
121
137
|
verificationSuccessful: true,
|
|
122
138
|
twoFactorSessionVerified: sessionTransitioned,
|
|
139
|
+
// R908(e): tells the client to re-challenge. Present only when the code
|
|
140
|
+
// verified at the IDP but produced no evidence we could record.
|
|
141
|
+
...(requiresChallenge ? { requiresChallenge: true, nextStep: 'mfa_verify' } : {}),
|
|
123
142
|
message: unwrappedData.message || 'Authenticator code verified successfully',
|
|
124
143
|
}, { status: 200 });
|
|
125
144
|
}
|
|
@@ -23,14 +23,14 @@ async function GET(req) {
|
|
|
23
23
|
// Still need to get session data for twoFactorComplete even when refresh is in progress
|
|
24
24
|
const sessionData = await (0, session_store_1.getSession)(finalSessionToken);
|
|
25
25
|
logger_1.logger.info('[REFRESH-VIABILITY] Refresh already in progress, telling middleware to wait', { sessionToken: finalSessionToken.substring(0, 8) + '...' });
|
|
26
|
-
return server_1.NextResponse.json({ canRefresh: true, reason: 'refresh_in_progress', refreshInProgress: true, sessionToken: finalSessionToken, twoFactorComplete: sessionData?.mfaVerified ??
|
|
26
|
+
return server_1.NextResponse.json({ canRefresh: true, reason: 'refresh_in_progress', refreshInProgress: true, sessionToken: finalSessionToken, twoFactorComplete: sessionData?.mfaVerified ?? false }, { status: 200 });
|
|
27
27
|
}
|
|
28
28
|
const sessionData = await (0, session_store_1.getSession)(finalSessionToken);
|
|
29
29
|
if (!sessionData) {
|
|
30
30
|
return server_1.NextResponse.json({ canRefresh: false, reason: 'session_not_found', sessionToken: finalSessionToken }, { status: 200 });
|
|
31
31
|
}
|
|
32
32
|
const viabilityCheck = (0, refresh_token_validator_1.checkRefreshViability)(sessionData);
|
|
33
|
-
return server_1.NextResponse.json({ canRefresh: viabilityCheck.canRefresh, reason: viabilityCheck.reason, timeRemaining: viabilityCheck.timeRemaining, expiresAt: viabilityCheck.expiresAt, accessTokenExpired: viabilityCheck.accessTokenExpired, accessTokenTimeRemaining: viabilityCheck.accessTokenTimeRemaining, sessionToken: finalSessionToken, twoFactorComplete: sessionData.mfaVerified ??
|
|
33
|
+
return server_1.NextResponse.json({ canRefresh: viabilityCheck.canRefresh, reason: viabilityCheck.reason, timeRemaining: viabilityCheck.timeRemaining, expiresAt: viabilityCheck.expiresAt, accessTokenExpired: viabilityCheck.accessTokenExpired, accessTokenTimeRemaining: viabilityCheck.accessTokenTimeRemaining, sessionToken: finalSessionToken, twoFactorComplete: sessionData.mfaVerified ?? false, userId: sessionData.userId, refreshInProgress: false });
|
|
34
34
|
}
|
|
35
35
|
catch (error) {
|
|
36
36
|
logger_1.logger.error('[REFRESH-VIABILITY] Error checking refresh viability', { error: error instanceof Error ? error.message : String(error) });
|
|
@@ -106,7 +106,7 @@ async function GET(req) {
|
|
|
106
106
|
const mfaExpiresAt = sessionData.mfaExpiresAt || 0;
|
|
107
107
|
const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
|
|
108
108
|
// Check both field names for compatibility (mfaVerified is the normalized name)
|
|
109
|
-
const sessionMfaComplete = sessionData.mfaVerified ??
|
|
109
|
+
const sessionMfaComplete = sessionData.mfaVerified ?? false;
|
|
110
110
|
const effectiveTwoFactorComplete = sessionMfaComplete && !mfaExpired;
|
|
111
111
|
console.log('[VIABILITY] Session 2FA check:', {
|
|
112
112
|
sessionToken: sessionToken.substring(0, 8) + '...',
|
|
@@ -7,13 +7,18 @@
|
|
|
7
7
|
*/
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
9
|
exports.makeAuthDecision = makeAuthDecision;
|
|
10
|
+
const safe_callback_1 = require("./safe-callback");
|
|
10
11
|
/**
|
|
11
12
|
* Main decision function - pure, testable, deterministic
|
|
12
13
|
*/
|
|
13
14
|
function makeAuthDecision(context) {
|
|
14
15
|
const { pathname, isPublicRoute, isLoginPage, searchParams, sessionPointer, sessionStatus, circuitBreakerOpen } = context;
|
|
15
|
-
// SAFEGUARD: Never use auth pages as callback URLs to prevent redirect loops
|
|
16
|
-
|
|
16
|
+
// SAFEGUARD: Never use auth pages as callback URLs to prevent redirect loops.
|
|
17
|
+
// 375656: `pathname` is our own request path rather than an attacker-supplied
|
|
18
|
+
// parameter, so this site was not the open redirect - but it is routed through
|
|
19
|
+
// the same guard so the package has ONE definition of an acceptable callback,
|
|
20
|
+
// and so the value is parser-normalised before being carried into a redirect.
|
|
21
|
+
const safeCallbackUrl = (0, safe_callback_1.safeCallbackPath)(pathname);
|
|
17
22
|
// Public routes are allowed, but we need to check login page separately
|
|
18
23
|
if (isPublicRoute && !isLoginPage) {
|
|
19
24
|
// For non-login public routes, check if authenticated user needs 2FA
|
|
@@ -104,8 +109,17 @@ function makeAuthDecision(context) {
|
|
|
104
109
|
// If user is on the login page, send them to verify-code with the original callback
|
|
105
110
|
if (isLoginPage) {
|
|
106
111
|
const callbackUrl = searchParams.get('callbackUrl') || '/';
|
|
107
|
-
//
|
|
108
|
-
|
|
112
|
+
// 375656: this previously tested the raw parameter with a prefix check against
|
|
113
|
+
// the auth path, and returned the parameter itself when it did not match.
|
|
114
|
+
// (The vulnerable expression is deliberately NOT reproduced verbatim here: a
|
|
115
|
+
// comment carrying it puts the string into dist, and a reviewer grepping for
|
|
116
|
+
// that pattern would score this FIXED build as vulnerable - the exact
|
|
117
|
+
// sink-counting inversion recorded on 375936.)
|
|
118
|
+
// That is a redirect-LOOP guard in the seat where an ORIGIN check belongs:
|
|
119
|
+
// `https://evil.example.com` does not start with `/account-auth/`, so it passed
|
|
120
|
+
// through untouched. safeCallbackPath() asks the URL parser instead, so every
|
|
121
|
+
// spelling that escapes the origin fails one comparison. See ./safe-callback.
|
|
122
|
+
const safeCallbackUrl = (0, safe_callback_1.safeCallbackPath)(callbackUrl);
|
|
109
123
|
console.log('[MIDDLEWARE-DECISION] On login page with 2FA incomplete, redirecting to verify-code', {
|
|
110
124
|
originalCallback: callbackUrl,
|
|
111
125
|
safeCallback: safeCallbackUrl,
|
|
@@ -148,8 +162,8 @@ function makeAuthDecision(context) {
|
|
|
148
162
|
// For login page, redirect to 2FA with original callback
|
|
149
163
|
if (isLoginPage) {
|
|
150
164
|
const callbackUrl = searchParams.get('callbackUrl') || '/';
|
|
151
|
-
//
|
|
152
|
-
const safeCallbackUrl =
|
|
165
|
+
// 375656: origin-checked, not prefix-checked. See ./safe-callback.
|
|
166
|
+
const safeCallbackUrl = (0, safe_callback_1.safeCallbackPath)(callbackUrl);
|
|
153
167
|
return {
|
|
154
168
|
type: 'redirect',
|
|
155
169
|
location: `/account-auth/verify-code?callbackUrl=${encodeURIComponent(safeCallbackUrl)}`,
|
|
@@ -165,8 +179,13 @@ function makeAuthDecision(context) {
|
|
|
165
179
|
// Authenticated user on login page - redirect to dashboard or callback
|
|
166
180
|
if (isLoginPage) {
|
|
167
181
|
const callbackUrl = searchParams.get('callbackUrl') || '/';
|
|
168
|
-
//
|
|
169
|
-
|
|
182
|
+
// 375656 - THE PUBLISHED OPEN REDIRECT. This value becomes `location` VERBATIM
|
|
183
|
+
// below (not a query parameter on a relative path, as at the two sites above),
|
|
184
|
+
// so the old `startsWith('/account-auth/')` test sent an authenticated user
|
|
185
|
+
// straight to any absolute URL an attacker put in `?callbackUrl=`. Its own
|
|
186
|
+
// comment says "prevents infinite loop", which is what it was written for -
|
|
187
|
+
// loop prevention, never origin validation. See ./safe-callback.
|
|
188
|
+
const safeCallbackUrl = (0, safe_callback_1.safeCallbackPath)(callbackUrl);
|
|
170
189
|
console.log('[MIDDLEWARE-DECISION] Authenticated user on login page, redirecting', {
|
|
171
190
|
originalCallback: callbackUrl,
|
|
172
191
|
safeCallback: safeCallbackUrl
|
package/dist/auth/better-auth.js
CHANGED
|
@@ -77,6 +77,24 @@ const session_store_1 = require("../lib/session-store");
|
|
|
77
77
|
function secretFingerprint(value) {
|
|
78
78
|
return (0, crypto_1.createHash)('sha256').update(value).digest('hex').slice(0, 12);
|
|
79
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* 273183: keys written onto the `ba:{slug}:{token}` record by an author OTHER
|
|
82
|
+
* than Better Auth, which must survive a Better Auth session write.
|
|
83
|
+
*
|
|
84
|
+
* Today this is exactly `idpTokens`, written by `storeIdpTokensInSession`. The
|
|
85
|
+
* list is a named constant rather than a hardcoded check because the shape of
|
|
86
|
+
* the bug is "a second author appeared and the adapter did not know" — the next
|
|
87
|
+
* enrichment key should be added here, not rediscovered as a strip.
|
|
88
|
+
*/
|
|
89
|
+
const PRESERVED_SESSION_KEYS = ['idpTokens'];
|
|
90
|
+
/**
|
|
91
|
+
* True for a JSON object we can safely merge into — not null, not an array.
|
|
92
|
+
* `typeof null === 'object'` and arrays are objects, and merging into either
|
|
93
|
+
* would corrupt the record rather than preserve it.
|
|
94
|
+
*/
|
|
95
|
+
function isPlainObject(value) {
|
|
96
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
97
|
+
}
|
|
80
98
|
/**
|
|
81
99
|
* Build Better Auth social providers from IDP config.
|
|
82
100
|
*/
|
|
@@ -451,7 +469,111 @@ function createBetterAuthInstance(idpConfig, opts = {}) {
|
|
|
451
469
|
*/
|
|
452
470
|
set: async (key, value, ttl) => {
|
|
453
471
|
const redis = (0, redis_1.getRedis)();
|
|
454
|
-
|
|
472
|
+
const redisKey = `ba:${appSlug}:${key}`;
|
|
473
|
+
/**
|
|
474
|
+
* 273183 — READ-MODIFY-WRITE, BECAUSE THIS RECORD HAS TWO AUTHORS.
|
|
475
|
+
* (Found by DotNetPert-Scout; handed over as Cluster-B root-cause
|
|
476
|
+
* evidence under BAPert's R920(a). Reproduced here before fixing.)
|
|
477
|
+
*
|
|
478
|
+
* Better Auth owns `{session, user}` and serialises exactly that. But
|
|
479
|
+
* `storeIdpTokensInSession` (below, ~:1010) ENRICHES the same key with
|
|
480
|
+
* `idpTokens` via its own read-modify-write. This setter did a blind
|
|
481
|
+
* `setex` of Better Auth's payload, so every write it makes — including
|
|
482
|
+
* each routine `updateAge` session rewrite, not merely an expiry —
|
|
483
|
+
* STRIPPED `idpTokens` back off the record.
|
|
484
|
+
*
|
|
485
|
+
* The consumer then falls back to an identity husk: `mfaVerified:false`,
|
|
486
|
+
* `roles:[]`. It fails CLOSED, so it is not a 369782-class bypass — but
|
|
487
|
+
* per BAPert it is a public-test blocker on its own terms, because a
|
|
488
|
+
* tester who trips the `updateAge` cadence gets a spurious 2FA re-prompt
|
|
489
|
+
* AND SILENTLY LOSES THEIR ROLES mid-session. That is a visible product
|
|
490
|
+
* failure regardless of which direction it fails in.
|
|
491
|
+
*
|
|
492
|
+
* WHY MERGE HERE RATHER THAN MOVE THE DATA. The enriching writer already
|
|
493
|
+
* does a read-modify-write; it was correct. The bug is that ONE of the
|
|
494
|
+
* two authors of this key wrote as though it were the only one. Fixing
|
|
495
|
+
* the adapter fixes it for every future enrichment key, whereas moving
|
|
496
|
+
* `idpTokens` elsewhere would leave the next one to rediscover this.
|
|
497
|
+
*
|
|
498
|
+
* ONLY keys in PRESERVED_KEYS are carried, and ONLY when the incoming
|
|
499
|
+
* payload does not set them — so Better Auth still wins on everything it
|
|
500
|
+
* owns, and an explicit incoming value is never overridden by a stale one.
|
|
501
|
+
*
|
|
502
|
+
* NOT ATOMIC, and deliberately not: an enrichment landing between this
|
|
503
|
+
* read and this write is still lost. Closing that needs a WATCH/MULTI or
|
|
504
|
+
* a Lua script, which is a larger change than tonight allows. This
|
|
505
|
+
* narrows the window from "every single write" to "a genuine race", and
|
|
506
|
+
* the residual is recorded here rather than left to be re-found.
|
|
507
|
+
*/
|
|
508
|
+
let payload = value;
|
|
509
|
+
try {
|
|
510
|
+
const existingRaw = await redis.get(redisKey);
|
|
511
|
+
if (existingRaw) {
|
|
512
|
+
const existing = JSON.parse(existingRaw);
|
|
513
|
+
const incoming = JSON.parse(value);
|
|
514
|
+
if (isPlainObject(existing) && isPlainObject(incoming)) {
|
|
515
|
+
let carried = false;
|
|
516
|
+
for (const preserved of PRESERVED_SESSION_KEYS) {
|
|
517
|
+
if (preserved in existing && !(preserved in incoming)) {
|
|
518
|
+
incoming[preserved] = existing[preserved];
|
|
519
|
+
carried = true;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
if (carried)
|
|
523
|
+
payload = JSON.stringify(incoming);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
catch (mergeErr) {
|
|
528
|
+
/**
|
|
529
|
+
* This adapter also stores `verification:*` and `active-sessions-*`
|
|
530
|
+
* records, which are not necessarily session-shaped JSON. A record we
|
|
531
|
+
* cannot parse is not a session to enrich, so writing through unchanged
|
|
532
|
+
* is correct rather than merely tolerable.
|
|
533
|
+
*
|
|
534
|
+
* WARNED, NOT THROWN, and the asymmetry is the point: this block is an
|
|
535
|
+
* ENRICHMENT-PRESERVING read. Letting it fail the write would convert a
|
|
536
|
+
* degraded read into a lost session — the precise inversion 170033 fix 1
|
|
537
|
+
* above exists to prevent. The `setex` below is outside this try and is
|
|
538
|
+
* still unswallowed, so a genuine write failure still propagates.
|
|
539
|
+
*/
|
|
540
|
+
console.warn(`[BETTER_AUTH] secondaryStorage.set: could not preserve enrichment keys on ` +
|
|
541
|
+
`ba:${appSlug}:sha256:${secretFingerprint(key)} — writing Better Auth's payload ` +
|
|
542
|
+
`as-is. If this record held idpTokens they are now gone (273183):`, mergeErr instanceof Error ? mergeErr.message : String(mergeErr));
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* QA 6952 / R913 - CLAMP, DO NOT DEFER.
|
|
546
|
+
*
|
|
547
|
+
* This was `ttl ?? SESSION_TTL`, which READS as "SESSION_TTL is the single
|
|
548
|
+
* source of session TTL" - what session-store.ts claims, and the mechanism
|
|
549
|
+
* by which the 7d-vs-3d zombie window was supposedly closed for good.
|
|
550
|
+
*
|
|
551
|
+
* IT WAS DEAD CODE. Better Auth ALWAYS supplies a ttl: every one of the
|
|
552
|
+
* eight secondaryStorage.set call sites in
|
|
553
|
+
* better-auth/dist/db/internal-adapter.mjs passes one explicitly (:36,
|
|
554
|
+
* :180, :189, :309, :320, :344, :553, :622). `??` fires only on
|
|
555
|
+
* null/undefined, so the fallback NEVER RAN. Every `ba:` record took Better
|
|
556
|
+
* Auth's 7d default - `session.expiresIn` is not configured in this file -
|
|
557
|
+
* while the canonical store stayed at SESSION_TTL (3d). That 4-day drift IS
|
|
558
|
+
* the zombie window the constant exists to prevent: the guard was bypassed
|
|
559
|
+
* by the very parameter written to defer to it.
|
|
560
|
+
*
|
|
561
|
+
* `Math.min`, NOT a hard SESSION_TTL: this same adapter also stores
|
|
562
|
+
* `verification:*` (:553, :622) and `active-sessions-*` (:180, :320, :344)
|
|
563
|
+
* records whose TTLs are legitimately SHORTER than 3d. Overwriting those
|
|
564
|
+
* would EXTEND them - turning a fix for over-long sessions into a bug that
|
|
565
|
+
* keeps verification codes alive for three days. Clamp DOWN, never up.
|
|
566
|
+
*
|
|
567
|
+
* Clamped down to 3d rather than raising the canonical store to 7d because
|
|
568
|
+
* SESSION_TTL matches the IDP refresh-token lifetime: a session outliving
|
|
569
|
+
* the refresh token sustaining it cannot be refreshed, so the extra days are
|
|
570
|
+
* a husk that reads as authenticated. Fail-closed beats ambiguous.
|
|
571
|
+
*
|
|
572
|
+
* DO NOT "fix" this by making storeIdpTokensInSession preserve Better
|
|
573
|
+
* Auth's 7d. That deletes the last remnant of QA 6952 and makes the drift
|
|
574
|
+
* universal. See test/ba-session-ttl-clamp.test.cjs.
|
|
575
|
+
*/
|
|
576
|
+
await redis.setex(redisKey, Math.min(ttl ?? session_store_1.SESSION_TTL, session_store_1.SESSION_TTL), payload);
|
|
455
577
|
},
|
|
456
578
|
delete: async (key) => {
|
|
457
579
|
try {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE definition of "is this callback target safe to navigate to", for the
|
|
3
|
+
* whole package (375656).
|
|
4
|
+
*
|
|
5
|
+
* *** THIS DELEGATES PARSING TO THE URL PARSER. DO NOT REPLACE IT WITH
|
|
6
|
+
* *** startsWith() CHECKS. THAT APPROACH FAILED FOUR TIMES.
|
|
7
|
+
*
|
|
8
|
+
* WHY THIS FILE EXISTS: `@payez/next-mvp` shipped THREE bodies named
|
|
9
|
+
* `makeAuthDecision`, each carrying its own `callbackUrl.startsWith('/account-auth/')`
|
|
10
|
+
* test. That test is a REDIRECT-LOOP guard sitting in the seat where an ORIGIN
|
|
11
|
+
* check belongs - its own comment says "prevents infinite loop" - so
|
|
12
|
+
* `https://evil.example.com` does not start with `/account-auth/`, passes
|
|
13
|
+
* through untouched, and becomes the redirect target. The exported copy
|
|
14
|
+
* (`auth/auth-decision.ts`, re-exported from `index.ts` and `edge/middleware.ts`)
|
|
15
|
+
* is public API, so that is a published open redirect independent of who calls it.
|
|
16
|
+
*
|
|
17
|
+
* HISTORY, because it is the whole argument for the current shape (267097 /
|
|
18
|
+
* 267174 / 267448), carried over verbatim from the portal's `src/lib/safe-callback.ts`
|
|
19
|
+
* where this guard was proven (0 leaks / 21 hostile inputs):
|
|
20
|
+
*
|
|
21
|
+
* v1 `startsWith('/') && !startsWith('//')` -> bypassed by /BACKSLASH host
|
|
22
|
+
* v2 + `!startsWith('/' + BACKSLASH)` -> bypassed by /TAB/host
|
|
23
|
+
* ... and /LF/host, /CR/host
|
|
24
|
+
* v3 resolve once + compare ORIGIN -> bypassed by /..//host
|
|
25
|
+
* and /.//host, /%2e%2e//host
|
|
26
|
+
* v4 + require the RESULT to be a FIXED POINT <- current
|
|
27
|
+
*
|
|
28
|
+
* *** DRIFT, 2026-09-11 (325543). THIS FILE WAS v3 WHILE THE PORTAL'S COPY WAS
|
|
29
|
+
* *** v4, AND THE PACKAGE COPY IS THE ONE THAT SHIPPED. The portal fixed its own
|
|
30
|
+
* *** src/lib/safe-callback.ts (e1f08fea) and deployed; the served bundle STILL
|
|
31
|
+
* *** escaped 5/5 in a real browser, because `src/contexts/ThemeContext.tsx`
|
|
32
|
+
* *** imports the BARE BARREL `@payez/next-mvp`, which pulls dist/index.js ->
|
|
33
|
+
* *** auth/auth-decision -> THIS FILE into the client graph, and because the
|
|
34
|
+
* *** portal's `src/middleware.ts` imports `@payez/next-mvp/edge`, which requires
|
|
35
|
+
* *** THIS FILE server-side on every request. It was read as a stale build
|
|
36
|
+
* *** artifact of the portal's file for most of a day. It was not. It was a
|
|
37
|
+
* *** SECOND DEFINITION, and no rebuild of the portal could have removed it.
|
|
38
|
+
* *** The header below says "ONE definition ... for the whole package" and that
|
|
39
|
+
* *** was true INSIDE this package boundary the entire time. The defect lived in
|
|
40
|
+
* *** the gap BETWEEN two boundaries that each enforced one-definition correctly.
|
|
41
|
+
* *** A guard that exists in N copies is only as strong as its weakest copy, and
|
|
42
|
+
* *** a per-repo grep gate cannot see N. IF YOU CHANGE THE PREDICATE HERE, CHANGE
|
|
43
|
+
* *** IT IN THE PORTAL'S src/lib/safe-callback.ts IN THE SAME HOUR, OR DELETE ONE
|
|
44
|
+
* *** OF THEM.
|
|
45
|
+
*
|
|
46
|
+
* Both bypasses work the same way: the browser NORMALISES the string AFTER the
|
|
47
|
+
* guard has finished looking at it. The WHATWG URL parser converts backslashes
|
|
48
|
+
* to forward slashes and STRIPS all ASCII tab, LF and CR before resolving. So a
|
|
49
|
+
* prefix check can always be defeated by a character that is not there yet when
|
|
50
|
+
* it runs. Each fix bought one character. The list was never provably complete.
|
|
51
|
+
*
|
|
52
|
+
* SO: stop re-implementing the parser and ask it instead. Resolve the input
|
|
53
|
+
* against a synthetic base and compare ORIGINS. Anything that escapes the
|
|
54
|
+
* origin - however it is spelled, including spellings nobody has found yet -
|
|
55
|
+
* fails one comparison. The returned value is the parser's own NORMALISED
|
|
56
|
+
* output, so an accepted path cannot smuggle a control character onward either.
|
|
57
|
+
*
|
|
58
|
+
* A synthetic base is used deliberately so this works identically in a server
|
|
59
|
+
* component (no window), on the client, and in the edge runtime.
|
|
60
|
+
*/
|
|
61
|
+
/** Where we send anyone whose requested target we will not honour. */
|
|
62
|
+
export declare const DEFAULT_CALLBACK = "/";
|
|
63
|
+
/**
|
|
64
|
+
* The value to navigate to: a same-origin relative path, NORMALISED by the URL
|
|
65
|
+
* parser, or `fallback` for anything that escapes the origin or loops back into
|
|
66
|
+
* the auth surface. Never throws.
|
|
67
|
+
*/
|
|
68
|
+
export declare function safeCallbackPath(raw: unknown, fallback?: string): string;
|
|
69
|
+
/**
|
|
70
|
+
* True when `raw` is a callback target we are willing to honour.
|
|
71
|
+
*
|
|
72
|
+
* DEFINED IN TERMS OF `safeCallbackPath` ON PURPOSE - it used to carry its own
|
|
73
|
+
* copy of the predicate, and that copy was missing the second resolution above
|
|
74
|
+
* while the two were maintained separately. This file's whole history is
|
|
75
|
+
* duplicated predicates drifting apart (see the header). One definition, one
|
|
76
|
+
* place to fix.
|
|
77
|
+
*/
|
|
78
|
+
export declare function isSafeCallbackPath(raw: unknown): raw is string;
|
|
79
|
+
/**
|
|
80
|
+
* Pick the post-login destination: an explicit `?callbackUrl=` when it is one we
|
|
81
|
+
* will honour, otherwise the current path, otherwise `/`.
|
|
82
|
+
*
|
|
83
|
+
* This replaces the package's three hand-rolled `getSafeCallbackUrl` /
|
|
84
|
+
* `getCallbackUrl` bodies. Note the ARGUMENT ORDER DEPENDENCY those had: they
|
|
85
|
+
* tested `pathname` FIRST and returned early, so a caller on the login page
|
|
86
|
+
* never reached the attacker-controlled parameter at all - safe, but only
|
|
87
|
+
* because that caller happened to pass a value that tripped the function's own
|
|
88
|
+
* first guard. Nothing in the code named that dependency, and an argument
|
|
89
|
+
* reorder would have opened it. Here BOTH inputs are validated, so the
|
|
90
|
+
* protection no longer depends on which one is passed.
|
|
91
|
+
*/
|
|
92
|
+
export declare function resolveCallbackDestination(pathname: string, searchParams?: URLSearchParams | null, fallback?: string): string;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* ONE definition of "is this callback target safe to navigate to", for the
|
|
4
|
+
* whole package (375656).
|
|
5
|
+
*
|
|
6
|
+
* *** THIS DELEGATES PARSING TO THE URL PARSER. DO NOT REPLACE IT WITH
|
|
7
|
+
* *** startsWith() CHECKS. THAT APPROACH FAILED FOUR TIMES.
|
|
8
|
+
*
|
|
9
|
+
* WHY THIS FILE EXISTS: `@payez/next-mvp` shipped THREE bodies named
|
|
10
|
+
* `makeAuthDecision`, each carrying its own `callbackUrl.startsWith('/account-auth/')`
|
|
11
|
+
* test. That test is a REDIRECT-LOOP guard sitting in the seat where an ORIGIN
|
|
12
|
+
* check belongs - its own comment says "prevents infinite loop" - so
|
|
13
|
+
* `https://evil.example.com` does not start with `/account-auth/`, passes
|
|
14
|
+
* through untouched, and becomes the redirect target. The exported copy
|
|
15
|
+
* (`auth/auth-decision.ts`, re-exported from `index.ts` and `edge/middleware.ts`)
|
|
16
|
+
* is public API, so that is a published open redirect independent of who calls it.
|
|
17
|
+
*
|
|
18
|
+
* HISTORY, because it is the whole argument for the current shape (267097 /
|
|
19
|
+
* 267174 / 267448), carried over verbatim from the portal's `src/lib/safe-callback.ts`
|
|
20
|
+
* where this guard was proven (0 leaks / 21 hostile inputs):
|
|
21
|
+
*
|
|
22
|
+
* v1 `startsWith('/') && !startsWith('//')` -> bypassed by /BACKSLASH host
|
|
23
|
+
* v2 + `!startsWith('/' + BACKSLASH)` -> bypassed by /TAB/host
|
|
24
|
+
* ... and /LF/host, /CR/host
|
|
25
|
+
* v3 resolve once + compare ORIGIN -> bypassed by /..//host
|
|
26
|
+
* and /.//host, /%2e%2e//host
|
|
27
|
+
* v4 + require the RESULT to be a FIXED POINT <- current
|
|
28
|
+
*
|
|
29
|
+
* *** DRIFT, 2026-09-11 (325543). THIS FILE WAS v3 WHILE THE PORTAL'S COPY WAS
|
|
30
|
+
* *** v4, AND THE PACKAGE COPY IS THE ONE THAT SHIPPED. The portal fixed its own
|
|
31
|
+
* *** src/lib/safe-callback.ts (e1f08fea) and deployed; the served bundle STILL
|
|
32
|
+
* *** escaped 5/5 in a real browser, because `src/contexts/ThemeContext.tsx`
|
|
33
|
+
* *** imports the BARE BARREL `@payez/next-mvp`, which pulls dist/index.js ->
|
|
34
|
+
* *** auth/auth-decision -> THIS FILE into the client graph, and because the
|
|
35
|
+
* *** portal's `src/middleware.ts` imports `@payez/next-mvp/edge`, which requires
|
|
36
|
+
* *** THIS FILE server-side on every request. It was read as a stale build
|
|
37
|
+
* *** artifact of the portal's file for most of a day. It was not. It was a
|
|
38
|
+
* *** SECOND DEFINITION, and no rebuild of the portal could have removed it.
|
|
39
|
+
* *** The header below says "ONE definition ... for the whole package" and that
|
|
40
|
+
* *** was true INSIDE this package boundary the entire time. The defect lived in
|
|
41
|
+
* *** the gap BETWEEN two boundaries that each enforced one-definition correctly.
|
|
42
|
+
* *** A guard that exists in N copies is only as strong as its weakest copy, and
|
|
43
|
+
* *** a per-repo grep gate cannot see N. IF YOU CHANGE THE PREDICATE HERE, CHANGE
|
|
44
|
+
* *** IT IN THE PORTAL'S src/lib/safe-callback.ts IN THE SAME HOUR, OR DELETE ONE
|
|
45
|
+
* *** OF THEM.
|
|
46
|
+
*
|
|
47
|
+
* Both bypasses work the same way: the browser NORMALISES the string AFTER the
|
|
48
|
+
* guard has finished looking at it. The WHATWG URL parser converts backslashes
|
|
49
|
+
* to forward slashes and STRIPS all ASCII tab, LF and CR before resolving. So a
|
|
50
|
+
* prefix check can always be defeated by a character that is not there yet when
|
|
51
|
+
* it runs. Each fix bought one character. The list was never provably complete.
|
|
52
|
+
*
|
|
53
|
+
* SO: stop re-implementing the parser and ask it instead. Resolve the input
|
|
54
|
+
* against a synthetic base and compare ORIGINS. Anything that escapes the
|
|
55
|
+
* origin - however it is spelled, including spellings nobody has found yet -
|
|
56
|
+
* fails one comparison. The returned value is the parser's own NORMALISED
|
|
57
|
+
* output, so an accepted path cannot smuggle a control character onward either.
|
|
58
|
+
*
|
|
59
|
+
* A synthetic base is used deliberately so this works identically in a server
|
|
60
|
+
* component (no window), on the client, and in the edge runtime.
|
|
61
|
+
*/
|
|
62
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
63
|
+
exports.DEFAULT_CALLBACK = void 0;
|
|
64
|
+
exports.safeCallbackPath = safeCallbackPath;
|
|
65
|
+
exports.isSafeCallbackPath = isSafeCallbackPath;
|
|
66
|
+
exports.resolveCallbackDestination = resolveCallbackDestination;
|
|
67
|
+
/** Where we send anyone whose requested target we will not honour. */
|
|
68
|
+
exports.DEFAULT_CALLBACK = '/';
|
|
69
|
+
/** The auth surface. A callback pointing back into it causes a redirect cycle. */
|
|
70
|
+
const AUTH_PREFIX = '/account-auth/';
|
|
71
|
+
/**
|
|
72
|
+
* Synthetic, unreachable base. Only ever used as the resolution origin for the
|
|
73
|
+
* comparison below - never emitted, never navigated to. `.invalid` is reserved
|
|
74
|
+
* by RFC 2606 and can never resolve.
|
|
75
|
+
*/
|
|
76
|
+
const BASE = 'http://safe-callback.invalid';
|
|
77
|
+
/**
|
|
78
|
+
* The value to navigate to: a same-origin relative path, NORMALISED by the URL
|
|
79
|
+
* parser, or `fallback` for anything that escapes the origin or loops back into
|
|
80
|
+
* the auth surface. Never throws.
|
|
81
|
+
*/
|
|
82
|
+
function safeCallbackPath(raw, fallback = exports.DEFAULT_CALLBACK) {
|
|
83
|
+
if (typeof raw !== 'string' || raw.length === 0)
|
|
84
|
+
return fallback;
|
|
85
|
+
let url;
|
|
86
|
+
try {
|
|
87
|
+
url = new URL(raw, BASE);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return fallback;
|
|
91
|
+
}
|
|
92
|
+
// Anything that resolved to a different origin escaped, however it was spelled:
|
|
93
|
+
// absolute, protocol-relative //host, backslash /\host, tab/LF/CR variants, or
|
|
94
|
+
// a scheme like javascript: (whose origin is opaque and never equals BASE).
|
|
95
|
+
if (url.origin !== BASE)
|
|
96
|
+
return fallback;
|
|
97
|
+
// Loopbacks into the auth surface cause redirect cycles.
|
|
98
|
+
if (url.pathname.startsWith(AUTH_PREFIX))
|
|
99
|
+
return fallback;
|
|
100
|
+
// The parser's normalised output - control characters already stripped.
|
|
101
|
+
const candidate = url.pathname + url.search + url.hash;
|
|
102
|
+
// *** SECOND RESOLUTION. DO NOT DELETE THIS AS REDUNDANT - IT IS NOT. ***
|
|
103
|
+
//
|
|
104
|
+
// v3 (the origin comparison above) was bypassed too, and differently from v1/v2.
|
|
105
|
+
// The origin check constrains where `raw` RESOLVED TO. It does not constrain how
|
|
106
|
+
// the RESULT IS SPELLED - and the result is resolved a SECOND time, by whoever
|
|
107
|
+
// navigates to it. A returned path that begins with `//` is a PROTOCOL-RELATIVE
|
|
108
|
+
// URL at that second resolution, so it lands off-origin having passed every
|
|
109
|
+
// check here:
|
|
110
|
+
//
|
|
111
|
+
// '/..//evil.example.com' -> pathname '//evil.example.com' -> evil.example.com
|
|
112
|
+
// '/.//evil.example.com' -> pathname '//evil.example.com' -> evil.example.com
|
|
113
|
+
// '/%2e%2e//evil.example.com' -> pathname '//evil.example.com' -> evil.example.com
|
|
114
|
+
//
|
|
115
|
+
// The parser resolves `..` against the synthetic base and, at the root, leaves
|
|
116
|
+
// behind the EMPTY SEGMENT rather than collapsing it. Origin stayed BASE the
|
|
117
|
+
// whole time, so nothing upstream fired.
|
|
118
|
+
//
|
|
119
|
+
// MEASURED, not reasoned: in a real headless Chromium on the live origin
|
|
120
|
+
// https://portal.keelauth.com, 5 spellings resolved to https://evil.example.com
|
|
121
|
+
// (QAPert-NightHawk, 2026-09-11, card 325543), and on 2026-09-11 the SERVED
|
|
122
|
+
// BUNDLE was measured escaping 5/5 through THIS PACKAGE'S copy while the
|
|
123
|
+
// portal's own src/lib/safe-callback.ts was already fixed - the two copies had
|
|
124
|
+
// drifted. See the DRIFT note in this file's header.
|
|
125
|
+
//
|
|
126
|
+
// THE FIX FOLLOWS THIS FILE'S OWN DOCTRINE: do not pattern-match the spellings
|
|
127
|
+
// (that is the startsWith() trap that failed four times, and this would be the
|
|
128
|
+
// fifth). Ask the parser AGAIN about the exact value we are about to hand out,
|
|
129
|
+
// and require it to be a FIXED POINT. Anything whose meaning changes when it is
|
|
130
|
+
// resolved a second time is by definition not a stable same-origin path, however
|
|
131
|
+
// it is spelled - including spellings nobody has found yet.
|
|
132
|
+
let echo;
|
|
133
|
+
try {
|
|
134
|
+
echo = new URL(candidate, BASE);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return fallback;
|
|
138
|
+
}
|
|
139
|
+
if (echo.origin !== BASE)
|
|
140
|
+
return fallback;
|
|
141
|
+
if (echo.pathname.startsWith(AUTH_PREFIX))
|
|
142
|
+
return fallback;
|
|
143
|
+
if (echo.pathname + echo.search + echo.hash !== candidate)
|
|
144
|
+
return fallback;
|
|
145
|
+
return candidate;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Sentinel that `safeCallbackPath` can never legitimately return: it is not a
|
|
149
|
+
* fixed point of the URL parser (the parser would percent-encode the space), so
|
|
150
|
+
* it cannot collide with a genuine accepted value.
|
|
151
|
+
*/
|
|
152
|
+
const REJECTED = '\u0000rejected';
|
|
153
|
+
/**
|
|
154
|
+
* True when `raw` is a callback target we are willing to honour.
|
|
155
|
+
*
|
|
156
|
+
* DEFINED IN TERMS OF `safeCallbackPath` ON PURPOSE - it used to carry its own
|
|
157
|
+
* copy of the predicate, and that copy was missing the second resolution above
|
|
158
|
+
* while the two were maintained separately. This file's whole history is
|
|
159
|
+
* duplicated predicates drifting apart (see the header). One definition, one
|
|
160
|
+
* place to fix.
|
|
161
|
+
*/
|
|
162
|
+
function isSafeCallbackPath(raw) {
|
|
163
|
+
return typeof raw === 'string' && raw.length > 0 && safeCallbackPath(raw, REJECTED) !== REJECTED;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Pick the post-login destination: an explicit `?callbackUrl=` when it is one we
|
|
167
|
+
* will honour, otherwise the current path, otherwise `/`.
|
|
168
|
+
*
|
|
169
|
+
* This replaces the package's three hand-rolled `getSafeCallbackUrl` /
|
|
170
|
+
* `getCallbackUrl` bodies. Note the ARGUMENT ORDER DEPENDENCY those had: they
|
|
171
|
+
* tested `pathname` FIRST and returned early, so a caller on the login page
|
|
172
|
+
* never reached the attacker-controlled parameter at all - safe, but only
|
|
173
|
+
* because that caller happened to pass a value that tripped the function's own
|
|
174
|
+
* first guard. Nothing in the code named that dependency, and an argument
|
|
175
|
+
* reorder would have opened it. Here BOTH inputs are validated, so the
|
|
176
|
+
* protection no longer depends on which one is passed.
|
|
177
|
+
*/
|
|
178
|
+
function resolveCallbackDestination(pathname, searchParams, fallback = exports.DEFAULT_CALLBACK) {
|
|
179
|
+
const requested = searchParams?.get('callbackUrl');
|
|
180
|
+
if (isSafeCallbackPath(requested))
|
|
181
|
+
return safeCallbackPath(requested, fallback);
|
|
182
|
+
return safeCallbackPath(pathname, fallback);
|
|
183
|
+
}
|
|
@@ -9,7 +9,6 @@ export type ProfileResponse = {
|
|
|
9
9
|
phone_confirmed?: boolean;
|
|
10
10
|
two_factor_enabled?: boolean;
|
|
11
11
|
roles?: string[];
|
|
12
|
-
contact_information?: Record<string, any>;
|
|
13
12
|
[key: string]: any;
|
|
14
13
|
};
|
|
15
14
|
export declare function useProfile(): import("@tanstack/react-query").UseQueryResult<ProfileResponse, Error>;
|