@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.
Files changed (33) hide show
  1. package/dist/api-handlers/mfa/totp-verify.d.ts +3 -1
  2. package/dist/api-handlers/mfa/totp-verify.js +22 -3
  3. package/dist/api-handlers/session/refresh-viability.js +2 -2
  4. package/dist/api-handlers/session/viability.js +1 -1
  5. package/dist/auth/auth-decision.js +27 -8
  6. package/dist/auth/better-auth.js +123 -1
  7. package/dist/auth/safe-callback.d.ts +63 -0
  8. package/dist/auth/safe-callback.js +112 -0
  9. package/dist/hooks/useProfile.d.ts +0 -1
  10. package/dist/lib/session-store.js +56 -7
  11. package/dist/middleware/create-middleware.js +45 -13
  12. package/dist/pages/profile/EnhancedProfilePage.js +38 -13
  13. package/dist/pages/verify-code/page.js +26 -1
  14. package/dist/routes/auth/viability.d.ts +2 -2
  15. package/dist/routes/auth/viability.js +2 -2
  16. package/dist/server/auth-guard.js +26 -1
  17. package/package.json +1 -1
  18. package/src/api-handlers/mfa/totp-verify.ts +24 -3
  19. package/src/api-handlers/session/refresh-viability.ts +2 -2
  20. package/src/api-handlers/session/viability.ts +1 -1
  21. package/src/auth/auth-decision.ts +28 -8
  22. package/src/auth/better-auth.ts +128 -1
  23. package/src/auth/safe-callback.ts +113 -0
  24. package/src/hooks/useProfile.ts +8 -1
  25. package/src/lib/session-store.ts +66 -7
  26. package/src/middleware/create-middleware.ts +45 -13
  27. package/src/pages/profile/EnhancedProfilePage.tsx +43 -14
  28. package/src/pages/verify-code/page.tsx +26 -1
  29. package/src/routes/auth/viability.ts +2 -2
  30. package/src/server/auth-guard.ts +27 -1
  31. package/dist/middleware/auth-decision.d.ts +0 -33
  32. package/dist/middleware/auth-decision.js +0 -65
  33. 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
- /** Ensures callback URLs never point to auth pages (prevents redirect loops) */
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
- if (pathname.startsWith('/account-auth/')) {
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
- // --- Handle stale session early (cookie exists but session doesn't) ---
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
- const safeCallback = getSafeCallbackUrl(pathname);
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
- contact_info: rawProfile.contact_information ? {
109
- secondary_email: rawProfile.contact_information.secondary_email,
110
- secondary_phone: rawProfile.contact_information.secondary_phone,
111
- website: rawProfile.contact_information.website,
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.contact_information ? {
114
- address_line_1: rawProfile.contact_information.addressLine1,
115
- address_line_2: rawProfile.contact_information.addressLine2,
116
- city: rawProfile.contact_information.city,
117
- state_id: rawProfile.contact_information.stateId,
118
- state_name: rawProfile.contact_information.stateName,
119
- postal_code: rawProfile.contact_information.postalCode,
120
- country_code: rawProfile.contact_information.countryCode,
121
- country_name: rawProfile.contact_information.countryName,
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
- const callbackUrl = searchParams?.get('callbackUrl') || '/dashboard';
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: any;
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: any;
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 ?? session?.twoFactorComplete ?? false;
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 ?? session?.twoFactorComplete ?? false;
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
- const mfaVerified = sessionData.mfaVerified ?? sessionData.twoFactorComplete ?? false;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payez/next-mvp",
3
- "version": "4.4.1",
3
+ "version": "4.4.2",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -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
- await transitionTo2FASession(
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
- console.log('[VERIFY_TOTP] Session transitioned to 2FA-verified');
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 ?? (sessionData as any)?.twoFactorComplete ?? false }, { status: 200 });
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 ?? (sessionData as any).twoFactorComplete ?? false, userId: sessionData.userId, refreshInProgress: false });
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 ?? (sessionData as any).twoFactorComplete ?? false;
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
- const safeCallbackUrl = pathname.startsWith('/account-auth/') ? '/' : pathname;
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
- // CRITICAL FIX: Never use auth pages as callback URLs
149
- const safeCallbackUrl = callbackUrl.startsWith('/account-auth/') ? '/' : callbackUrl;
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
- // CRITICAL FIX: Never use auth pages as callback URLs
197
- const safeCallbackUrl = callbackUrl.startsWith('/account-auth/') ? '/' : 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
- // CRITICAL FIX: Never redirect back to login page (prevents infinite loop)
216
- const safeCallbackUrl = callbackUrl.startsWith('/account-auth/') ? '/' : callbackUrl;
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
@@ -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
- await redis.setex(`ba:${appSlug}:${key}`, ttl ?? SESSION_TTL, value);
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 {