@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
@@ -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 {
@@ -0,0 +1,113 @@
1
+ /**
2
+ * ONE definition of "is this callback target safe to navigate to", for the
3
+ * whole package (375656).
4
+ *
5
+ * *** THIS DELEGATES PARSING TO THE URL PARSER. DO NOT REPLACE IT WITH
6
+ * *** startsWith() CHECKS. THAT APPROACH FAILED FOUR TIMES.
7
+ *
8
+ * WHY THIS FILE EXISTS: `@payez/next-mvp` shipped THREE bodies named
9
+ * `makeAuthDecision`, each carrying its own `callbackUrl.startsWith('/account-auth/')`
10
+ * test. That test is a REDIRECT-LOOP guard sitting in the seat where an ORIGIN
11
+ * check belongs - its own comment says "prevents infinite loop" - so
12
+ * `https://evil.example.com` does not start with `/account-auth/`, passes
13
+ * through untouched, and becomes the redirect target. The exported copy
14
+ * (`auth/auth-decision.ts`, re-exported from `index.ts` and `edge/middleware.ts`)
15
+ * is public API, so that is a published open redirect independent of who calls it.
16
+ *
17
+ * HISTORY, because it is the whole argument for the current shape (267097 /
18
+ * 267174 / 267448), carried over verbatim from the portal's `src/lib/safe-callback.ts`
19
+ * where this guard was proven (0 leaks / 21 hostile inputs):
20
+ *
21
+ * v1 `startsWith('/') && !startsWith('//')` -> bypassed by /BACKSLASH host
22
+ * v2 + `!startsWith('/' + BACKSLASH)` -> bypassed by /TAB/host
23
+ * ... and /LF/host, /CR/host
24
+ *
25
+ * Both bypasses work the same way: the browser NORMALISES the string AFTER the
26
+ * guard has finished looking at it. The WHATWG URL parser converts backslashes
27
+ * to forward slashes and STRIPS all ASCII tab, LF and CR before resolving. So a
28
+ * prefix check can always be defeated by a character that is not there yet when
29
+ * it runs. Each fix bought one character. The list was never provably complete.
30
+ *
31
+ * SO: stop re-implementing the parser and ask it instead. Resolve the input
32
+ * against a synthetic base and compare ORIGINS. Anything that escapes the
33
+ * origin - however it is spelled, including spellings nobody has found yet -
34
+ * fails one comparison. The returned value is the parser's own NORMALISED
35
+ * output, so an accepted path cannot smuggle a control character onward either.
36
+ *
37
+ * A synthetic base is used deliberately so this works identically in a server
38
+ * component (no window), on the client, and in the edge runtime.
39
+ */
40
+
41
+ /** Where we send anyone whose requested target we will not honour. */
42
+ export const DEFAULT_CALLBACK = '/';
43
+
44
+ /** The auth surface. A callback pointing back into it causes a redirect cycle. */
45
+ const AUTH_PREFIX = '/account-auth/';
46
+
47
+ /**
48
+ * Synthetic, unreachable base. Only ever used as the resolution origin for the
49
+ * comparison below - never emitted, never navigated to. `.invalid` is reserved
50
+ * by RFC 2606 and can never resolve.
51
+ */
52
+ const BASE = 'http://safe-callback.invalid';
53
+
54
+ /**
55
+ * The value to navigate to: a same-origin relative path, NORMALISED by the URL
56
+ * parser, or `fallback` for anything that escapes the origin or loops back into
57
+ * the auth surface. Never throws.
58
+ */
59
+ export function safeCallbackPath(raw: unknown, fallback: string = DEFAULT_CALLBACK): string {
60
+ if (typeof raw !== 'string' || raw.length === 0) return fallback;
61
+
62
+ let url: URL;
63
+ try {
64
+ url = new URL(raw, BASE);
65
+ } catch {
66
+ return fallback;
67
+ }
68
+
69
+ // Anything that resolved to a different origin escaped, however it was spelled:
70
+ // absolute, protocol-relative //host, backslash /\host, tab/LF/CR variants, or
71
+ // a scheme like javascript: (whose origin is opaque and never equals BASE).
72
+ if (url.origin !== BASE) return fallback;
73
+
74
+ // Loopbacks into the auth surface cause redirect cycles.
75
+ if (url.pathname.startsWith(AUTH_PREFIX)) return fallback;
76
+
77
+ // The parser's normalised output - control characters already stripped.
78
+ return url.pathname + url.search + url.hash;
79
+ }
80
+
81
+ /** True when `raw` is a callback target we are willing to honour. */
82
+ export function isSafeCallbackPath(raw: unknown): raw is string {
83
+ if (typeof raw !== 'string' || raw.length === 0) return false;
84
+ try {
85
+ const url = new URL(raw, BASE);
86
+ return url.origin === BASE && !url.pathname.startsWith(AUTH_PREFIX);
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Pick the post-login destination: an explicit `?callbackUrl=` when it is one we
94
+ * will honour, otherwise the current path, otherwise `/`.
95
+ *
96
+ * This replaces the package's three hand-rolled `getSafeCallbackUrl` /
97
+ * `getCallbackUrl` bodies. Note the ARGUMENT ORDER DEPENDENCY those had: they
98
+ * tested `pathname` FIRST and returned early, so a caller on the login page
99
+ * never reached the attacker-controlled parameter at all - safe, but only
100
+ * because that caller happened to pass a value that tripped the function's own
101
+ * first guard. Nothing in the code named that dependency, and an argument
102
+ * reorder would have opened it. Here BOTH inputs are validated, so the
103
+ * protection no longer depends on which one is passed.
104
+ */
105
+ export function resolveCallbackDestination(
106
+ pathname: string,
107
+ searchParams?: URLSearchParams | null,
108
+ fallback: string = DEFAULT_CALLBACK
109
+ ): string {
110
+ const requested = searchParams?.get('callbackUrl');
111
+ if (isSafeCallbackPath(requested)) return safeCallbackPath(requested, fallback);
112
+ return safeCallbackPath(pathname, fallback);
113
+ }
@@ -49,7 +49,14 @@ export type ProfileResponse = {
49
49
  phone_confirmed?: boolean;
50
50
  two_factor_enabled?: boolean;
51
51
  roles?: string[];
52
- contact_information?: Record<string, any>;
52
+ // REMOVED: `contact_information?: Record<string, any>` - the ADMIN DTO's field
53
+ // name (AdminUserProfileResponse), never emitted by /api/account/profile. This
54
+ // permissive declaration is WHY the wrong mapper type-checked for so long: the
55
+ // correct `contact_info` and `address` shapes were already declared right here,
56
+ // and this `Record<string, any>` sat beside them as an off switch, accepting
57
+ // `contact_information.addressLine1` on a path that could never carry data.
58
+ // Found by BAPert (41365) as the residual of my own half-sweep - I deleted the
59
+ // twin of this line from EnhancedProfilePage and left this one standing.
53
60
  [key: string]: any;
54
61
  };
55
62
 
@@ -434,6 +434,28 @@ export async function transitionTo2FASession(
434
434
  }
435
435
  }
436
436
 
437
+ // 339563: derive the expiry ONCE - it is both a field to write and the
438
+ // evidence test below. Re-deriving it twice invites the two reads to drift.
439
+ const mfaLifetime = mfaLifetimeFromAccessToken(newAccessToken);
440
+
441
+ // *** R908(e). ABSENCE OF THE EXPIRY IS NOT A COMPLETED CEREMONY. ***
442
+ //
443
+ // `mfaExpiresAt` is the only evidence we hold that a real MFA ceremony took
444
+ // place and when it lapses. When the stepped-up token carries no `mfa_expires`
445
+ // claim we do NOT know that this session is MFA-verified, and R908(e) binds
446
+ // the consumer to the third arm:
447
+ //
448
+ // producers degrade -> consumers RE-CHALLENGE -> nobody infers a
449
+ // negative from a silence.
450
+ //
451
+ // So: never mint `mfaVerified: true` off a token that carries no expiry (that
452
+ // is forging evidence of a ceremony we did not observe - 12188/12390), and
453
+ // never hard-refuse the user either (R486: null is "cannot determine", not
454
+ // "not 2FA-backed"). Leave the session un-verified and set `nextStep:
455
+ // 'mfa_verify'`, which is the field the gate already reads to mean "a usable
456
+ // method exists; challenge them" (SessionModel.ts:136).
457
+ const mfaEvidencePresent = mfaLifetime.mfaExpiresAt !== undefined;
458
+
437
459
  // Support both old and new field names in input
438
460
  // CRITICAL: Set BOTH mfaVerified (new) AND twoFactorComplete (legacy) for compatibility
439
461
  // auth.ts session callback reads twoFactorComplete, so we must set it here
@@ -441,16 +463,33 @@ export async function transitionTo2FASession(
441
463
  idpAccessToken: newAccessToken,
442
464
  idpRefreshToken: tokens.idpRefreshToken || tokens.refreshToken,
443
465
  idpAccessTokenExpires: tokens.idpAccessTokenExpires || tokens.accessTokenExpires,
444
- mfaVerified: true,
445
- twoFactorComplete: true, // Legacy field - required by auth.ts session callback
466
+ mfaVerified: mfaEvidencePresent,
467
+ twoFactorComplete: mfaEvidencePresent, // Legacy field - required by auth.ts session callback
446
468
  mfaMethod: mfaMethod,
447
469
  // Update bearerKeyId if extracted from new token
448
470
  ...(bearerKeyId && { bearerKeyId }),
449
471
  // 339563: write the expiry in the SAME update that sets the flag. We already
450
472
  // hold the stepped-up token here and already decode its header above.
451
- ...mfaLifetimeFromAccessToken(newAccessToken),
473
+ ...mfaLifetime,
474
+ // R908(e): no evidence -> route to the challenge, do not grant and do not eject.
475
+ ...(mfaEvidencePresent ? {} : { nextStep: 'mfa_verify' as const }),
452
476
  };
453
477
 
478
+ if (!mfaEvidencePresent) {
479
+ console.warn(
480
+ '[transitionTo2FASession] R908(e): the stepped-up token carries NO mfa_expires ' +
481
+ 'claim, so there is no evidence of an MFA ceremony to record. NOT setting ' +
482
+ 'mfaVerified; setting nextStep=mfa_verify so the consumer re-challenges. ' +
483
+ 'If this fires for a lane that DID complete a real ceremony, the defect is ' +
484
+ 'on the emit side (R908(b)) - do not "fix" it by minting the flag here.',
485
+ {
486
+ sessionToken: sessionToken?.substring(0, 8) + '...',
487
+ mfaMethod,
488
+ hasAccessToken: !!newAccessToken,
489
+ }
490
+ );
491
+ }
492
+
454
493
  const refreshExpires = tokens.idpRefreshTokenExpires || tokens.refreshTokenExpires;
455
494
  if (refreshExpires !== undefined) {
456
495
  updates.idpRefreshTokenExpires = refreshExpires;
@@ -462,11 +501,13 @@ export async function transitionTo2FASession(
462
501
  mfaMethod: updates.mfaMethod,
463
502
  hasIdpAccessToken: !!updates.idpAccessToken,
464
503
  // 339563: surface the expiry we derived. `mfaExpiresAt: undefined` here is
465
- // the passwordless/primary-credential lane (token carried no mfa_expires),
466
- // NOT a bug - but it IS the case the consumers currently fail OPEN on, so
467
- // it needs to be visible in the log rather than inferred from its absence.
504
+ // the passwordless/primary-credential lane (token carried no mfa_expires).
505
+ // Since R908(e) that absence no longer fails OPEN: it withholds mfaVerified
506
+ // and sets nextStep=mfa_verify instead, so log both together - the pair is
507
+ // the whole decision.
468
508
  mfaExpiresAt: updates.mfaExpiresAt,
469
509
  mfaExpiresAtISO: updates.mfaExpiresAt ? new Date(updates.mfaExpiresAt).toISOString() : undefined,
510
+ nextStep: (updates as any).nextStep,
470
511
  });
471
512
 
472
513
  const result = await updateSession(sessionToken, updates);
@@ -543,9 +584,27 @@ export async function mark2FAComplete(
543
584
  );
544
585
  }
545
586
 
587
+ // R908(e): identical rule to transitionTo2FASession above - no expiry derived
588
+ // means no observed ceremony, so we re-challenge rather than mint the flag.
589
+ // This path matters more than its sibling: `mark2FAComplete(sessionToken)`
590
+ // with the accessToken omitted used to write a bare `mfaVerified: true` that
591
+ // NO consumer could ever expire, and it is reachable from route handlers that
592
+ // take their tokens from the request body.
593
+ const mfaEvidencePresent = mfaLifetime.mfaExpiresAt !== undefined;
594
+
595
+ if (!mfaEvidencePresent) {
596
+ console.warn(
597
+ '[mark2FAComplete] R908(e): no mfa_expires could be derived, so mfaVerified ' +
598
+ 'is NOT being set. Writing nextStep=mfa_verify instead so the consumer ' +
599
+ 're-challenges. A caller that needs the grant must pass the stepped-up ' +
600
+ 'access token that carries the claim.'
601
+ );
602
+ }
603
+
546
604
  return updateSession(sessionToken, {
547
- mfaVerified: true,
605
+ mfaVerified: mfaEvidencePresent,
548
606
  ...mfaLifetime,
607
+ ...(mfaEvidencePresent ? {} : { nextStep: 'mfa_verify' as const }),
549
608
  });
550
609
  }
551
610
 
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { NextRequest, NextResponse } from 'next/server';
21
21
  import { isUnauthenticatedRoute, should2FABypass } from '../auth/route-config';
22
+ import { resolveCallbackDestination } from '../auth/safe-callback';
22
23
  import { getInternalApiUrl } from '../edge/internal-api-url';
23
24
  import { getSessionCookieName, getSecureSessionCookieName } from '../lib/app-slug';
24
25
  import { checkPagePermission, isRBACEnabled } from './rbac-check';
@@ -151,16 +152,23 @@ function isLoginPage(pathname: string): boolean {
151
152
  return pathname === LOGIN_PAGE;
152
153
  }
153
154
 
154
- /** Ensures callback URLs never point to auth pages (prevents redirect loops) */
155
+ /**
156
+ * Ensures callback URLs never point to auth pages (prevents redirect loops) AND
157
+ * never point off-origin (prevents open redirect).
158
+ *
159
+ * 375656: the previous body tested `pathname` FIRST and returned '/' early, so
160
+ * the two call sites that pass an attacker-controlled `searchParams` are both
161
+ * inside `if (onLoginPage)` branches - and `isLoginPage` is exact equality against
162
+ * `LOGIN_PAGE`, which starts with `/account-auth/`. The early return therefore
163
+ * fired before the parameter was ever read. That made this function SAFE, but
164
+ * safe because of which argument the caller happened to pass, not because the
165
+ * attacker-controlled value was ever checked: `existing` was returned raw on the
166
+ * strength of a `startsWith` test that `https://evil.example.com` passes.
167
+ * Nothing named that dependency, and reordering the arguments would have opened
168
+ * it silently. Both inputs are now validated by the same parser-based guard.
169
+ */
155
170
  function getSafeCallbackUrl(pathname: string, searchParams?: URLSearchParams): string {
156
- if (pathname.startsWith('/account-auth/')) {
157
- return '/';
158
- }
159
- const existing = searchParams?.get('callbackUrl');
160
- if (existing && !existing.startsWith('/account-auth/')) {
161
- return existing;
162
- }
163
- return pathname;
171
+ return resolveCallbackDestination(pathname, searchParams);
164
172
  }
165
173
 
166
174
  /** Clear all session cookies on a response */
@@ -388,14 +396,38 @@ export function createMvpMiddleware(options: MvpMiddlewareOptions = {}) {
388
396
  // --- Fetch session viability from internal API ---
389
397
  const { sessionStatus, sessionPointer } = await checkViability(request, viabilityEndpoint, log);
390
398
 
391
- // --- Handle stale session early (cookie exists but session doesn't) ---
399
+ // --- Stale session (cookie exists but session doesn't): OBSERVE ONLY, DO NOT DECIDE ---
400
+ //
401
+ // *** CARD 349607. THIS BLOCK USED TO `return` HERE, AND THAT RETURN WAS A BYPASS. ***
402
+ //
403
+ // makeAuthDecision() below already handles this exact condition at its
404
+ // `if (!sessionStatus.exists)` branch, and it handles it with TWO guard
405
+ // clauses that this early return did not have:
406
+ //
407
+ // if (onLoginPage) return { type: 'allow' }; // no redirect loop
408
+ // if (pathname.startsWith('/api/'))return { type: 'allow' }; // no HTML 307 at fetch()
409
+ //
410
+ // Returning here skipped BOTH. The `/api/` case is the reported harm: a
411
+ // stale-session XHR was answered with a 307 to an HTML login page, so the
412
+ // failure is invisible to `fetch()` - the caller sees an opaque redirect
413
+ // instead of an auth error, which is the same "correct logic preempted by
414
+ // an earlier return" shape recorded on 307290.
415
+ //
416
+ // This was NOT a missing feature one level down - the decision engine's
417
+ // branch is a strict superset of the behaviour this block implemented.
418
+ // The early return was a DUPLICATE that had drifted from the original.
419
+ // Adding a single `/api/` exemption here would have re-created the
420
+ // duplication (and left the onLoginPage divergence in place), so the
421
+ // duplicate is removed instead and the engine is left as the ONE decider.
422
+ //
423
+ // The warning is deliberately KEPT: it is the only operational signal that
424
+ // stale sessions are occurring, and it costs nothing to log before
425
+ // delegating. Do not re-introduce a `return` in this block.
392
426
  if (!isPublic && sessionStatus.exists === false && sessionPointer.sessionToken) {
393
- const safeCallback = getSafeCallbackUrl(pathname);
394
- log.warn('Stale session detected', {
427
+ log.warn('Stale session detected (delegating to decision engine)', {
395
428
  sessionToken: sessionPointer.sessionToken?.substring(0, 8) + '...',
396
429
  pathname
397
430
  });
398
- return redirectTo(request, `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}`, true);
399
431
  }
400
432
 
401
433
  // --- Run decision engine ---
@@ -69,7 +69,17 @@ interface ProfileData {
69
69
  full_name?: string;
70
70
  two_factor_enabled?: boolean;
71
71
  roles?: string[];
72
- contact_information?: any;
72
+ // `contact_information` was declared here and read by the mapper below. The MEMBER
73
+ // endpoint never emits it - it belongs to AdminUserProfileResponse. The CORRECT
74
+ // shapes were already declared above (contact_info :44, address :51); only the
75
+ // mapper was pointed at the wrong field. Removed so nothing can read it again.
76
+ }
77
+
78
+ // An all-null object is not data. The member profile DTOs are always instantiated
79
+ // server-side (UserService.GetUserProfileAsync), so `obj ? ... : undefined` cannot
80
+ // tell "has an address" from "has an empty address shell".
81
+ function hasAnyValue(obj: Record<string, unknown> | undefined | null): boolean {
82
+ return !!obj && Object.values(obj).some(v => v !== null && v !== undefined && v !== '');
73
83
  }
74
84
 
75
85
  interface EditableSectionProps {
@@ -233,20 +243,39 @@ export default function EnhancedProfilePage() {
233
243
  title: undefined,
234
244
  bio: undefined,
235
245
  },
236
- contact_info: rawProfile.contact_information ? {
237
- secondary_email: rawProfile.contact_information.secondary_email,
238
- secondary_phone: rawProfile.contact_information.secondary_phone,
239
- website: rawProfile.contact_information.website,
246
+ // Both blocks used to be read off `rawProfile.contact_information`, which the
247
+ // MEMBER endpoint NEVER emits - that name belongs to AdminUserProfileResponse
248
+ // (its only 2 occurrences in PayEz-Core are in that admin DTO).
249
+ // /api/account/profile returns UserProfileResponse: `contact_info` plus a
250
+ // SEPARATE TOP-LEVEL `address`. Both guards therefore always took the false
251
+ // branch and both blocks rendered permanently empty for every member.
252
+ //
253
+ // NOT a rename: `address` is a SIBLING of contact_info, not nested inside it,
254
+ // so contact_information -> contact_info alone fixes Contact and leaves ADDRESS
255
+ // DEAD. Inner names are snake_case on both DTOs; the old camelCase reads were
256
+ // shaped for the ADMIN payload, which is where those keys really are camelCase.
257
+ // GUARD ON CONTENT, NOT PRESENCE. UserService.GetUserProfileAsync ALWAYS
258
+ // instantiates both DTOs (ContactInfo = new ProfileContactInfoDto {...},
259
+ // Address = new AddressDto {...}), so both arrive NON-NULL with all-null
260
+ // members when the member has nothing on file. A truthiness guard always
261
+ // passes and would render a card full of blank fields - the hazard QAPert
262
+ // named in 41360 for the naive rename, which applies just as much to
263
+ // reading the CORRECT fields.
264
+ contact_info: hasAnyValue(rawProfile.contact_info) ? {
265
+ secondary_email: rawProfile.contact_info?.secondary_email,
266
+ secondary_phone: rawProfile.contact_info?.secondary_phone,
267
+ website: rawProfile.contact_info?.website,
268
+ preferred_contact_method: rawProfile.contact_info?.preferred_contact_method,
240
269
  } : undefined,
241
- address: rawProfile.contact_information ? {
242
- address_line_1: rawProfile.contact_information.addressLine1,
243
- address_line_2: rawProfile.contact_information.addressLine2,
244
- city: rawProfile.contact_information.city,
245
- state_id: rawProfile.contact_information.stateId,
246
- state_name: rawProfile.contact_information.stateName,
247
- postal_code: rawProfile.contact_information.postalCode,
248
- country_code: rawProfile.contact_information.countryCode,
249
- country_name: rawProfile.contact_information.countryName,
270
+ address: hasAnyValue(rawProfile.address) ? {
271
+ address_line_1: rawProfile.address?.address_line_1,
272
+ address_line_2: rawProfile.address?.address_line_2,
273
+ city: rawProfile.address?.city,
274
+ state_id: rawProfile.address?.state_id,
275
+ state_name: rawProfile.address?.state_name,
276
+ postal_code: rawProfile.address?.postal_code,
277
+ country_code: rawProfile.address?.country_code,
278
+ country_name: rawProfile.address?.country_name,
250
279
  } : undefined,
251
280
  two_factor_enabled: rawProfile.two_factor_enabled,
252
281
  roles: rawProfile.roles,
@@ -35,6 +35,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
35
35
  import { authClient } from '../../client/better-auth-client';
36
36
  import { Suspense } from 'react';
37
37
  import { useColors } from '../../theme/useTheme';
38
+ import { safeCallbackPath } from '../../auth/safe-callback';
38
39
 
39
40
  /**
40
41
  * Session storage key to track that user intentionally navigated to verify-code.
@@ -52,7 +53,31 @@ interface MaskedInfo {
52
53
  function VerifyCodeForm({ enrollHref }: { enrollHref: string }) {
53
54
  const router = useRouter();
54
55
  const searchParams = useSearchParams();
55
- const callbackUrl = searchParams?.get('callbackUrl') || '/dashboard';
56
+ /**
57
+ * 375936 - ORIGIN-GUARDED AT THE READ, NOT AT THE SINK.
58
+ *
59
+ * This value ends up in `window.location.href` after a SUCCESSFUL 2FA
60
+ * ceremony (see the setTimeout further down). That is a full navigation, so
61
+ * an absolute URL here is a cross-origin redirect - the user completes a
62
+ * genuine login and is then dropped on an attacker-controlled page at the
63
+ * exact moment they are most primed to trust what they see.
64
+ *
65
+ * This used to be a raw `searchParams?.get('callbackUrl') || '/dashboard'`.
66
+ * The middleware does NOT protect this page: create-middleware's
67
+ * `needsTwoFactorRedirect` returns false for VERIFY_CODE_PAGE, so a direct
68
+ * request arrives here with its query string untouched.
69
+ *
70
+ * Do NOT "simplify" this back to a `startsWith('/account-auth/')` test. The
71
+ * three such tests below are LOOP prevention, not ORIGIN prevention -
72
+ * `https://evil.example` passes every one of them. safeCallbackPath asks the
73
+ * URL parser instead, so protocol-relative (`//host`), backslash (`/\host`)
74
+ * and TAB/LF/CR spellings all fail one origin comparison rather than needing
75
+ * their own blacklist entry.
76
+ *
77
+ * Guarding at the READ rather than at the sink means every later use of
78
+ * `callbackUrl` on this page inherits the guarantee.
79
+ */
80
+ const callbackUrl = safeCallbackPath(searchParams?.get('callbackUrl'), '/dashboard');
56
81
 
57
82
  const { data: sessionData, isPending } = authClient.useSession();
58
83
  const session = sessionData;
@@ -297,7 +297,7 @@ export async function GET(req: NextRequest) {
297
297
  const mfaExpiresAt = session?.mfaExpiresAt || 0;
298
298
  const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
299
299
  // Check mfaVerified (normalized name) with fallback to twoFactorComplete for compatibility
300
- const mfaVerifiedInSession = session?.mfaVerified ?? (session as any)?.twoFactorComplete ?? false;
300
+ const mfaVerifiedInSession = session?.mfaVerified ?? false;
301
301
 
302
302
  // User has completed 2FA requirements if: they verified AND it hasn't expired
303
303
  const userHasCompletedTenantTwoFactorRequirements = mfaVerifiedInSession && !mfaExpired;
@@ -347,7 +347,7 @@ export async function GET(req: NextRequest) {
347
347
  const mfaExpiresAt = session?.mfaExpiresAt || 0;
348
348
  const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
349
349
  // Check mfaVerified (normalized name) with fallback to twoFactorComplete for compatibility
350
- const mfaVerifiedInSession = session?.mfaVerified ?? (session as any)?.twoFactorComplete ?? false;
350
+ const mfaVerifiedInSession = session?.mfaVerified ?? false;
351
351
 
352
352
  // DEBUG: Log what we're reading from the session
353
353
  console.log('[VIABILITY] Session 2FA state:', {
@@ -127,7 +127,33 @@ export async function authGuard(options?: AuthGuardOptions): Promise<AuthGuardRe
127
127
  }
128
128
 
129
129
  if (requires2FA) {
130
- const mfaVerified = sessionData.mfaVerified ?? (sessionData as any).twoFactorComplete ?? false;
130
+ // 369858: the legacy `?? (sessionData as any).twoFactorComplete` carry is REMOVED.
131
+ // This is THE access gate - the only one of the 8 carry sites that grants access
132
+ // rather than reporting status - so it reads exactly one source of truth.
133
+ //
134
+ // Removing it is fail-closed. No current writer emits the legacy field alone:
135
+ // session-store.ts:444-445 writes both as a pair from a server-derived stepped-up
136
+ // token, auth/refresh.ts:376 writes it as a MIRROR of mfaVerified, and
137
+ // routes/auth/session.ts:149-157 refuses both on PRESENCE, so a caller cannot
138
+ // assert either. The carry therefore only ever served sessions minted before
139
+ // `mfaVerified` existed, and for those the correct behaviour is re-verify, not
140
+ // grandfather. Cost is one forced re-challenge for a stale legacy session.
141
+ //
142
+ // Severity note, so nobody re-inflates this later: the operator was `??`
143
+ // (nullish), NOT `||`. `false ?? x` is `false`, so an explicitly DENIED session
144
+ // could never be upgraded by a stale flag. This was legacy grandfathering, not a
145
+ // live 2FA bypass. Had it been `||`, it would have been the bypass.
146
+ const mfaVerified = sessionData.mfaVerified ?? false;
147
+
148
+ // 339563 / 369858 §3: THIS IS STILL A FAIL-OPEN AND IS DELIBERATELY LEFT VISIBLE.
149
+ // An absent mfaExpiresAt collapses to 0, so `mfaExpired` is false and the MFA
150
+ // verdict NEVER EXPIRES. That needs no legacy-session hypothesis: it fires on a
151
+ // current `mfaVerified: true` session whose token carried no `mfa_expires` claim -
152
+ // the passwordless/primary-credential lane, named on the write path in the
153
+ // source's own voice at session-store.ts:464-467 ("it IS the case the consumers
154
+ // currently fail OPEN on"). Removing the carry above does NOT touch this.
155
+ // Tracked separately by BAPert; not fixed here because failing closed on absent
156
+ // expiry would deny the passwordless lane outright and that is a product call.
131
157
  const mfaExpiresAt = sessionData.mfaExpiresAt || 0;
132
158
  const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
133
159