@payez/next-mvp 4.4.1 → 4.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/api/auth-handler.js +13 -4
  2. package/dist/api-handlers/mfa/totp-verify.d.ts +3 -1
  3. package/dist/api-handlers/mfa/totp-verify.js +22 -3
  4. package/dist/api-handlers/session/refresh-viability.js +2 -2
  5. package/dist/api-handlers/session/viability.js +1 -1
  6. package/dist/auth/auth-decision.js +27 -8
  7. package/dist/auth/better-auth.js +123 -1
  8. package/dist/auth/safe-callback.d.ts +92 -0
  9. package/dist/auth/safe-callback.js +183 -0
  10. package/dist/hooks/useProfile.d.ts +0 -1
  11. package/dist/lib/acr.d.ts +64 -0
  12. package/dist/lib/acr.js +109 -0
  13. package/dist/lib/session-store.js +56 -7
  14. package/dist/middleware/create-middleware.js +45 -13
  15. package/dist/pages/profile/EnhancedProfilePage.js +38 -13
  16. package/dist/pages/verify-code/page.js +26 -1
  17. package/dist/routes/auth/viability.d.ts +2 -2
  18. package/dist/routes/auth/viability.js +2 -2
  19. package/dist/server/auth-guard.js +26 -1
  20. package/package.json +2 -1
  21. package/scripts/module-reach.mjs +280 -0
  22. package/src/api/auth-handler.ts +19 -4
  23. package/src/api-handlers/mfa/totp-verify.ts +24 -3
  24. package/src/api-handlers/session/refresh-viability.ts +2 -2
  25. package/src/api-handlers/session/viability.ts +1 -1
  26. package/src/auth/auth-decision.ts +28 -8
  27. package/src/auth/better-auth.ts +128 -1
  28. package/src/auth/safe-callback.ts +185 -0
  29. package/src/hooks/useProfile.ts +8 -1
  30. package/src/lib/acr.ts +109 -0
  31. package/src/lib/session-store.ts +66 -7
  32. package/src/middleware/create-middleware.ts +45 -13
  33. package/src/pages/profile/EnhancedProfilePage.tsx +43 -14
  34. package/src/pages/verify-code/page.tsx +26 -1
  35. package/src/routes/auth/viability.ts +2 -2
  36. package/src/server/auth-guard.ts +27 -1
  37. package/dist/middleware/auth-decision.d.ts +0 -33
  38. package/dist/middleware/auth-decision.js +0 -65
  39. package/src/middleware/auth-decision.ts +0 -43
@@ -0,0 +1,64 @@
1
+ /**
2
+ * CARD 375639 - the one place this package decides what an ABSENT assurance
3
+ * signal means.
4
+ *
5
+ * Card 375276 fixed this question inside `payez_idp_admin_web/src` (commit
6
+ * d9c6ac0, BAPert R980 remedy (a)) and fenced its own scope explicitly:
7
+ *
8
+ * "fallbacks on the stored `session.authenticationLevel` (refresh lane)
9
+ * are deliberately NOT covered ... That gap is 375276's remaining half."
10
+ *
11
+ * `src/api/auth-handler.ts` IS that remaining half, and it is in the shared
12
+ * package rather than in one app, so every consumer of the published
13
+ * `@payez/next-mvp/api/auth-handler` export inherits it.
14
+ *
15
+ * WHY BOTH SIGNALS LIVE IN ONE MODULE
16
+ * -----------------------------------
17
+ * `acr` (how strong the authentication was) and `amr` (which methods were
18
+ * used) are sent to the IDP together in a single refresh body. Defaulting one
19
+ * without the other produces an INCOHERENT assertion - "no second factor
20
+ * happened, and the method used was mfa" - so they are resolved side by side
21
+ * and a change to one is forced to confront the other.
22
+ *
23
+ * MEASURED CONVENTION IN THIS PACKAGE (census, 2026-09-11)
24
+ * -------------------------------------------------------
25
+ * acr absent-defaults: '0' x1, '1' x3, '2' x1 <- the '2' was auth-handler
26
+ * amr absent-defaults: [] x5, ['pwd','mfa'] x1 <- the list was auth-handler
27
+ *
28
+ * Both fail-open values were outliers at a single site, not the convention.
29
+ * These constants adopt the majority, fail-closed convention.
30
+ */
31
+ /**
32
+ * The assurance level used when the session and token say NOTHING about it.
33
+ *
34
+ * '1' = a single factor. It does not assert that a second factor happened.
35
+ * It must never be '2' (or higher): that is the value the IDP reads as
36
+ * "a second factor was completed", and an absent claim is not evidence of one.
37
+ */
38
+ export declare const ACR_WHEN_ABSENT = "1";
39
+ /**
40
+ * The authentication-methods list used when nothing is known.
41
+ *
42
+ * Empty asserts nothing. It must never contain 'mfa' (or any second-factor
43
+ * method): claiming a method that was never exercised is the same fabrication
44
+ * as ACR '2', and because the two corroborate each other a downstream check
45
+ * that cross-validates amr against acr would accept the fabricated pair.
46
+ */
47
+ export declare const AMR_WHEN_ABSENT: readonly string[];
48
+ /**
49
+ * Resolve an assurance level from any number of candidate sources, in
50
+ * priority order, falling back to the level that asserts no second factor.
51
+ *
52
+ * Accepts the candidates the refresh lane actually has: the stored
53
+ * `session.authenticationLevel` first, then the `acr` claim on the token.
54
+ */
55
+ export declare function resolveAcrLevel(...candidates: unknown[]): string;
56
+ /**
57
+ * Resolve the authentication-methods list.
58
+ *
59
+ * `raw` may arrive as a JSON string (the shape stored on the token) or as an
60
+ * array (the shape held on the session). Anything unparseable resolves to the
61
+ * empty list rather than to an assumed set of methods - a malformed claim is
62
+ * not evidence that mfa happened.
63
+ */
64
+ export declare function resolveAuthMethods(...candidates: unknown[]): string[];
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ /**
3
+ * CARD 375639 - the one place this package decides what an ABSENT assurance
4
+ * signal means.
5
+ *
6
+ * Card 375276 fixed this question inside `payez_idp_admin_web/src` (commit
7
+ * d9c6ac0, BAPert R980 remedy (a)) and fenced its own scope explicitly:
8
+ *
9
+ * "fallbacks on the stored `session.authenticationLevel` (refresh lane)
10
+ * are deliberately NOT covered ... That gap is 375276's remaining half."
11
+ *
12
+ * `src/api/auth-handler.ts` IS that remaining half, and it is in the shared
13
+ * package rather than in one app, so every consumer of the published
14
+ * `@payez/next-mvp/api/auth-handler` export inherits it.
15
+ *
16
+ * WHY BOTH SIGNALS LIVE IN ONE MODULE
17
+ * -----------------------------------
18
+ * `acr` (how strong the authentication was) and `amr` (which methods were
19
+ * used) are sent to the IDP together in a single refresh body. Defaulting one
20
+ * without the other produces an INCOHERENT assertion - "no second factor
21
+ * happened, and the method used was mfa" - so they are resolved side by side
22
+ * and a change to one is forced to confront the other.
23
+ *
24
+ * MEASURED CONVENTION IN THIS PACKAGE (census, 2026-09-11)
25
+ * -------------------------------------------------------
26
+ * acr absent-defaults: '0' x1, '1' x3, '2' x1 <- the '2' was auth-handler
27
+ * amr absent-defaults: [] x5, ['pwd','mfa'] x1 <- the list was auth-handler
28
+ *
29
+ * Both fail-open values were outliers at a single site, not the convention.
30
+ * These constants adopt the majority, fail-closed convention.
31
+ */
32
+ Object.defineProperty(exports, "__esModule", { value: true });
33
+ exports.AMR_WHEN_ABSENT = exports.ACR_WHEN_ABSENT = void 0;
34
+ exports.resolveAcrLevel = resolveAcrLevel;
35
+ exports.resolveAuthMethods = resolveAuthMethods;
36
+ /**
37
+ * The assurance level used when the session and token say NOTHING about it.
38
+ *
39
+ * '1' = a single factor. It does not assert that a second factor happened.
40
+ * It must never be '2' (or higher): that is the value the IDP reads as
41
+ * "a second factor was completed", and an absent claim is not evidence of one.
42
+ */
43
+ exports.ACR_WHEN_ABSENT = '1';
44
+ /**
45
+ * The authentication-methods list used when nothing is known.
46
+ *
47
+ * Empty asserts nothing. It must never contain 'mfa' (or any second-factor
48
+ * method): claiming a method that was never exercised is the same fabrication
49
+ * as ACR '2', and because the two corroborate each other a downstream check
50
+ * that cross-validates amr against acr would accept the fabricated pair.
51
+ */
52
+ exports.AMR_WHEN_ABSENT = [];
53
+ /**
54
+ * True when an assurance signal carries no information.
55
+ *
56
+ * Deliberately NOT `!value`. The `||` idiom this replaces cannot tell
57
+ * "the token said nothing" from "the token said 0", and numeric 0 is a
58
+ * meaningful assurance level (unauthenticated) that must survive as '0'
59
+ * rather than being promoted to the fallback.
60
+ */
61
+ function isAbsent(value) {
62
+ return value === undefined || value === null || value === '';
63
+ }
64
+ /**
65
+ * Resolve an assurance level from any number of candidate sources, in
66
+ * priority order, falling back to the level that asserts no second factor.
67
+ *
68
+ * Accepts the candidates the refresh lane actually has: the stored
69
+ * `session.authenticationLevel` first, then the `acr` claim on the token.
70
+ */
71
+ function resolveAcrLevel(...candidates) {
72
+ for (const candidate of candidates) {
73
+ if (!isAbsent(candidate)) {
74
+ return String(candidate);
75
+ }
76
+ }
77
+ return exports.ACR_WHEN_ABSENT;
78
+ }
79
+ /**
80
+ * Resolve the authentication-methods list.
81
+ *
82
+ * `raw` may arrive as a JSON string (the shape stored on the token) or as an
83
+ * array (the shape held on the session). Anything unparseable resolves to the
84
+ * empty list rather than to an assumed set of methods - a malformed claim is
85
+ * not evidence that mfa happened.
86
+ */
87
+ function resolveAuthMethods(...candidates) {
88
+ for (const candidate of candidates) {
89
+ if (isAbsent(candidate))
90
+ continue;
91
+ if (Array.isArray(candidate)) {
92
+ return candidate.map(String);
93
+ }
94
+ if (typeof candidate === 'string') {
95
+ try {
96
+ const parsed = JSON.parse(candidate);
97
+ if (Array.isArray(parsed)) {
98
+ return parsed.map(String);
99
+ }
100
+ }
101
+ catch {
102
+ // A malformed amr claim asserts nothing. Fall through to the next
103
+ // candidate rather than inventing methods.
104
+ continue;
105
+ }
106
+ }
107
+ }
108
+ return [...exports.AMR_WHEN_ABSENT];
109
+ }
@@ -389,6 +389,26 @@ async function transitionTo2FASession(sessionToken, tokens, mfaMethod) {
389
389
  console.log('[transitionTo2FASession] Extracted bearerKeyId (kid) from new JWT header:', bearerKeyId);
390
390
  }
391
391
  }
392
+ // 339563: derive the expiry ONCE - it is both a field to write and the
393
+ // evidence test below. Re-deriving it twice invites the two reads to drift.
394
+ const mfaLifetime = mfaLifetimeFromAccessToken(newAccessToken);
395
+ // *** R908(e). ABSENCE OF THE EXPIRY IS NOT A COMPLETED CEREMONY. ***
396
+ //
397
+ // `mfaExpiresAt` is the only evidence we hold that a real MFA ceremony took
398
+ // place and when it lapses. When the stepped-up token carries no `mfa_expires`
399
+ // claim we do NOT know that this session is MFA-verified, and R908(e) binds
400
+ // the consumer to the third arm:
401
+ //
402
+ // producers degrade -> consumers RE-CHALLENGE -> nobody infers a
403
+ // negative from a silence.
404
+ //
405
+ // So: never mint `mfaVerified: true` off a token that carries no expiry (that
406
+ // is forging evidence of a ceremony we did not observe - 12188/12390), and
407
+ // never hard-refuse the user either (R486: null is "cannot determine", not
408
+ // "not 2FA-backed"). Leave the session un-verified and set `nextStep:
409
+ // 'mfa_verify'`, which is the field the gate already reads to mean "a usable
410
+ // method exists; challenge them" (SessionModel.ts:136).
411
+ const mfaEvidencePresent = mfaLifetime.mfaExpiresAt !== undefined;
392
412
  // Support both old and new field names in input
393
413
  // CRITICAL: Set BOTH mfaVerified (new) AND twoFactorComplete (legacy) for compatibility
394
414
  // auth.ts session callback reads twoFactorComplete, so we must set it here
@@ -396,15 +416,28 @@ async function transitionTo2FASession(sessionToken, tokens, mfaMethod) {
396
416
  idpAccessToken: newAccessToken,
397
417
  idpRefreshToken: tokens.idpRefreshToken || tokens.refreshToken,
398
418
  idpAccessTokenExpires: tokens.idpAccessTokenExpires || tokens.accessTokenExpires,
399
- mfaVerified: true,
400
- twoFactorComplete: true, // Legacy field - required by auth.ts session callback
419
+ mfaVerified: mfaEvidencePresent,
420
+ twoFactorComplete: mfaEvidencePresent, // Legacy field - required by auth.ts session callback
401
421
  mfaMethod: mfaMethod,
402
422
  // Update bearerKeyId if extracted from new token
403
423
  ...(bearerKeyId && { bearerKeyId }),
404
424
  // 339563: write the expiry in the SAME update that sets the flag. We already
405
425
  // hold the stepped-up token here and already decode its header above.
406
- ...mfaLifetimeFromAccessToken(newAccessToken),
426
+ ...mfaLifetime,
427
+ // R908(e): no evidence -> route to the challenge, do not grant and do not eject.
428
+ ...(mfaEvidencePresent ? {} : { nextStep: 'mfa_verify' }),
407
429
  };
430
+ if (!mfaEvidencePresent) {
431
+ console.warn('[transitionTo2FASession] R908(e): the stepped-up token carries NO mfa_expires ' +
432
+ 'claim, so there is no evidence of an MFA ceremony to record. NOT setting ' +
433
+ 'mfaVerified; setting nextStep=mfa_verify so the consumer re-challenges. ' +
434
+ 'If this fires for a lane that DID complete a real ceremony, the defect is ' +
435
+ 'on the emit side (R908(b)) - do not "fix" it by minting the flag here.', {
436
+ sessionToken: sessionToken?.substring(0, 8) + '...',
437
+ mfaMethod,
438
+ hasAccessToken: !!newAccessToken,
439
+ });
440
+ }
408
441
  const refreshExpires = tokens.idpRefreshTokenExpires || tokens.refreshTokenExpires;
409
442
  if (refreshExpires !== undefined) {
410
443
  updates.idpRefreshTokenExpires = refreshExpires;
@@ -415,11 +448,13 @@ async function transitionTo2FASession(sessionToken, tokens, mfaMethod) {
415
448
  mfaMethod: updates.mfaMethod,
416
449
  hasIdpAccessToken: !!updates.idpAccessToken,
417
450
  // 339563: surface the expiry we derived. `mfaExpiresAt: undefined` here is
418
- // the passwordless/primary-credential lane (token carried no mfa_expires),
419
- // NOT a bug - but it IS the case the consumers currently fail OPEN on, so
420
- // it needs to be visible in the log rather than inferred from its absence.
451
+ // the passwordless/primary-credential lane (token carried no mfa_expires).
452
+ // Since R908(e) that absence no longer fails OPEN: it withholds mfaVerified
453
+ // and sets nextStep=mfa_verify instead, so log both together - the pair is
454
+ // the whole decision.
421
455
  mfaExpiresAt: updates.mfaExpiresAt,
422
456
  mfaExpiresAtISO: updates.mfaExpiresAt ? new Date(updates.mfaExpiresAt).toISOString() : undefined,
457
+ nextStep: updates.nextStep,
423
458
  });
424
459
  const result = await updateSession(sessionToken, updates);
425
460
  console.log('[transitionTo2FASession] Result:', {
@@ -477,9 +512,23 @@ async function mark2FAComplete(sessionToken, accessToken) {
477
512
  'this session has no MFA expiry. Expected on a primary-credential lane ' +
478
513
  '(e.g. passwordless email); unexpected on an ordinary 2FA completion.');
479
514
  }
515
+ // R908(e): identical rule to transitionTo2FASession above - no expiry derived
516
+ // means no observed ceremony, so we re-challenge rather than mint the flag.
517
+ // This path matters more than its sibling: `mark2FAComplete(sessionToken)`
518
+ // with the accessToken omitted used to write a bare `mfaVerified: true` that
519
+ // NO consumer could ever expire, and it is reachable from route handlers that
520
+ // take their tokens from the request body.
521
+ const mfaEvidencePresent = mfaLifetime.mfaExpiresAt !== undefined;
522
+ if (!mfaEvidencePresent) {
523
+ console.warn('[mark2FAComplete] R908(e): no mfa_expires could be derived, so mfaVerified ' +
524
+ 'is NOT being set. Writing nextStep=mfa_verify instead so the consumer ' +
525
+ 're-challenges. A caller that needs the grant must pass the stepped-up ' +
526
+ 'access token that carries the claim.');
527
+ }
480
528
  return updateSession(sessionToken, {
481
- mfaVerified: true,
529
+ mfaVerified: mfaEvidencePresent,
482
530
  ...mfaLifetime,
531
+ ...(mfaEvidencePresent ? {} : { nextStep: 'mfa_verify' }),
483
532
  });
484
533
  }
485
534
  /**
@@ -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.3",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -24,6 +24,7 @@
24
24
  "dev:local": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/dev-local.ps1",
25
25
  "dev:broker": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/dev-broker.ps1",
26
26
  "check:publish-health": "node scripts/check-publish-health.mjs",
27
+ "census:reach": "node scripts/module-reach.mjs",
27
28
  "build:incremental": "npx tsc --project tsconfig.json"
28
29
  },
29
30
  "exports": {