@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
|
@@ -21,6 +21,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
21
21
|
exports.createMvpMiddleware = createMvpMiddleware;
|
|
22
22
|
const server_1 = require("next/server");
|
|
23
23
|
const route_config_1 = require("../auth/route-config");
|
|
24
|
+
const safe_callback_1 = require("../auth/safe-callback");
|
|
24
25
|
const internal_api_url_1 = require("../edge/internal-api-url");
|
|
25
26
|
const app_slug_1 = require("../lib/app-slug");
|
|
26
27
|
const rbac_check_1 = require("./rbac-check");
|
|
@@ -57,16 +58,23 @@ const VERIFY_CODE_PAGE = '/account-auth/verify-code';
|
|
|
57
58
|
function isLoginPage(pathname) {
|
|
58
59
|
return pathname === LOGIN_PAGE;
|
|
59
60
|
}
|
|
60
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* Ensures callback URLs never point to auth pages (prevents redirect loops) AND
|
|
63
|
+
* never point off-origin (prevents open redirect).
|
|
64
|
+
*
|
|
65
|
+
* 375656: the previous body tested `pathname` FIRST and returned '/' early, so
|
|
66
|
+
* the two call sites that pass an attacker-controlled `searchParams` are both
|
|
67
|
+
* inside `if (onLoginPage)` branches - and `isLoginPage` is exact equality against
|
|
68
|
+
* `LOGIN_PAGE`, which starts with `/account-auth/`. The early return therefore
|
|
69
|
+
* fired before the parameter was ever read. That made this function SAFE, but
|
|
70
|
+
* safe because of which argument the caller happened to pass, not because the
|
|
71
|
+
* attacker-controlled value was ever checked: `existing` was returned raw on the
|
|
72
|
+
* strength of a `startsWith` test that `https://evil.example.com` passes.
|
|
73
|
+
* Nothing named that dependency, and reordering the arguments would have opened
|
|
74
|
+
* it silently. Both inputs are now validated by the same parser-based guard.
|
|
75
|
+
*/
|
|
61
76
|
function getSafeCallbackUrl(pathname, searchParams) {
|
|
62
|
-
|
|
63
|
-
return '/';
|
|
64
|
-
}
|
|
65
|
-
const existing = searchParams?.get('callbackUrl');
|
|
66
|
-
if (existing && !existing.startsWith('/account-auth/')) {
|
|
67
|
-
return existing;
|
|
68
|
-
}
|
|
69
|
-
return pathname;
|
|
77
|
+
return (0, safe_callback_1.resolveCallbackDestination)(pathname, searchParams);
|
|
70
78
|
}
|
|
71
79
|
/** Clear all session cookies on a response */
|
|
72
80
|
function clearSessionCookies(response, request) {
|
|
@@ -276,14 +284,38 @@ function createMvpMiddleware(options = {}) {
|
|
|
276
284
|
}
|
|
277
285
|
// --- Fetch session viability from internal API ---
|
|
278
286
|
const { sessionStatus, sessionPointer } = await checkViability(request, viabilityEndpoint, log);
|
|
279
|
-
// ---
|
|
287
|
+
// --- Stale session (cookie exists but session doesn't): OBSERVE ONLY, DO NOT DECIDE ---
|
|
288
|
+
//
|
|
289
|
+
// *** CARD 349607. THIS BLOCK USED TO `return` HERE, AND THAT RETURN WAS A BYPASS. ***
|
|
290
|
+
//
|
|
291
|
+
// makeAuthDecision() below already handles this exact condition at its
|
|
292
|
+
// `if (!sessionStatus.exists)` branch, and it handles it with TWO guard
|
|
293
|
+
// clauses that this early return did not have:
|
|
294
|
+
//
|
|
295
|
+
// if (onLoginPage) return { type: 'allow' }; // no redirect loop
|
|
296
|
+
// if (pathname.startsWith('/api/'))return { type: 'allow' }; // no HTML 307 at fetch()
|
|
297
|
+
//
|
|
298
|
+
// Returning here skipped BOTH. The `/api/` case is the reported harm: a
|
|
299
|
+
// stale-session XHR was answered with a 307 to an HTML login page, so the
|
|
300
|
+
// failure is invisible to `fetch()` - the caller sees an opaque redirect
|
|
301
|
+
// instead of an auth error, which is the same "correct logic preempted by
|
|
302
|
+
// an earlier return" shape recorded on 307290.
|
|
303
|
+
//
|
|
304
|
+
// This was NOT a missing feature one level down - the decision engine's
|
|
305
|
+
// branch is a strict superset of the behaviour this block implemented.
|
|
306
|
+
// The early return was a DUPLICATE that had drifted from the original.
|
|
307
|
+
// Adding a single `/api/` exemption here would have re-created the
|
|
308
|
+
// duplication (and left the onLoginPage divergence in place), so the
|
|
309
|
+
// duplicate is removed instead and the engine is left as the ONE decider.
|
|
310
|
+
//
|
|
311
|
+
// The warning is deliberately KEPT: it is the only operational signal that
|
|
312
|
+
// stale sessions are occurring, and it costs nothing to log before
|
|
313
|
+
// delegating. Do not re-introduce a `return` in this block.
|
|
280
314
|
if (!isPublic && sessionStatus.exists === false && sessionPointer.sessionToken) {
|
|
281
|
-
|
|
282
|
-
log.warn('Stale session detected', {
|
|
315
|
+
log.warn('Stale session detected (delegating to decision engine)', {
|
|
283
316
|
sessionToken: sessionPointer.sessionToken?.substring(0, 8) + '...',
|
|
284
317
|
pathname
|
|
285
318
|
});
|
|
286
|
-
return redirectTo(request, `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}`, true);
|
|
287
319
|
}
|
|
288
320
|
// --- Run decision engine ---
|
|
289
321
|
const decision = makeAuthDecision({
|
|
@@ -21,6 +21,12 @@ const jsx_runtime_1 = require("react/jsx-runtime");
|
|
|
21
21
|
const react_1 = __importDefault(require("react"));
|
|
22
22
|
const useProfile_1 = require("../../hooks/useProfile");
|
|
23
23
|
const useTheme_1 = require("../../theme/useTheme");
|
|
24
|
+
// An all-null object is not data. The member profile DTOs are always instantiated
|
|
25
|
+
// server-side (UserService.GetUserProfileAsync), so `obj ? ... : undefined` cannot
|
|
26
|
+
// tell "has an address" from "has an empty address shell".
|
|
27
|
+
function hasAnyValue(obj) {
|
|
28
|
+
return !!obj && Object.values(obj).some(v => v !== null && v !== undefined && v !== '');
|
|
29
|
+
}
|
|
24
30
|
function EditableSection({ title, children, onEdit, isEditing, onSave, onCancel, isDarkMode }) {
|
|
25
31
|
const cardBg = isDarkMode ? 'bg-slate-800' : 'bg-white';
|
|
26
32
|
const borderColor = isDarkMode ? 'border-slate-700' : 'border-gray-200';
|
|
@@ -105,20 +111,39 @@ function EnhancedProfilePage() {
|
|
|
105
111
|
title: undefined,
|
|
106
112
|
bio: undefined,
|
|
107
113
|
},
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
114
|
+
// Both blocks used to be read off `rawProfile.contact_information`, which the
|
|
115
|
+
// MEMBER endpoint NEVER emits - that name belongs to AdminUserProfileResponse
|
|
116
|
+
// (its only 2 occurrences in PayEz-Core are in that admin DTO).
|
|
117
|
+
// /api/account/profile returns UserProfileResponse: `contact_info` plus a
|
|
118
|
+
// SEPARATE TOP-LEVEL `address`. Both guards therefore always took the false
|
|
119
|
+
// branch and both blocks rendered permanently empty for every member.
|
|
120
|
+
//
|
|
121
|
+
// NOT a rename: `address` is a SIBLING of contact_info, not nested inside it,
|
|
122
|
+
// so contact_information -> contact_info alone fixes Contact and leaves ADDRESS
|
|
123
|
+
// DEAD. Inner names are snake_case on both DTOs; the old camelCase reads were
|
|
124
|
+
// shaped for the ADMIN payload, which is where those keys really are camelCase.
|
|
125
|
+
// GUARD ON CONTENT, NOT PRESENCE. UserService.GetUserProfileAsync ALWAYS
|
|
126
|
+
// instantiates both DTOs (ContactInfo = new ProfileContactInfoDto {...},
|
|
127
|
+
// Address = new AddressDto {...}), so both arrive NON-NULL with all-null
|
|
128
|
+
// members when the member has nothing on file. A truthiness guard always
|
|
129
|
+
// passes and would render a card full of blank fields - the hazard QAPert
|
|
130
|
+
// named in 41360 for the naive rename, which applies just as much to
|
|
131
|
+
// reading the CORRECT fields.
|
|
132
|
+
contact_info: hasAnyValue(rawProfile.contact_info) ? {
|
|
133
|
+
secondary_email: rawProfile.contact_info?.secondary_email,
|
|
134
|
+
secondary_phone: rawProfile.contact_info?.secondary_phone,
|
|
135
|
+
website: rawProfile.contact_info?.website,
|
|
136
|
+
preferred_contact_method: rawProfile.contact_info?.preferred_contact_method,
|
|
112
137
|
} : undefined,
|
|
113
|
-
address: rawProfile.
|
|
114
|
-
address_line_1: rawProfile.
|
|
115
|
-
address_line_2: rawProfile.
|
|
116
|
-
city: rawProfile.
|
|
117
|
-
state_id: rawProfile.
|
|
118
|
-
state_name: rawProfile.
|
|
119
|
-
postal_code: rawProfile.
|
|
120
|
-
country_code: rawProfile.
|
|
121
|
-
country_name: rawProfile.
|
|
138
|
+
address: hasAnyValue(rawProfile.address) ? {
|
|
139
|
+
address_line_1: rawProfile.address?.address_line_1,
|
|
140
|
+
address_line_2: rawProfile.address?.address_line_2,
|
|
141
|
+
city: rawProfile.address?.city,
|
|
142
|
+
state_id: rawProfile.address?.state_id,
|
|
143
|
+
state_name: rawProfile.address?.state_name,
|
|
144
|
+
postal_code: rawProfile.address?.postal_code,
|
|
145
|
+
country_code: rawProfile.address?.country_code,
|
|
146
|
+
country_name: rawProfile.address?.country_name,
|
|
122
147
|
} : undefined,
|
|
123
148
|
two_factor_enabled: rawProfile.two_factor_enabled,
|
|
124
149
|
roles: rawProfile.roles,
|
|
@@ -37,6 +37,7 @@ const navigation_1 = require("next/navigation");
|
|
|
37
37
|
const better_auth_client_1 = require("../../client/better-auth-client");
|
|
38
38
|
const react_2 = require("react");
|
|
39
39
|
const useTheme_1 = require("../../theme/useTheme");
|
|
40
|
+
const safe_callback_1 = require("../../auth/safe-callback");
|
|
40
41
|
/**
|
|
41
42
|
* Session storage key to track that user intentionally navigated to verify-code.
|
|
42
43
|
* Prevents auto-redirect back to dashboard when session refreshes in background.
|
|
@@ -45,7 +46,31 @@ const VERIFY_IN_PROGRESS_KEY = 'idealvibe_2fa_verify_in_progress';
|
|
|
45
46
|
function VerifyCodeForm({ enrollHref }) {
|
|
46
47
|
const router = (0, navigation_1.useRouter)();
|
|
47
48
|
const searchParams = (0, navigation_1.useSearchParams)();
|
|
48
|
-
|
|
49
|
+
/**
|
|
50
|
+
* 375936 - ORIGIN-GUARDED AT THE READ, NOT AT THE SINK.
|
|
51
|
+
*
|
|
52
|
+
* This value ends up in `window.location.href` after a SUCCESSFUL 2FA
|
|
53
|
+
* ceremony (see the setTimeout further down). That is a full navigation, so
|
|
54
|
+
* an absolute URL here is a cross-origin redirect - the user completes a
|
|
55
|
+
* genuine login and is then dropped on an attacker-controlled page at the
|
|
56
|
+
* exact moment they are most primed to trust what they see.
|
|
57
|
+
*
|
|
58
|
+
* This used to be a raw `searchParams?.get('callbackUrl') || '/dashboard'`.
|
|
59
|
+
* The middleware does NOT protect this page: create-middleware's
|
|
60
|
+
* `needsTwoFactorRedirect` returns false for VERIFY_CODE_PAGE, so a direct
|
|
61
|
+
* request arrives here with its query string untouched.
|
|
62
|
+
*
|
|
63
|
+
* Do NOT "simplify" this back to a `startsWith('/account-auth/')` test. The
|
|
64
|
+
* three such tests below are LOOP prevention, not ORIGIN prevention -
|
|
65
|
+
* `https://evil.example` passes every one of them. safeCallbackPath asks the
|
|
66
|
+
* URL parser instead, so protocol-relative (`//host`), backslash (`/\host`)
|
|
67
|
+
* and TAB/LF/CR spellings all fail one origin comparison rather than needing
|
|
68
|
+
* their own blacklist entry.
|
|
69
|
+
*
|
|
70
|
+
* Guarding at the READ rather than at the sink means every later use of
|
|
71
|
+
* `callbackUrl` on this page inherits the guarantee.
|
|
72
|
+
*/
|
|
73
|
+
const callbackUrl = (0, safe_callback_1.safeCallbackPath)(searchParams?.get('callbackUrl'), '/dashboard');
|
|
49
74
|
const { data: sessionData, isPending } = better_auth_client_1.authClient.useSession();
|
|
50
75
|
const session = sessionData;
|
|
51
76
|
const status = isPending ? 'loading' : session ? 'authenticated' : 'unauthenticated';
|
|
@@ -35,12 +35,12 @@ export declare function GET(req: NextRequest): Promise<NextResponse<{
|
|
|
35
35
|
authenticated: boolean;
|
|
36
36
|
sessionToken: any;
|
|
37
37
|
tenantRequiresTwoFactor: boolean;
|
|
38
|
-
userHasCompletedTenantTwoFactorRequirements:
|
|
38
|
+
userHasCompletedTenantTwoFactorRequirements: boolean;
|
|
39
39
|
userStillNeedsTwoFactor: boolean;
|
|
40
40
|
tenantAllowed2FAMethods: string[] | null;
|
|
41
41
|
nextStep: "enroll_mfa" | "mfa_verify" | null | undefined;
|
|
42
42
|
requires2FA: boolean;
|
|
43
|
-
twoFactorComplete:
|
|
43
|
+
twoFactorComplete: boolean;
|
|
44
44
|
accessTokenExpired: boolean;
|
|
45
45
|
expiresAt: string;
|
|
46
46
|
roles: string[];
|
|
@@ -280,7 +280,7 @@ async function GET(req) {
|
|
|
280
280
|
const mfaExpiresAt = session?.mfaExpiresAt || 0;
|
|
281
281
|
const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
|
|
282
282
|
// Check mfaVerified (normalized name) with fallback to twoFactorComplete for compatibility
|
|
283
|
-
const mfaVerifiedInSession = session?.mfaVerified ??
|
|
283
|
+
const mfaVerifiedInSession = session?.mfaVerified ?? false;
|
|
284
284
|
// User has completed 2FA requirements if: they verified AND it hasn't expired
|
|
285
285
|
const userHasCompletedTenantTwoFactorRequirements = mfaVerifiedInSession && !mfaExpired;
|
|
286
286
|
// userStillNeedsTwoFactor = inverse of completed (matches session callback logic)
|
|
@@ -322,7 +322,7 @@ async function GET(req) {
|
|
|
322
322
|
const mfaExpiresAt = session?.mfaExpiresAt || 0;
|
|
323
323
|
const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
|
|
324
324
|
// Check mfaVerified (normalized name) with fallback to twoFactorComplete for compatibility
|
|
325
|
-
const mfaVerifiedInSession = session?.mfaVerified ??
|
|
325
|
+
const mfaVerifiedInSession = session?.mfaVerified ?? false;
|
|
326
326
|
// DEBUG: Log what we're reading from the session
|
|
327
327
|
console.log('[VIABILITY] Session 2FA state:', {
|
|
328
328
|
sessionToken: sessionToken?.substring(0, 8) + '...',
|
|
@@ -87,7 +87,32 @@ async function authGuard(options) {
|
|
|
87
87
|
(0, navigation_1.redirect)(serviceUnavailableUrl);
|
|
88
88
|
}
|
|
89
89
|
if (requires2FA) {
|
|
90
|
-
|
|
90
|
+
// 369858: the legacy `?? (sessionData as any).twoFactorComplete` carry is REMOVED.
|
|
91
|
+
// This is THE access gate - the only one of the 8 carry sites that grants access
|
|
92
|
+
// rather than reporting status - so it reads exactly one source of truth.
|
|
93
|
+
//
|
|
94
|
+
// Removing it is fail-closed. No current writer emits the legacy field alone:
|
|
95
|
+
// session-store.ts:444-445 writes both as a pair from a server-derived stepped-up
|
|
96
|
+
// token, auth/refresh.ts:376 writes it as a MIRROR of mfaVerified, and
|
|
97
|
+
// routes/auth/session.ts:149-157 refuses both on PRESENCE, so a caller cannot
|
|
98
|
+
// assert either. The carry therefore only ever served sessions minted before
|
|
99
|
+
// `mfaVerified` existed, and for those the correct behaviour is re-verify, not
|
|
100
|
+
// grandfather. Cost is one forced re-challenge for a stale legacy session.
|
|
101
|
+
//
|
|
102
|
+
// Severity note, so nobody re-inflates this later: the operator was `??`
|
|
103
|
+
// (nullish), NOT `||`. `false ?? x` is `false`, so an explicitly DENIED session
|
|
104
|
+
// could never be upgraded by a stale flag. This was legacy grandfathering, not a
|
|
105
|
+
// live 2FA bypass. Had it been `||`, it would have been the bypass.
|
|
106
|
+
const mfaVerified = sessionData.mfaVerified ?? false;
|
|
107
|
+
// 339563 / 369858 §3: THIS IS STILL A FAIL-OPEN AND IS DELIBERATELY LEFT VISIBLE.
|
|
108
|
+
// An absent mfaExpiresAt collapses to 0, so `mfaExpired` is false and the MFA
|
|
109
|
+
// verdict NEVER EXPIRES. That needs no legacy-session hypothesis: it fires on a
|
|
110
|
+
// current `mfaVerified: true` session whose token carried no `mfa_expires` claim -
|
|
111
|
+
// the passwordless/primary-credential lane, named on the write path in the
|
|
112
|
+
// source's own voice at session-store.ts:464-467 ("it IS the case the consumers
|
|
113
|
+
// currently fail OPEN on"). Removing the carry above does NOT touch this.
|
|
114
|
+
// Tracked separately by BAPert; not fixed here because failing closed on absent
|
|
115
|
+
// expiry would deny the passwordless lane outright and that is a product call.
|
|
91
116
|
const mfaExpiresAt = sessionData.mfaExpiresAt || 0;
|
|
92
117
|
const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
|
|
93
118
|
if (!mfaVerified || mfaExpired) {
|
package/package.json
CHANGED
|
@@ -82,6 +82,10 @@ export async function POST(req: NextRequest) {
|
|
|
82
82
|
// session. No tokens => no transition: we must not mark 2FA complete on the
|
|
83
83
|
// strength of a 200 alone.
|
|
84
84
|
let sessionTransitioned = false;
|
|
85
|
+
// R908(e): "we could not establish MFA" is a THIRD outcome, distinct from
|
|
86
|
+
// both success and failure. It must reach the client as a challenge, not as
|
|
87
|
+
// a silent success (fail-open) and not as a rejection (fail-closed).
|
|
88
|
+
let requiresChallenge = false;
|
|
85
89
|
|
|
86
90
|
if (unwrappedData.access_token && unwrappedData.refresh_token) {
|
|
87
91
|
const token = await getTokenTestAware(req);
|
|
@@ -104,7 +108,11 @@ export async function POST(req: NextRequest) {
|
|
|
104
108
|
// Refresh token may carry no exp claim; default stands.
|
|
105
109
|
}
|
|
106
110
|
|
|
107
|
-
|
|
111
|
+
// R908(e): the transition can now decline to grant. Read what was
|
|
112
|
+
// actually written instead of assuming the call granted - the store
|
|
113
|
+
// withholds mfaVerified when the stepped-up token carries no
|
|
114
|
+
// mfa_expires, and reports the re-challenge via nextStep.
|
|
115
|
+
const transitioned = await transitionTo2FASession(
|
|
108
116
|
sessionToken,
|
|
109
117
|
{
|
|
110
118
|
accessToken: unwrappedData.access_token,
|
|
@@ -115,8 +123,18 @@ export async function POST(req: NextRequest) {
|
|
|
115
123
|
'totp' // 2FA method, used by the refresh-token flow
|
|
116
124
|
);
|
|
117
125
|
|
|
118
|
-
sessionTransitioned = true;
|
|
119
|
-
|
|
126
|
+
sessionTransitioned = transitioned?.mfaVerified === true;
|
|
127
|
+
requiresChallenge = transitioned?.nextStep === 'mfa_verify';
|
|
128
|
+
|
|
129
|
+
if (sessionTransitioned) {
|
|
130
|
+
console.log('[VERIFY_TOTP] Session transitioned to 2FA-verified');
|
|
131
|
+
} else {
|
|
132
|
+
console.warn(
|
|
133
|
+
'[VERIFY_TOTP] R908(e): IDP verified the code but the session was NOT ' +
|
|
134
|
+
'marked MFA-verified (no mfa_expires on the returned token). Reporting ' +
|
|
135
|
+
'requiresChallenge so the client re-challenges rather than proceeding.'
|
|
136
|
+
);
|
|
137
|
+
}
|
|
120
138
|
} else {
|
|
121
139
|
console.warn('[VERIFY_TOTP] IDP verified the code but no session token was resolvable — session NOT transitioned');
|
|
122
140
|
}
|
|
@@ -138,6 +156,9 @@ export async function POST(req: NextRequest) {
|
|
|
138
156
|
success: true,
|
|
139
157
|
verificationSuccessful: true,
|
|
140
158
|
twoFactorSessionVerified: sessionTransitioned,
|
|
159
|
+
// R908(e): tells the client to re-challenge. Present only when the code
|
|
160
|
+
// verified at the IDP but produced no evidence we could record.
|
|
161
|
+
...(requiresChallenge ? { requiresChallenge: true, nextStep: 'mfa_verify' as const } : {}),
|
|
141
162
|
message: unwrappedData.message || 'Authenticator code verified successfully',
|
|
142
163
|
},
|
|
143
164
|
{ status: 200 }
|
|
@@ -21,14 +21,14 @@ export async function GET(req: NextRequest) {
|
|
|
21
21
|
// Still need to get session data for twoFactorComplete even when refresh is in progress
|
|
22
22
|
const sessionData = await getSession(finalSessionToken);
|
|
23
23
|
logger.info('[REFRESH-VIABILITY] Refresh already in progress, telling middleware to wait', { sessionToken: finalSessionToken.substring(0, 8) + '...' });
|
|
24
|
-
return NextResponse.json({ canRefresh: true, reason: 'refresh_in_progress', refreshInProgress: true, sessionToken: finalSessionToken, twoFactorComplete: sessionData?.mfaVerified ??
|
|
24
|
+
return NextResponse.json({ canRefresh: true, reason: 'refresh_in_progress', refreshInProgress: true, sessionToken: finalSessionToken, twoFactorComplete: sessionData?.mfaVerified ?? false }, { status: 200 });
|
|
25
25
|
}
|
|
26
26
|
const sessionData = await getSession(finalSessionToken);
|
|
27
27
|
if (!sessionData) {
|
|
28
28
|
return NextResponse.json({ canRefresh: false, reason: 'session_not_found', sessionToken: finalSessionToken }, { status: 200 });
|
|
29
29
|
}
|
|
30
30
|
const viabilityCheck = checkRefreshViability(sessionData);
|
|
31
|
-
return 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 ??
|
|
31
|
+
return 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 });
|
|
32
32
|
} catch (error) {
|
|
33
33
|
logger.error('[REFRESH-VIABILITY] Error checking refresh viability', { error: error instanceof Error ? error.message : String(error) });
|
|
34
34
|
return NextResponse.json({ canRefresh: false, reason: 'check_error', error: error instanceof Error ? error.message : String(error) }, { status: 500 });
|
|
@@ -119,7 +119,7 @@ export async function GET(req: NextRequest) {
|
|
|
119
119
|
const mfaExpiresAt = sessionData.mfaExpiresAt || 0;
|
|
120
120
|
const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
|
|
121
121
|
// Check both field names for compatibility (mfaVerified is the normalized name)
|
|
122
|
-
const sessionMfaComplete = sessionData.mfaVerified ??
|
|
122
|
+
const sessionMfaComplete = sessionData.mfaVerified ?? false;
|
|
123
123
|
const effectiveTwoFactorComplete = sessionMfaComplete && !mfaExpired;
|
|
124
124
|
|
|
125
125
|
console.log('[VIABILITY] Session 2FA check:', {
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* All auth flow logic is centralized here for testability and maintainability.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { safeCallbackPath } from './safe-callback';
|
|
9
|
+
|
|
8
10
|
// Input types for the decision engine
|
|
9
11
|
export interface AuthContext {
|
|
10
12
|
pathname: string;
|
|
@@ -45,8 +47,12 @@ export function makeAuthDecision(context: AuthContext): AuthAction {
|
|
|
45
47
|
circuitBreakerOpen
|
|
46
48
|
} = context;
|
|
47
49
|
|
|
48
|
-
// SAFEGUARD: Never use auth pages as callback URLs to prevent redirect loops
|
|
49
|
-
|
|
50
|
+
// SAFEGUARD: Never use auth pages as callback URLs to prevent redirect loops.
|
|
51
|
+
// 375656: `pathname` is our own request path rather than an attacker-supplied
|
|
52
|
+
// parameter, so this site was not the open redirect - but it is routed through
|
|
53
|
+
// the same guard so the package has ONE definition of an acceptable callback,
|
|
54
|
+
// and so the value is parser-normalised before being carried into a redirect.
|
|
55
|
+
const safeCallbackUrl = safeCallbackPath(pathname);
|
|
50
56
|
|
|
51
57
|
// Public routes are allowed, but we need to check login page separately
|
|
52
58
|
if (isPublicRoute && !isLoginPage) {
|
|
@@ -145,8 +151,17 @@ export function makeAuthDecision(context: AuthContext): AuthAction {
|
|
|
145
151
|
// If user is on the login page, send them to verify-code with the original callback
|
|
146
152
|
if (isLoginPage) {
|
|
147
153
|
const callbackUrl = searchParams.get('callbackUrl') || '/';
|
|
148
|
-
//
|
|
149
|
-
|
|
154
|
+
// 375656: this previously tested the raw parameter with a prefix check against
|
|
155
|
+
// the auth path, and returned the parameter itself when it did not match.
|
|
156
|
+
// (The vulnerable expression is deliberately NOT reproduced verbatim here: a
|
|
157
|
+
// comment carrying it puts the string into dist, and a reviewer grepping for
|
|
158
|
+
// that pattern would score this FIXED build as vulnerable - the exact
|
|
159
|
+
// sink-counting inversion recorded on 375936.)
|
|
160
|
+
// That is a redirect-LOOP guard in the seat where an ORIGIN check belongs:
|
|
161
|
+
// `https://evil.example.com` does not start with `/account-auth/`, so it passed
|
|
162
|
+
// through untouched. safeCallbackPath() asks the URL parser instead, so every
|
|
163
|
+
// spelling that escapes the origin fails one comparison. See ./safe-callback.
|
|
164
|
+
const safeCallbackUrl = safeCallbackPath(callbackUrl);
|
|
150
165
|
console.log('[MIDDLEWARE-DECISION] On login page with 2FA incomplete, redirecting to verify-code', {
|
|
151
166
|
originalCallback: callbackUrl,
|
|
152
167
|
safeCallback: safeCallbackUrl,
|
|
@@ -193,8 +208,8 @@ export function makeAuthDecision(context: AuthContext): AuthAction {
|
|
|
193
208
|
// For login page, redirect to 2FA with original callback
|
|
194
209
|
if (isLoginPage) {
|
|
195
210
|
const callbackUrl = searchParams.get('callbackUrl') || '/';
|
|
196
|
-
//
|
|
197
|
-
const safeCallbackUrl = callbackUrl
|
|
211
|
+
// 375656: origin-checked, not prefix-checked. See ./safe-callback.
|
|
212
|
+
const safeCallbackUrl = safeCallbackPath(callbackUrl);
|
|
198
213
|
return {
|
|
199
214
|
type: 'redirect',
|
|
200
215
|
location: `/account-auth/verify-code?callbackUrl=${encodeURIComponent(safeCallbackUrl)}`,
|
|
@@ -212,8 +227,13 @@ export function makeAuthDecision(context: AuthContext): AuthAction {
|
|
|
212
227
|
// Authenticated user on login page - redirect to dashboard or callback
|
|
213
228
|
if (isLoginPage) {
|
|
214
229
|
const callbackUrl = searchParams.get('callbackUrl') || '/';
|
|
215
|
-
//
|
|
216
|
-
|
|
230
|
+
// 375656 - THE PUBLISHED OPEN REDIRECT. This value becomes `location` VERBATIM
|
|
231
|
+
// below (not a query parameter on a relative path, as at the two sites above),
|
|
232
|
+
// so the old `startsWith('/account-auth/')` test sent an authenticated user
|
|
233
|
+
// straight to any absolute URL an attacker put in `?callbackUrl=`. Its own
|
|
234
|
+
// comment says "prevents infinite loop", which is what it was written for -
|
|
235
|
+
// loop prevention, never origin validation. See ./safe-callback.
|
|
236
|
+
const safeCallbackUrl = safeCallbackPath(callbackUrl);
|
|
217
237
|
console.log('[MIDDLEWARE-DECISION] Authenticated user on login page, redirecting', {
|
|
218
238
|
originalCallback: callbackUrl,
|
|
219
239
|
safeCallback: safeCallbackUrl
|
package/src/auth/better-auth.ts
CHANGED
|
@@ -34,6 +34,26 @@ export function secretFingerprint(value: string): string {
|
|
|
34
34
|
return createHash('sha256').update(value).digest('hex').slice(0, 12);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* 273183: keys written onto the `ba:{slug}:{token}` record by an author OTHER
|
|
39
|
+
* than Better Auth, which must survive a Better Auth session write.
|
|
40
|
+
*
|
|
41
|
+
* Today this is exactly `idpTokens`, written by `storeIdpTokensInSession`. The
|
|
42
|
+
* list is a named constant rather than a hardcoded check because the shape of
|
|
43
|
+
* the bug is "a second author appeared and the adapter did not know" — the next
|
|
44
|
+
* enrichment key should be added here, not rediscovered as a strip.
|
|
45
|
+
*/
|
|
46
|
+
const PRESERVED_SESSION_KEYS = ['idpTokens'] as const;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* True for a JSON object we can safely merge into — not null, not an array.
|
|
50
|
+
* `typeof null === 'object'` and arrays are objects, and merging into either
|
|
51
|
+
* would corrupt the record rather than preserve it.
|
|
52
|
+
*/
|
|
53
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
54
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
37
57
|
/**
|
|
38
58
|
* Better Auth social provider config shape.
|
|
39
59
|
*/
|
|
@@ -498,7 +518,114 @@ export function createBetterAuthInstance(
|
|
|
498
518
|
*/
|
|
499
519
|
set: async (key: string, value: string, ttl?: number) => {
|
|
500
520
|
const redis = getRedis();
|
|
501
|
-
|
|
521
|
+
const redisKey = `ba:${appSlug}:${key}`;
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* 273183 — READ-MODIFY-WRITE, BECAUSE THIS RECORD HAS TWO AUTHORS.
|
|
525
|
+
* (Found by DotNetPert-Scout; handed over as Cluster-B root-cause
|
|
526
|
+
* evidence under BAPert's R920(a). Reproduced here before fixing.)
|
|
527
|
+
*
|
|
528
|
+
* Better Auth owns `{session, user}` and serialises exactly that. But
|
|
529
|
+
* `storeIdpTokensInSession` (below, ~:1010) ENRICHES the same key with
|
|
530
|
+
* `idpTokens` via its own read-modify-write. This setter did a blind
|
|
531
|
+
* `setex` of Better Auth's payload, so every write it makes — including
|
|
532
|
+
* each routine `updateAge` session rewrite, not merely an expiry —
|
|
533
|
+
* STRIPPED `idpTokens` back off the record.
|
|
534
|
+
*
|
|
535
|
+
* The consumer then falls back to an identity husk: `mfaVerified:false`,
|
|
536
|
+
* `roles:[]`. It fails CLOSED, so it is not a 369782-class bypass — but
|
|
537
|
+
* per BAPert it is a public-test blocker on its own terms, because a
|
|
538
|
+
* tester who trips the `updateAge` cadence gets a spurious 2FA re-prompt
|
|
539
|
+
* AND SILENTLY LOSES THEIR ROLES mid-session. That is a visible product
|
|
540
|
+
* failure regardless of which direction it fails in.
|
|
541
|
+
*
|
|
542
|
+
* WHY MERGE HERE RATHER THAN MOVE THE DATA. The enriching writer already
|
|
543
|
+
* does a read-modify-write; it was correct. The bug is that ONE of the
|
|
544
|
+
* two authors of this key wrote as though it were the only one. Fixing
|
|
545
|
+
* the adapter fixes it for every future enrichment key, whereas moving
|
|
546
|
+
* `idpTokens` elsewhere would leave the next one to rediscover this.
|
|
547
|
+
*
|
|
548
|
+
* ONLY keys in PRESERVED_KEYS are carried, and ONLY when the incoming
|
|
549
|
+
* payload does not set them — so Better Auth still wins on everything it
|
|
550
|
+
* owns, and an explicit incoming value is never overridden by a stale one.
|
|
551
|
+
*
|
|
552
|
+
* NOT ATOMIC, and deliberately not: an enrichment landing between this
|
|
553
|
+
* read and this write is still lost. Closing that needs a WATCH/MULTI or
|
|
554
|
+
* a Lua script, which is a larger change than tonight allows. This
|
|
555
|
+
* narrows the window from "every single write" to "a genuine race", and
|
|
556
|
+
* the residual is recorded here rather than left to be re-found.
|
|
557
|
+
*/
|
|
558
|
+
let payload = value;
|
|
559
|
+
try {
|
|
560
|
+
const existingRaw = await redis.get(redisKey);
|
|
561
|
+
if (existingRaw) {
|
|
562
|
+
const existing = JSON.parse(existingRaw);
|
|
563
|
+
const incoming = JSON.parse(value);
|
|
564
|
+
if (isPlainObject(existing) && isPlainObject(incoming)) {
|
|
565
|
+
let carried = false;
|
|
566
|
+
for (const preserved of PRESERVED_SESSION_KEYS) {
|
|
567
|
+
if (preserved in existing && !(preserved in incoming)) {
|
|
568
|
+
incoming[preserved] = existing[preserved];
|
|
569
|
+
carried = true;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
if (carried) payload = JSON.stringify(incoming);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
} catch (mergeErr) {
|
|
576
|
+
/**
|
|
577
|
+
* This adapter also stores `verification:*` and `active-sessions-*`
|
|
578
|
+
* records, which are not necessarily session-shaped JSON. A record we
|
|
579
|
+
* cannot parse is not a session to enrich, so writing through unchanged
|
|
580
|
+
* is correct rather than merely tolerable.
|
|
581
|
+
*
|
|
582
|
+
* WARNED, NOT THROWN, and the asymmetry is the point: this block is an
|
|
583
|
+
* ENRICHMENT-PRESERVING read. Letting it fail the write would convert a
|
|
584
|
+
* degraded read into a lost session — the precise inversion 170033 fix 1
|
|
585
|
+
* above exists to prevent. The `setex` below is outside this try and is
|
|
586
|
+
* still unswallowed, so a genuine write failure still propagates.
|
|
587
|
+
*/
|
|
588
|
+
console.warn(
|
|
589
|
+
`[BETTER_AUTH] secondaryStorage.set: could not preserve enrichment keys on ` +
|
|
590
|
+
`ba:${appSlug}:sha256:${secretFingerprint(key)} — writing Better Auth's payload ` +
|
|
591
|
+
`as-is. If this record held idpTokens they are now gone (273183):`,
|
|
592
|
+
mergeErr instanceof Error ? mergeErr.message : String(mergeErr)
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* QA 6952 / R913 - CLAMP, DO NOT DEFER.
|
|
598
|
+
*
|
|
599
|
+
* This was `ttl ?? SESSION_TTL`, which READS as "SESSION_TTL is the single
|
|
600
|
+
* source of session TTL" - what session-store.ts claims, and the mechanism
|
|
601
|
+
* by which the 7d-vs-3d zombie window was supposedly closed for good.
|
|
602
|
+
*
|
|
603
|
+
* IT WAS DEAD CODE. Better Auth ALWAYS supplies a ttl: every one of the
|
|
604
|
+
* eight secondaryStorage.set call sites in
|
|
605
|
+
* better-auth/dist/db/internal-adapter.mjs passes one explicitly (:36,
|
|
606
|
+
* :180, :189, :309, :320, :344, :553, :622). `??` fires only on
|
|
607
|
+
* null/undefined, so the fallback NEVER RAN. Every `ba:` record took Better
|
|
608
|
+
* Auth's 7d default - `session.expiresIn` is not configured in this file -
|
|
609
|
+
* while the canonical store stayed at SESSION_TTL (3d). That 4-day drift IS
|
|
610
|
+
* the zombie window the constant exists to prevent: the guard was bypassed
|
|
611
|
+
* by the very parameter written to defer to it.
|
|
612
|
+
*
|
|
613
|
+
* `Math.min`, NOT a hard SESSION_TTL: this same adapter also stores
|
|
614
|
+
* `verification:*` (:553, :622) and `active-sessions-*` (:180, :320, :344)
|
|
615
|
+
* records whose TTLs are legitimately SHORTER than 3d. Overwriting those
|
|
616
|
+
* would EXTEND them - turning a fix for over-long sessions into a bug that
|
|
617
|
+
* keeps verification codes alive for three days. Clamp DOWN, never up.
|
|
618
|
+
*
|
|
619
|
+
* Clamped down to 3d rather than raising the canonical store to 7d because
|
|
620
|
+
* SESSION_TTL matches the IDP refresh-token lifetime: a session outliving
|
|
621
|
+
* the refresh token sustaining it cannot be refreshed, so the extra days are
|
|
622
|
+
* a husk that reads as authenticated. Fail-closed beats ambiguous.
|
|
623
|
+
*
|
|
624
|
+
* DO NOT "fix" this by making storeIdpTokensInSession preserve Better
|
|
625
|
+
* Auth's 7d. That deletes the last remnant of QA 6952 and makes the drift
|
|
626
|
+
* universal. See test/ba-session-ttl-clamp.test.cjs.
|
|
627
|
+
*/
|
|
628
|
+
await redis.setex(redisKey, Math.min(ttl ?? SESSION_TTL, SESSION_TTL), payload);
|
|
502
629
|
},
|
|
503
630
|
delete: async (key: string) => {
|
|
504
631
|
try {
|