@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,185 @@
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
+ * v3 resolve once + compare ORIGIN -> bypassed by /..//host
25
+ * and /.//host, /%2e%2e//host
26
+ * v4 + require the RESULT to be a FIXED POINT <- current
27
+ *
28
+ * *** DRIFT, 2026-09-11 (325543). THIS FILE WAS v3 WHILE THE PORTAL'S COPY WAS
29
+ * *** v4, AND THE PACKAGE COPY IS THE ONE THAT SHIPPED. The portal fixed its own
30
+ * *** src/lib/safe-callback.ts (e1f08fea) and deployed; the served bundle STILL
31
+ * *** escaped 5/5 in a real browser, because `src/contexts/ThemeContext.tsx`
32
+ * *** imports the BARE BARREL `@payez/next-mvp`, which pulls dist/index.js ->
33
+ * *** auth/auth-decision -> THIS FILE into the client graph, and because the
34
+ * *** portal's `src/middleware.ts` imports `@payez/next-mvp/edge`, which requires
35
+ * *** THIS FILE server-side on every request. It was read as a stale build
36
+ * *** artifact of the portal's file for most of a day. It was not. It was a
37
+ * *** SECOND DEFINITION, and no rebuild of the portal could have removed it.
38
+ * *** The header below says "ONE definition ... for the whole package" and that
39
+ * *** was true INSIDE this package boundary the entire time. The defect lived in
40
+ * *** the gap BETWEEN two boundaries that each enforced one-definition correctly.
41
+ * *** A guard that exists in N copies is only as strong as its weakest copy, and
42
+ * *** a per-repo grep gate cannot see N. IF YOU CHANGE THE PREDICATE HERE, CHANGE
43
+ * *** IT IN THE PORTAL'S src/lib/safe-callback.ts IN THE SAME HOUR, OR DELETE ONE
44
+ * *** OF THEM.
45
+ *
46
+ * Both bypasses work the same way: the browser NORMALISES the string AFTER the
47
+ * guard has finished looking at it. The WHATWG URL parser converts backslashes
48
+ * to forward slashes and STRIPS all ASCII tab, LF and CR before resolving. So a
49
+ * prefix check can always be defeated by a character that is not there yet when
50
+ * it runs. Each fix bought one character. The list was never provably complete.
51
+ *
52
+ * SO: stop re-implementing the parser and ask it instead. Resolve the input
53
+ * against a synthetic base and compare ORIGINS. Anything that escapes the
54
+ * origin - however it is spelled, including spellings nobody has found yet -
55
+ * fails one comparison. The returned value is the parser's own NORMALISED
56
+ * output, so an accepted path cannot smuggle a control character onward either.
57
+ *
58
+ * A synthetic base is used deliberately so this works identically in a server
59
+ * component (no window), on the client, and in the edge runtime.
60
+ */
61
+
62
+ /** Where we send anyone whose requested target we will not honour. */
63
+ export const DEFAULT_CALLBACK = '/';
64
+
65
+ /** The auth surface. A callback pointing back into it causes a redirect cycle. */
66
+ const AUTH_PREFIX = '/account-auth/';
67
+
68
+ /**
69
+ * Synthetic, unreachable base. Only ever used as the resolution origin for the
70
+ * comparison below - never emitted, never navigated to. `.invalid` is reserved
71
+ * by RFC 2606 and can never resolve.
72
+ */
73
+ const BASE = 'http://safe-callback.invalid';
74
+
75
+ /**
76
+ * The value to navigate to: a same-origin relative path, NORMALISED by the URL
77
+ * parser, or `fallback` for anything that escapes the origin or loops back into
78
+ * the auth surface. Never throws.
79
+ */
80
+ export function safeCallbackPath(raw: unknown, fallback: string = DEFAULT_CALLBACK): string {
81
+ if (typeof raw !== 'string' || raw.length === 0) return fallback;
82
+
83
+ let url: URL;
84
+ try {
85
+ url = new URL(raw, BASE);
86
+ } catch {
87
+ return fallback;
88
+ }
89
+
90
+ // Anything that resolved to a different origin escaped, however it was spelled:
91
+ // absolute, protocol-relative //host, backslash /\host, tab/LF/CR variants, or
92
+ // a scheme like javascript: (whose origin is opaque and never equals BASE).
93
+ if (url.origin !== BASE) return fallback;
94
+
95
+ // Loopbacks into the auth surface cause redirect cycles.
96
+ if (url.pathname.startsWith(AUTH_PREFIX)) return fallback;
97
+
98
+ // The parser's normalised output - control characters already stripped.
99
+ const candidate = url.pathname + url.search + url.hash;
100
+
101
+ // *** SECOND RESOLUTION. DO NOT DELETE THIS AS REDUNDANT - IT IS NOT. ***
102
+ //
103
+ // v3 (the origin comparison above) was bypassed too, and differently from v1/v2.
104
+ // The origin check constrains where `raw` RESOLVED TO. It does not constrain how
105
+ // the RESULT IS SPELLED - and the result is resolved a SECOND time, by whoever
106
+ // navigates to it. A returned path that begins with `//` is a PROTOCOL-RELATIVE
107
+ // URL at that second resolution, so it lands off-origin having passed every
108
+ // check here:
109
+ //
110
+ // '/..//evil.example.com' -> pathname '//evil.example.com' -> evil.example.com
111
+ // '/.//evil.example.com' -> pathname '//evil.example.com' -> evil.example.com
112
+ // '/%2e%2e//evil.example.com' -> pathname '//evil.example.com' -> evil.example.com
113
+ //
114
+ // The parser resolves `..` against the synthetic base and, at the root, leaves
115
+ // behind the EMPTY SEGMENT rather than collapsing it. Origin stayed BASE the
116
+ // whole time, so nothing upstream fired.
117
+ //
118
+ // MEASURED, not reasoned: in a real headless Chromium on the live origin
119
+ // https://portal.keelauth.com, 5 spellings resolved to https://evil.example.com
120
+ // (QAPert-NightHawk, 2026-09-11, card 325543), and on 2026-09-11 the SERVED
121
+ // BUNDLE was measured escaping 5/5 through THIS PACKAGE'S copy while the
122
+ // portal's own src/lib/safe-callback.ts was already fixed - the two copies had
123
+ // drifted. See the DRIFT note in this file's header.
124
+ //
125
+ // THE FIX FOLLOWS THIS FILE'S OWN DOCTRINE: do not pattern-match the spellings
126
+ // (that is the startsWith() trap that failed four times, and this would be the
127
+ // fifth). Ask the parser AGAIN about the exact value we are about to hand out,
128
+ // and require it to be a FIXED POINT. Anything whose meaning changes when it is
129
+ // resolved a second time is by definition not a stable same-origin path, however
130
+ // it is spelled - including spellings nobody has found yet.
131
+ let echo: URL;
132
+ try {
133
+ echo = new URL(candidate, BASE);
134
+ } catch {
135
+ return fallback;
136
+ }
137
+ if (echo.origin !== BASE) return fallback;
138
+ if (echo.pathname.startsWith(AUTH_PREFIX)) return fallback;
139
+ if (echo.pathname + echo.search + echo.hash !== candidate) return fallback;
140
+
141
+ return candidate;
142
+ }
143
+
144
+ /**
145
+ * Sentinel that `safeCallbackPath` can never legitimately return: it is not a
146
+ * fixed point of the URL parser (the parser would percent-encode the space), so
147
+ * it cannot collide with a genuine accepted value.
148
+ */
149
+ const REJECTED = '\u0000rejected';
150
+
151
+ /**
152
+ * True when `raw` is a callback target we are willing to honour.
153
+ *
154
+ * DEFINED IN TERMS OF `safeCallbackPath` ON PURPOSE - it used to carry its own
155
+ * copy of the predicate, and that copy was missing the second resolution above
156
+ * while the two were maintained separately. This file's whole history is
157
+ * duplicated predicates drifting apart (see the header). One definition, one
158
+ * place to fix.
159
+ */
160
+ export function isSafeCallbackPath(raw: unknown): raw is string {
161
+ return typeof raw === 'string' && raw.length > 0 && safeCallbackPath(raw, REJECTED) !== REJECTED;
162
+ }
163
+
164
+ /**
165
+ * Pick the post-login destination: an explicit `?callbackUrl=` when it is one we
166
+ * will honour, otherwise the current path, otherwise `/`.
167
+ *
168
+ * This replaces the package's three hand-rolled `getSafeCallbackUrl` /
169
+ * `getCallbackUrl` bodies. Note the ARGUMENT ORDER DEPENDENCY those had: they
170
+ * tested `pathname` FIRST and returned early, so a caller on the login page
171
+ * never reached the attacker-controlled parameter at all - safe, but only
172
+ * because that caller happened to pass a value that tripped the function's own
173
+ * first guard. Nothing in the code named that dependency, and an argument
174
+ * reorder would have opened it. Here BOTH inputs are validated, so the
175
+ * protection no longer depends on which one is passed.
176
+ */
177
+ export function resolveCallbackDestination(
178
+ pathname: string,
179
+ searchParams?: URLSearchParams | null,
180
+ fallback: string = DEFAULT_CALLBACK
181
+ ): string {
182
+ const requested = searchParams?.get('callbackUrl');
183
+ if (isSafeCallbackPath(requested)) return safeCallbackPath(requested, fallback);
184
+ return safeCallbackPath(pathname, fallback);
185
+ }
@@ -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
 
package/src/lib/acr.ts ADDED
@@ -0,0 +1,109 @@
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
+ /**
33
+ * The assurance level used when the session and token say NOTHING about it.
34
+ *
35
+ * '1' = a single factor. It does not assert that a second factor happened.
36
+ * It must never be '2' (or higher): that is the value the IDP reads as
37
+ * "a second factor was completed", and an absent claim is not evidence of one.
38
+ */
39
+ export const ACR_WHEN_ABSENT = '1';
40
+
41
+ /**
42
+ * The authentication-methods list used when nothing is known.
43
+ *
44
+ * Empty asserts nothing. It must never contain 'mfa' (or any second-factor
45
+ * method): claiming a method that was never exercised is the same fabrication
46
+ * as ACR '2', and because the two corroborate each other a downstream check
47
+ * that cross-validates amr against acr would accept the fabricated pair.
48
+ */
49
+ export const AMR_WHEN_ABSENT: readonly string[] = [];
50
+
51
+ /**
52
+ * True when an assurance signal carries no information.
53
+ *
54
+ * Deliberately NOT `!value`. The `||` idiom this replaces cannot tell
55
+ * "the token said nothing" from "the token said 0", and numeric 0 is a
56
+ * meaningful assurance level (unauthenticated) that must survive as '0'
57
+ * rather than being promoted to the fallback.
58
+ */
59
+ function isAbsent(value: unknown): boolean {
60
+ return value === undefined || value === null || value === '';
61
+ }
62
+
63
+ /**
64
+ * Resolve an assurance level from any number of candidate sources, in
65
+ * priority order, falling back to the level that asserts no second factor.
66
+ *
67
+ * Accepts the candidates the refresh lane actually has: the stored
68
+ * `session.authenticationLevel` first, then the `acr` claim on the token.
69
+ */
70
+ export function resolveAcrLevel(...candidates: unknown[]): string {
71
+ for (const candidate of candidates) {
72
+ if (!isAbsent(candidate)) {
73
+ return String(candidate);
74
+ }
75
+ }
76
+ return ACR_WHEN_ABSENT;
77
+ }
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
+ export function resolveAuthMethods(...candidates: unknown[]): string[] {
88
+ for (const candidate of candidates) {
89
+ if (isAbsent(candidate)) continue;
90
+
91
+ if (Array.isArray(candidate)) {
92
+ return candidate.map(String);
93
+ }
94
+
95
+ if (typeof candidate === 'string') {
96
+ try {
97
+ const parsed = JSON.parse(candidate);
98
+ if (Array.isArray(parsed)) {
99
+ return parsed.map(String);
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 [...AMR_WHEN_ABSENT];
109
+ }
@@ -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:', {