@payez/next-mvp 4.4.0 → 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 (34) 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/scripts/check-publish-health.mjs +53 -11
  19. package/src/api-handlers/mfa/totp-verify.ts +24 -3
  20. package/src/api-handlers/session/refresh-viability.ts +2 -2
  21. package/src/api-handlers/session/viability.ts +1 -1
  22. package/src/auth/auth-decision.ts +28 -8
  23. package/src/auth/better-auth.ts +128 -1
  24. package/src/auth/safe-callback.ts +113 -0
  25. package/src/hooks/useProfile.ts +8 -1
  26. package/src/lib/session-store.ts +66 -7
  27. package/src/middleware/create-middleware.ts +45 -13
  28. package/src/pages/profile/EnhancedProfilePage.tsx +43 -14
  29. package/src/pages/verify-code/page.tsx +26 -1
  30. package/src/routes/auth/viability.ts +2 -2
  31. package/src/server/auth-guard.ts +27 -1
  32. package/dist/middleware/auth-decision.d.ts +0 -33
  33. package/dist/middleware/auth-decision.js +0 -65
  34. 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.0",
3
+ "version": "4.4.2",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -43,6 +43,10 @@ function run(args) {
43
43
  }).trim();
44
44
  }
45
45
 
46
+ function sleepSync(ms) {
47
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
48
+ }
49
+
46
50
  /** Numeric-aware semver compare. Returns >0 if a > b, 0 if equal, <0 if a < b. */
47
51
  function compareVersions(a, b) {
48
52
  const norm = (v) => String(v).split('-')[0].split('.').map((n) => parseInt(n, 10) || 0);
@@ -74,19 +78,57 @@ try {
74
78
  authUser = null;
75
79
  }
76
80
 
81
+ // READ THE REGISTRY, NOT npm's CACHE (measured 2026-09-10, Nextpert-Scout).
82
+ //
83
+ // This script is wired as `postpublish` in package.json. postpublish runs ONLY after
84
+ // a SUCCESSFUL upload — so the one moment it is guaranteed to run is the one moment
85
+ // npm's metadata cache is guaranteed to be stale. Publishing 4.4.0 produced:
86
+ //
87
+ // [publish-health] FAIL: the registry LAGS local by at least one version.
88
+ // [publish-health] 4.4.0 is built locally but the registry serves 4.1.21.
89
+ // npm error code 1
90
+ //
91
+ // 4.4.0 was already live. THE HONEST REACTION TO THAT OUTPUT IS TO PUBLISH AGAIN,
92
+ // which is the dangerous action — and against a registry that refuses republication
93
+ // it burns a version number, which is how a version string ends up naming two trees.
94
+ //
95
+ // `--prefer-online` forces revalidation. The bounded retry covers real propagation
96
+ // delay, which is seconds, not weeks.
97
+ //
98
+ // THIS DOES NOT SOFTEN THE CHECK, and it must not be "helpfully" turned into a warning:
99
+ // a genuine lag (the 171900 failure mode — a dead credential leaving versions
100
+ // unpublished for WEEKS) still exhausts every attempt and still exits non-zero. The
101
+ // retry changes how accurately we read the registry, never what counts as a failure.
102
+ const REGISTRY_ATTEMPTS = 4;
103
+ const REGISTRY_RETRY_MS = 2000;
104
+
77
105
  let registryVersion = null;
78
- try {
79
- registryVersion = run(['view', name, 'version']);
80
- } catch (err) {
81
- const msg = String(err.stderr || err.message || '');
82
- if (/E404|404 Not Found/.test(msg)) {
83
- console.error(`[publish-health] FAIL: ${name} has NEVER been published to the registry.`);
84
- console.error(`[publish-health] local=${localVersion} registry=<absent>`);
85
- process.exit(1);
106
+ for (let attempt = 1; attempt <= REGISTRY_ATTEMPTS; attempt++) {
107
+ try {
108
+ registryVersion = run(['view', name, 'version', '--prefer-online']);
109
+ } catch (err) {
110
+ const msg = String(err.stderr || err.message || '');
111
+ if (/E404|404 Not Found/.test(msg)) {
112
+ console.error(`[publish-health] FAIL: ${name} has NEVER been published to the registry.`);
113
+ console.error(`[publish-health] local=${localVersion} registry=<absent>`);
114
+ process.exit(1);
115
+ }
116
+ console.error(`[publish-health] INCONCLUSIVE: could not query the registry: ${msg.split('\n')[0]}`);
117
+ console.error('[publish-health] Treat as UNKNOWN, not as a pass.');
118
+ process.exit(2);
119
+ }
120
+
121
+ // Stop as soon as the registry is at or ahead of local. Only an apparent lag is
122
+ // worth re-reading, and only because THIS script's own hook makes a stale read likely.
123
+ if (compareVersions(localVersion, registryVersion) <= 0) break;
124
+
125
+ if (attempt < REGISTRY_ATTEMPTS) {
126
+ console.log(
127
+ `[publish-health] registry says ${registryVersion}, local is ${localVersion} — ` +
128
+ `re-reading (${attempt}/${REGISTRY_ATTEMPTS - 1}) in case this is post-publish propagation lag.`,
129
+ );
130
+ sleepSync(REGISTRY_RETRY_MS);
86
131
  }
87
- console.error(`[publish-health] INCONCLUSIVE: could not query the registry: ${msg.split('\n')[0]}`);
88
- console.error('[publish-health] Treat as UNKNOWN, not as a pass.');
89
- process.exit(2);
90
132
  }
91
133
 
92
134
  const delta = compareVersions(localVersion, registryVersion);
@@ -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