@payez/next-mvp 4.4.1 → 4.4.2
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/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 +63 -0
- package/dist/auth/safe-callback.js +112 -0
- package/dist/hooks/useProfile.d.ts +0 -1
- 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 +1 -1
- 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 +113 -0
- package/src/hooks/useProfile.ts +8 -1
- 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
|
@@ -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,63 @@
|
|
|
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
|
+
*
|
|
25
|
+
* Both bypasses work the same way: the browser NORMALISES the string AFTER the
|
|
26
|
+
* guard has finished looking at it. The WHATWG URL parser converts backslashes
|
|
27
|
+
* to forward slashes and STRIPS all ASCII tab, LF and CR before resolving. So a
|
|
28
|
+
* prefix check can always be defeated by a character that is not there yet when
|
|
29
|
+
* it runs. Each fix bought one character. The list was never provably complete.
|
|
30
|
+
*
|
|
31
|
+
* SO: stop re-implementing the parser and ask it instead. Resolve the input
|
|
32
|
+
* against a synthetic base and compare ORIGINS. Anything that escapes the
|
|
33
|
+
* origin - however it is spelled, including spellings nobody has found yet -
|
|
34
|
+
* fails one comparison. The returned value is the parser's own NORMALISED
|
|
35
|
+
* output, so an accepted path cannot smuggle a control character onward either.
|
|
36
|
+
*
|
|
37
|
+
* A synthetic base is used deliberately so this works identically in a server
|
|
38
|
+
* component (no window), on the client, and in the edge runtime.
|
|
39
|
+
*/
|
|
40
|
+
/** Where we send anyone whose requested target we will not honour. */
|
|
41
|
+
export declare const DEFAULT_CALLBACK = "/";
|
|
42
|
+
/**
|
|
43
|
+
* The value to navigate to: a same-origin relative path, NORMALISED by the URL
|
|
44
|
+
* parser, or `fallback` for anything that escapes the origin or loops back into
|
|
45
|
+
* the auth surface. Never throws.
|
|
46
|
+
*/
|
|
47
|
+
export declare function safeCallbackPath(raw: unknown, fallback?: string): string;
|
|
48
|
+
/** True when `raw` is a callback target we are willing to honour. */
|
|
49
|
+
export declare function isSafeCallbackPath(raw: unknown): raw is string;
|
|
50
|
+
/**
|
|
51
|
+
* Pick the post-login destination: an explicit `?callbackUrl=` when it is one we
|
|
52
|
+
* will honour, otherwise the current path, otherwise `/`.
|
|
53
|
+
*
|
|
54
|
+
* This replaces the package's three hand-rolled `getSafeCallbackUrl` /
|
|
55
|
+
* `getCallbackUrl` bodies. Note the ARGUMENT ORDER DEPENDENCY those had: they
|
|
56
|
+
* tested `pathname` FIRST and returned early, so a caller on the login page
|
|
57
|
+
* never reached the attacker-controlled parameter at all - safe, but only
|
|
58
|
+
* because that caller happened to pass a value that tripped the function's own
|
|
59
|
+
* first guard. Nothing in the code named that dependency, and an argument
|
|
60
|
+
* reorder would have opened it. Here BOTH inputs are validated, so the
|
|
61
|
+
* protection no longer depends on which one is passed.
|
|
62
|
+
*/
|
|
63
|
+
export declare function resolveCallbackDestination(pathname: string, searchParams?: URLSearchParams | null, fallback?: string): string;
|
|
@@ -0,0 +1,112 @@
|
|
|
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
|
+
*
|
|
26
|
+
* Both bypasses work the same way: the browser NORMALISES the string AFTER the
|
|
27
|
+
* guard has finished looking at it. The WHATWG URL parser converts backslashes
|
|
28
|
+
* to forward slashes and STRIPS all ASCII tab, LF and CR before resolving. So a
|
|
29
|
+
* prefix check can always be defeated by a character that is not there yet when
|
|
30
|
+
* it runs. Each fix bought one character. The list was never provably complete.
|
|
31
|
+
*
|
|
32
|
+
* SO: stop re-implementing the parser and ask it instead. Resolve the input
|
|
33
|
+
* against a synthetic base and compare ORIGINS. Anything that escapes the
|
|
34
|
+
* origin - however it is spelled, including spellings nobody has found yet -
|
|
35
|
+
* fails one comparison. The returned value is the parser's own NORMALISED
|
|
36
|
+
* output, so an accepted path cannot smuggle a control character onward either.
|
|
37
|
+
*
|
|
38
|
+
* A synthetic base is used deliberately so this works identically in a server
|
|
39
|
+
* component (no window), on the client, and in the edge runtime.
|
|
40
|
+
*/
|
|
41
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
|
+
exports.DEFAULT_CALLBACK = void 0;
|
|
43
|
+
exports.safeCallbackPath = safeCallbackPath;
|
|
44
|
+
exports.isSafeCallbackPath = isSafeCallbackPath;
|
|
45
|
+
exports.resolveCallbackDestination = resolveCallbackDestination;
|
|
46
|
+
/** Where we send anyone whose requested target we will not honour. */
|
|
47
|
+
exports.DEFAULT_CALLBACK = '/';
|
|
48
|
+
/** The auth surface. A callback pointing back into it causes a redirect cycle. */
|
|
49
|
+
const AUTH_PREFIX = '/account-auth/';
|
|
50
|
+
/**
|
|
51
|
+
* Synthetic, unreachable base. Only ever used as the resolution origin for the
|
|
52
|
+
* comparison below - never emitted, never navigated to. `.invalid` is reserved
|
|
53
|
+
* by RFC 2606 and can never resolve.
|
|
54
|
+
*/
|
|
55
|
+
const BASE = 'http://safe-callback.invalid';
|
|
56
|
+
/**
|
|
57
|
+
* The value to navigate to: a same-origin relative path, NORMALISED by the URL
|
|
58
|
+
* parser, or `fallback` for anything that escapes the origin or loops back into
|
|
59
|
+
* the auth surface. Never throws.
|
|
60
|
+
*/
|
|
61
|
+
function safeCallbackPath(raw, fallback = exports.DEFAULT_CALLBACK) {
|
|
62
|
+
if (typeof raw !== 'string' || raw.length === 0)
|
|
63
|
+
return fallback;
|
|
64
|
+
let url;
|
|
65
|
+
try {
|
|
66
|
+
url = new URL(raw, BASE);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return fallback;
|
|
70
|
+
}
|
|
71
|
+
// Anything that resolved to a different origin escaped, however it was spelled:
|
|
72
|
+
// absolute, protocol-relative //host, backslash /\host, tab/LF/CR variants, or
|
|
73
|
+
// a scheme like javascript: (whose origin is opaque and never equals BASE).
|
|
74
|
+
if (url.origin !== BASE)
|
|
75
|
+
return fallback;
|
|
76
|
+
// Loopbacks into the auth surface cause redirect cycles.
|
|
77
|
+
if (url.pathname.startsWith(AUTH_PREFIX))
|
|
78
|
+
return fallback;
|
|
79
|
+
// The parser's normalised output - control characters already stripped.
|
|
80
|
+
return url.pathname + url.search + url.hash;
|
|
81
|
+
}
|
|
82
|
+
/** True when `raw` is a callback target we are willing to honour. */
|
|
83
|
+
function isSafeCallbackPath(raw) {
|
|
84
|
+
if (typeof raw !== 'string' || raw.length === 0)
|
|
85
|
+
return false;
|
|
86
|
+
try {
|
|
87
|
+
const url = new URL(raw, BASE);
|
|
88
|
+
return url.origin === BASE && !url.pathname.startsWith(AUTH_PREFIX);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Pick the post-login destination: an explicit `?callbackUrl=` when it is one we
|
|
96
|
+
* will honour, otherwise the current path, otherwise `/`.
|
|
97
|
+
*
|
|
98
|
+
* This replaces the package's three hand-rolled `getSafeCallbackUrl` /
|
|
99
|
+
* `getCallbackUrl` bodies. Note the ARGUMENT ORDER DEPENDENCY those had: they
|
|
100
|
+
* tested `pathname` FIRST and returned early, so a caller on the login page
|
|
101
|
+
* never reached the attacker-controlled parameter at all - safe, but only
|
|
102
|
+
* because that caller happened to pass a value that tripped the function's own
|
|
103
|
+
* first guard. Nothing in the code named that dependency, and an argument
|
|
104
|
+
* reorder would have opened it. Here BOTH inputs are validated, so the
|
|
105
|
+
* protection no longer depends on which one is passed.
|
|
106
|
+
*/
|
|
107
|
+
function resolveCallbackDestination(pathname, searchParams, fallback = exports.DEFAULT_CALLBACK) {
|
|
108
|
+
const requested = searchParams?.get('callbackUrl');
|
|
109
|
+
if (isSafeCallbackPath(requested))
|
|
110
|
+
return safeCallbackPath(requested, fallback);
|
|
111
|
+
return safeCallbackPath(pathname, fallback);
|
|
112
|
+
}
|
|
@@ -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>;
|
|
@@ -389,6 +389,26 @@ async function transitionTo2FASession(sessionToken, tokens, mfaMethod) {
|
|
|
389
389
|
console.log('[transitionTo2FASession] Extracted bearerKeyId (kid) from new JWT header:', bearerKeyId);
|
|
390
390
|
}
|
|
391
391
|
}
|
|
392
|
+
// 339563: derive the expiry ONCE - it is both a field to write and the
|
|
393
|
+
// evidence test below. Re-deriving it twice invites the two reads to drift.
|
|
394
|
+
const mfaLifetime = mfaLifetimeFromAccessToken(newAccessToken);
|
|
395
|
+
// *** R908(e). ABSENCE OF THE EXPIRY IS NOT A COMPLETED CEREMONY. ***
|
|
396
|
+
//
|
|
397
|
+
// `mfaExpiresAt` is the only evidence we hold that a real MFA ceremony took
|
|
398
|
+
// place and when it lapses. When the stepped-up token carries no `mfa_expires`
|
|
399
|
+
// claim we do NOT know that this session is MFA-verified, and R908(e) binds
|
|
400
|
+
// the consumer to the third arm:
|
|
401
|
+
//
|
|
402
|
+
// producers degrade -> consumers RE-CHALLENGE -> nobody infers a
|
|
403
|
+
// negative from a silence.
|
|
404
|
+
//
|
|
405
|
+
// So: never mint `mfaVerified: true` off a token that carries no expiry (that
|
|
406
|
+
// is forging evidence of a ceremony we did not observe - 12188/12390), and
|
|
407
|
+
// never hard-refuse the user either (R486: null is "cannot determine", not
|
|
408
|
+
// "not 2FA-backed"). Leave the session un-verified and set `nextStep:
|
|
409
|
+
// 'mfa_verify'`, which is the field the gate already reads to mean "a usable
|
|
410
|
+
// method exists; challenge them" (SessionModel.ts:136).
|
|
411
|
+
const mfaEvidencePresent = mfaLifetime.mfaExpiresAt !== undefined;
|
|
392
412
|
// Support both old and new field names in input
|
|
393
413
|
// CRITICAL: Set BOTH mfaVerified (new) AND twoFactorComplete (legacy) for compatibility
|
|
394
414
|
// auth.ts session callback reads twoFactorComplete, so we must set it here
|
|
@@ -396,15 +416,28 @@ async function transitionTo2FASession(sessionToken, tokens, mfaMethod) {
|
|
|
396
416
|
idpAccessToken: newAccessToken,
|
|
397
417
|
idpRefreshToken: tokens.idpRefreshToken || tokens.refreshToken,
|
|
398
418
|
idpAccessTokenExpires: tokens.idpAccessTokenExpires || tokens.accessTokenExpires,
|
|
399
|
-
mfaVerified:
|
|
400
|
-
twoFactorComplete:
|
|
419
|
+
mfaVerified: mfaEvidencePresent,
|
|
420
|
+
twoFactorComplete: mfaEvidencePresent, // Legacy field - required by auth.ts session callback
|
|
401
421
|
mfaMethod: mfaMethod,
|
|
402
422
|
// Update bearerKeyId if extracted from new token
|
|
403
423
|
...(bearerKeyId && { bearerKeyId }),
|
|
404
424
|
// 339563: write the expiry in the SAME update that sets the flag. We already
|
|
405
425
|
// hold the stepped-up token here and already decode its header above.
|
|
406
|
-
...
|
|
426
|
+
...mfaLifetime,
|
|
427
|
+
// R908(e): no evidence -> route to the challenge, do not grant and do not eject.
|
|
428
|
+
...(mfaEvidencePresent ? {} : { nextStep: 'mfa_verify' }),
|
|
407
429
|
};
|
|
430
|
+
if (!mfaEvidencePresent) {
|
|
431
|
+
console.warn('[transitionTo2FASession] R908(e): the stepped-up token carries NO mfa_expires ' +
|
|
432
|
+
'claim, so there is no evidence of an MFA ceremony to record. NOT setting ' +
|
|
433
|
+
'mfaVerified; setting nextStep=mfa_verify so the consumer re-challenges. ' +
|
|
434
|
+
'If this fires for a lane that DID complete a real ceremony, the defect is ' +
|
|
435
|
+
'on the emit side (R908(b)) - do not "fix" it by minting the flag here.', {
|
|
436
|
+
sessionToken: sessionToken?.substring(0, 8) + '...',
|
|
437
|
+
mfaMethod,
|
|
438
|
+
hasAccessToken: !!newAccessToken,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
408
441
|
const refreshExpires = tokens.idpRefreshTokenExpires || tokens.refreshTokenExpires;
|
|
409
442
|
if (refreshExpires !== undefined) {
|
|
410
443
|
updates.idpRefreshTokenExpires = refreshExpires;
|
|
@@ -415,11 +448,13 @@ async function transitionTo2FASession(sessionToken, tokens, mfaMethod) {
|
|
|
415
448
|
mfaMethod: updates.mfaMethod,
|
|
416
449
|
hasIdpAccessToken: !!updates.idpAccessToken,
|
|
417
450
|
// 339563: surface the expiry we derived. `mfaExpiresAt: undefined` here is
|
|
418
|
-
// the passwordless/primary-credential lane (token carried no mfa_expires)
|
|
419
|
-
//
|
|
420
|
-
//
|
|
451
|
+
// the passwordless/primary-credential lane (token carried no mfa_expires).
|
|
452
|
+
// Since R908(e) that absence no longer fails OPEN: it withholds mfaVerified
|
|
453
|
+
// and sets nextStep=mfa_verify instead, so log both together - the pair is
|
|
454
|
+
// the whole decision.
|
|
421
455
|
mfaExpiresAt: updates.mfaExpiresAt,
|
|
422
456
|
mfaExpiresAtISO: updates.mfaExpiresAt ? new Date(updates.mfaExpiresAt).toISOString() : undefined,
|
|
457
|
+
nextStep: updates.nextStep,
|
|
423
458
|
});
|
|
424
459
|
const result = await updateSession(sessionToken, updates);
|
|
425
460
|
console.log('[transitionTo2FASession] Result:', {
|
|
@@ -477,9 +512,23 @@ async function mark2FAComplete(sessionToken, accessToken) {
|
|
|
477
512
|
'this session has no MFA expiry. Expected on a primary-credential lane ' +
|
|
478
513
|
'(e.g. passwordless email); unexpected on an ordinary 2FA completion.');
|
|
479
514
|
}
|
|
515
|
+
// R908(e): identical rule to transitionTo2FASession above - no expiry derived
|
|
516
|
+
// means no observed ceremony, so we re-challenge rather than mint the flag.
|
|
517
|
+
// This path matters more than its sibling: `mark2FAComplete(sessionToken)`
|
|
518
|
+
// with the accessToken omitted used to write a bare `mfaVerified: true` that
|
|
519
|
+
// NO consumer could ever expire, and it is reachable from route handlers that
|
|
520
|
+
// take their tokens from the request body.
|
|
521
|
+
const mfaEvidencePresent = mfaLifetime.mfaExpiresAt !== undefined;
|
|
522
|
+
if (!mfaEvidencePresent) {
|
|
523
|
+
console.warn('[mark2FAComplete] R908(e): no mfa_expires could be derived, so mfaVerified ' +
|
|
524
|
+
'is NOT being set. Writing nextStep=mfa_verify instead so the consumer ' +
|
|
525
|
+
're-challenges. A caller that needs the grant must pass the stepped-up ' +
|
|
526
|
+
'access token that carries the claim.');
|
|
527
|
+
}
|
|
480
528
|
return updateSession(sessionToken, {
|
|
481
|
-
mfaVerified:
|
|
529
|
+
mfaVerified: mfaEvidencePresent,
|
|
482
530
|
...mfaLifetime,
|
|
531
|
+
...(mfaEvidencePresent ? {} : { nextStep: 'mfa_verify' }),
|
|
483
532
|
});
|
|
484
533
|
}
|
|
485
534
|
/**
|