@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
@@ -17,6 +17,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
17
17
  exports.createAuthHandler = createAuthHandler;
18
18
  const server_1 = require("next/server");
19
19
  const auth_1 = require("../server/auth");
20
+ const acr_1 = require("../lib/acr");
20
21
  const nanoid_1 = require("nanoid");
21
22
  const session_store_1 = require("../lib/session-store");
22
23
  /**
@@ -158,10 +159,18 @@ function createAuthHandler(options = {}) {
158
159
  console.error('[AUTH_HANDLER] No refresh token available');
159
160
  return { success: false, error: 'No refresh token' };
160
161
  }
161
- // Extract authentication methods from session
162
- const authMethods = currentSession.authMethods ||
163
- (currentSession.token?.amr ? JSON.parse(currentSession.token.amr) : ['pwd', 'mfa']);
164
- const authLevel = String(currentSession.authenticationLevel || currentSession.token?.acr || '2');
162
+ // Extract authentication methods from session.
163
+ //
164
+ // 375639: these two lines used to read
165
+ // authMethods = ... : ['pwd', 'mfa']
166
+ // authLevel = String(... || ... || '2')
167
+ // so a session that knew NOTHING about how the user authenticated told
168
+ // the IDP that a second factor had happened AND named 'mfa' as the
169
+ // method. The two fabrications corroborated each other, so a downstream
170
+ // check cross-validating amr against acr would have accepted the pair.
171
+ // Absent is not satisfied; both now resolve fail-closed, in one place.
172
+ const authMethods = (0, acr_1.resolveAuthMethods)(currentSession.authMethods, currentSession.token?.amr);
173
+ const authLevel = (0, acr_1.resolveAcrLevel)(currentSession.authenticationLevel, currentSession.token?.acr);
165
174
  const twoFactorMethod = currentSession.twoFactorMethod || 'authenticator';
166
175
  // Build refresh request body
167
176
  const refreshRequestBody = {
@@ -40,8 +40,10 @@ export declare function POST(req: NextRequest): Promise<NextResponse<{
40
40
  attemptedRefresh: boolean;
41
41
  };
42
42
  }> | NextResponse<{
43
+ message: any;
44
+ requiresChallenge?: boolean | undefined;
45
+ nextStep?: "mfa_verify" | undefined;
43
46
  success: boolean;
44
47
  verificationSuccessful: boolean;
45
48
  twoFactorSessionVerified: boolean;
46
- message: any;
47
49
  }>>;
@@ -68,6 +68,10 @@ async function POST(req) {
68
68
  // session. No tokens => no transition: we must not mark 2FA complete on the
69
69
  // strength of a 200 alone.
70
70
  let sessionTransitioned = false;
71
+ // R908(e): "we could not establish MFA" is a THIRD outcome, distinct from
72
+ // both success and failure. It must reach the client as a challenge, not as
73
+ // a silent success (fail-open) and not as a rejection (fail-closed).
74
+ let requiresChallenge = false;
71
75
  if (unwrappedData.access_token && unwrappedData.refresh_token) {
72
76
  const token = await (0, test_aware_get_token_1.getTokenTestAware)(req);
73
77
  const sessionToken = (token?.sessionToken || token?.redisSessionId);
@@ -90,15 +94,27 @@ async function POST(req) {
90
94
  catch {
91
95
  // Refresh token may carry no exp claim; default stands.
92
96
  }
93
- await (0, session_store_1.transitionTo2FASession)(sessionToken, {
97
+ // R908(e): the transition can now decline to grant. Read what was
98
+ // actually written instead of assuming the call granted - the store
99
+ // withholds mfaVerified when the stepped-up token carries no
100
+ // mfa_expires, and reports the re-challenge via nextStep.
101
+ const transitioned = await (0, session_store_1.transitionTo2FASession)(sessionToken, {
94
102
  accessToken: unwrappedData.access_token,
95
103
  refreshToken: unwrappedData.refresh_token,
96
104
  accessTokenExpires,
97
105
  refreshTokenExpires,
98
106
  }, 'totp' // 2FA method, used by the refresh-token flow
99
107
  );
100
- sessionTransitioned = true;
101
- console.log('[VERIFY_TOTP] Session transitioned to 2FA-verified');
108
+ sessionTransitioned = transitioned?.mfaVerified === true;
109
+ requiresChallenge = transitioned?.nextStep === 'mfa_verify';
110
+ if (sessionTransitioned) {
111
+ console.log('[VERIFY_TOTP] Session transitioned to 2FA-verified');
112
+ }
113
+ else {
114
+ console.warn('[VERIFY_TOTP] R908(e): IDP verified the code but the session was NOT ' +
115
+ 'marked MFA-verified (no mfa_expires on the returned token). Reporting ' +
116
+ 'requiresChallenge so the client re-challenges rather than proceeding.');
117
+ }
102
118
  }
103
119
  else {
104
120
  console.warn('[VERIFY_TOTP] IDP verified the code but no session token was resolvable — session NOT transitioned');
@@ -120,6 +136,9 @@ async function POST(req) {
120
136
  success: true,
121
137
  verificationSuccessful: true,
122
138
  twoFactorSessionVerified: sessionTransitioned,
139
+ // R908(e): tells the client to re-challenge. Present only when the code
140
+ // verified at the IDP but produced no evidence we could record.
141
+ ...(requiresChallenge ? { requiresChallenge: true, nextStep: 'mfa_verify' } : {}),
123
142
  message: unwrappedData.message || 'Authenticator code verified successfully',
124
143
  }, { status: 200 });
125
144
  }
@@ -23,14 +23,14 @@ async function GET(req) {
23
23
  // Still need to get session data for twoFactorComplete even when refresh is in progress
24
24
  const sessionData = await (0, session_store_1.getSession)(finalSessionToken);
25
25
  logger_1.logger.info('[REFRESH-VIABILITY] Refresh already in progress, telling middleware to wait', { sessionToken: finalSessionToken.substring(0, 8) + '...' });
26
- return server_1.NextResponse.json({ canRefresh: true, reason: 'refresh_in_progress', refreshInProgress: true, sessionToken: finalSessionToken, twoFactorComplete: sessionData?.mfaVerified ?? sessionData?.twoFactorComplete ?? false }, { status: 200 });
26
+ return server_1.NextResponse.json({ canRefresh: true, reason: 'refresh_in_progress', refreshInProgress: true, sessionToken: finalSessionToken, twoFactorComplete: sessionData?.mfaVerified ?? false }, { status: 200 });
27
27
  }
28
28
  const sessionData = await (0, session_store_1.getSession)(finalSessionToken);
29
29
  if (!sessionData) {
30
30
  return server_1.NextResponse.json({ canRefresh: false, reason: 'session_not_found', sessionToken: finalSessionToken }, { status: 200 });
31
31
  }
32
32
  const viabilityCheck = (0, refresh_token_validator_1.checkRefreshViability)(sessionData);
33
- return server_1.NextResponse.json({ canRefresh: viabilityCheck.canRefresh, reason: viabilityCheck.reason, timeRemaining: viabilityCheck.timeRemaining, expiresAt: viabilityCheck.expiresAt, accessTokenExpired: viabilityCheck.accessTokenExpired, accessTokenTimeRemaining: viabilityCheck.accessTokenTimeRemaining, sessionToken: finalSessionToken, twoFactorComplete: sessionData.mfaVerified ?? sessionData.twoFactorComplete ?? false, userId: sessionData.userId, refreshInProgress: false });
33
+ return server_1.NextResponse.json({ canRefresh: viabilityCheck.canRefresh, reason: viabilityCheck.reason, timeRemaining: viabilityCheck.timeRemaining, expiresAt: viabilityCheck.expiresAt, accessTokenExpired: viabilityCheck.accessTokenExpired, accessTokenTimeRemaining: viabilityCheck.accessTokenTimeRemaining, sessionToken: finalSessionToken, twoFactorComplete: sessionData.mfaVerified ?? false, userId: sessionData.userId, refreshInProgress: false });
34
34
  }
35
35
  catch (error) {
36
36
  logger_1.logger.error('[REFRESH-VIABILITY] Error checking refresh viability', { error: error instanceof Error ? error.message : String(error) });
@@ -106,7 +106,7 @@ async function GET(req) {
106
106
  const mfaExpiresAt = sessionData.mfaExpiresAt || 0;
107
107
  const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
108
108
  // Check both field names for compatibility (mfaVerified is the normalized name)
109
- const sessionMfaComplete = sessionData.mfaVerified ?? sessionData.twoFactorComplete ?? false;
109
+ const sessionMfaComplete = sessionData.mfaVerified ?? false;
110
110
  const effectiveTwoFactorComplete = sessionMfaComplete && !mfaExpired;
111
111
  console.log('[VIABILITY] Session 2FA check:', {
112
112
  sessionToken: sessionToken.substring(0, 8) + '...',
@@ -7,13 +7,18 @@
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.makeAuthDecision = makeAuthDecision;
10
+ const safe_callback_1 = require("./safe-callback");
10
11
  /**
11
12
  * Main decision function - pure, testable, deterministic
12
13
  */
13
14
  function makeAuthDecision(context) {
14
15
  const { pathname, isPublicRoute, isLoginPage, searchParams, sessionPointer, sessionStatus, circuitBreakerOpen } = context;
15
- // SAFEGUARD: Never use auth pages as callback URLs to prevent redirect loops
16
- const safeCallbackUrl = pathname.startsWith('/account-auth/') ? '/' : pathname;
16
+ // SAFEGUARD: Never use auth pages as callback URLs to prevent redirect loops.
17
+ // 375656: `pathname` is our own request path rather than an attacker-supplied
18
+ // parameter, so this site was not the open redirect - but it is routed through
19
+ // the same guard so the package has ONE definition of an acceptable callback,
20
+ // and so the value is parser-normalised before being carried into a redirect.
21
+ const safeCallbackUrl = (0, safe_callback_1.safeCallbackPath)(pathname);
17
22
  // Public routes are allowed, but we need to check login page separately
18
23
  if (isPublicRoute && !isLoginPage) {
19
24
  // For non-login public routes, check if authenticated user needs 2FA
@@ -104,8 +109,17 @@ function makeAuthDecision(context) {
104
109
  // If user is on the login page, send them to verify-code with the original callback
105
110
  if (isLoginPage) {
106
111
  const callbackUrl = searchParams.get('callbackUrl') || '/';
107
- // CRITICAL FIX: Never use auth pages as callback URLs
108
- const safeCallbackUrl = callbackUrl.startsWith('/account-auth/') ? '/' : callbackUrl;
112
+ // 375656: this previously tested the raw parameter with a prefix check against
113
+ // the auth path, and returned the parameter itself when it did not match.
114
+ // (The vulnerable expression is deliberately NOT reproduced verbatim here: a
115
+ // comment carrying it puts the string into dist, and a reviewer grepping for
116
+ // that pattern would score this FIXED build as vulnerable - the exact
117
+ // sink-counting inversion recorded on 375936.)
118
+ // That is a redirect-LOOP guard in the seat where an ORIGIN check belongs:
119
+ // `https://evil.example.com` does not start with `/account-auth/`, so it passed
120
+ // through untouched. safeCallbackPath() asks the URL parser instead, so every
121
+ // spelling that escapes the origin fails one comparison. See ./safe-callback.
122
+ const safeCallbackUrl = (0, safe_callback_1.safeCallbackPath)(callbackUrl);
109
123
  console.log('[MIDDLEWARE-DECISION] On login page with 2FA incomplete, redirecting to verify-code', {
110
124
  originalCallback: callbackUrl,
111
125
  safeCallback: safeCallbackUrl,
@@ -148,8 +162,8 @@ function makeAuthDecision(context) {
148
162
  // For login page, redirect to 2FA with original callback
149
163
  if (isLoginPage) {
150
164
  const callbackUrl = searchParams.get('callbackUrl') || '/';
151
- // CRITICAL FIX: Never use auth pages as callback URLs
152
- const safeCallbackUrl = callbackUrl.startsWith('/account-auth/') ? '/' : callbackUrl;
165
+ // 375656: origin-checked, not prefix-checked. See ./safe-callback.
166
+ const safeCallbackUrl = (0, safe_callback_1.safeCallbackPath)(callbackUrl);
153
167
  return {
154
168
  type: 'redirect',
155
169
  location: `/account-auth/verify-code?callbackUrl=${encodeURIComponent(safeCallbackUrl)}`,
@@ -165,8 +179,13 @@ function makeAuthDecision(context) {
165
179
  // Authenticated user on login page - redirect to dashboard or callback
166
180
  if (isLoginPage) {
167
181
  const callbackUrl = searchParams.get('callbackUrl') || '/';
168
- // CRITICAL FIX: Never redirect back to login page (prevents infinite loop)
169
- const safeCallbackUrl = callbackUrl.startsWith('/account-auth/') ? '/' : callbackUrl;
182
+ // 375656 - THE PUBLISHED OPEN REDIRECT. This value becomes `location` VERBATIM
183
+ // below (not a query parameter on a relative path, as at the two sites above),
184
+ // so the old `startsWith('/account-auth/')` test sent an authenticated user
185
+ // straight to any absolute URL an attacker put in `?callbackUrl=`. Its own
186
+ // comment says "prevents infinite loop", which is what it was written for -
187
+ // loop prevention, never origin validation. See ./safe-callback.
188
+ const safeCallbackUrl = (0, safe_callback_1.safeCallbackPath)(callbackUrl);
170
189
  console.log('[MIDDLEWARE-DECISION] Authenticated user on login page, redirecting', {
171
190
  originalCallback: callbackUrl,
172
191
  safeCallback: safeCallbackUrl
@@ -77,6 +77,24 @@ const session_store_1 = require("../lib/session-store");
77
77
  function secretFingerprint(value) {
78
78
  return (0, crypto_1.createHash)('sha256').update(value).digest('hex').slice(0, 12);
79
79
  }
80
+ /**
81
+ * 273183: keys written onto the `ba:{slug}:{token}` record by an author OTHER
82
+ * than Better Auth, which must survive a Better Auth session write.
83
+ *
84
+ * Today this is exactly `idpTokens`, written by `storeIdpTokensInSession`. The
85
+ * list is a named constant rather than a hardcoded check because the shape of
86
+ * the bug is "a second author appeared and the adapter did not know" — the next
87
+ * enrichment key should be added here, not rediscovered as a strip.
88
+ */
89
+ const PRESERVED_SESSION_KEYS = ['idpTokens'];
90
+ /**
91
+ * True for a JSON object we can safely merge into — not null, not an array.
92
+ * `typeof null === 'object'` and arrays are objects, and merging into either
93
+ * would corrupt the record rather than preserve it.
94
+ */
95
+ function isPlainObject(value) {
96
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
97
+ }
80
98
  /**
81
99
  * Build Better Auth social providers from IDP config.
82
100
  */
@@ -451,7 +469,111 @@ function createBetterAuthInstance(idpConfig, opts = {}) {
451
469
  */
452
470
  set: async (key, value, ttl) => {
453
471
  const redis = (0, redis_1.getRedis)();
454
- await redis.setex(`ba:${appSlug}:${key}`, ttl ?? session_store_1.SESSION_TTL, value);
472
+ const redisKey = `ba:${appSlug}:${key}`;
473
+ /**
474
+ * 273183 — READ-MODIFY-WRITE, BECAUSE THIS RECORD HAS TWO AUTHORS.
475
+ * (Found by DotNetPert-Scout; handed over as Cluster-B root-cause
476
+ * evidence under BAPert's R920(a). Reproduced here before fixing.)
477
+ *
478
+ * Better Auth owns `{session, user}` and serialises exactly that. But
479
+ * `storeIdpTokensInSession` (below, ~:1010) ENRICHES the same key with
480
+ * `idpTokens` via its own read-modify-write. This setter did a blind
481
+ * `setex` of Better Auth's payload, so every write it makes — including
482
+ * each routine `updateAge` session rewrite, not merely an expiry —
483
+ * STRIPPED `idpTokens` back off the record.
484
+ *
485
+ * The consumer then falls back to an identity husk: `mfaVerified:false`,
486
+ * `roles:[]`. It fails CLOSED, so it is not a 369782-class bypass — but
487
+ * per BAPert it is a public-test blocker on its own terms, because a
488
+ * tester who trips the `updateAge` cadence gets a spurious 2FA re-prompt
489
+ * AND SILENTLY LOSES THEIR ROLES mid-session. That is a visible product
490
+ * failure regardless of which direction it fails in.
491
+ *
492
+ * WHY MERGE HERE RATHER THAN MOVE THE DATA. The enriching writer already
493
+ * does a read-modify-write; it was correct. The bug is that ONE of the
494
+ * two authors of this key wrote as though it were the only one. Fixing
495
+ * the adapter fixes it for every future enrichment key, whereas moving
496
+ * `idpTokens` elsewhere would leave the next one to rediscover this.
497
+ *
498
+ * ONLY keys in PRESERVED_KEYS are carried, and ONLY when the incoming
499
+ * payload does not set them — so Better Auth still wins on everything it
500
+ * owns, and an explicit incoming value is never overridden by a stale one.
501
+ *
502
+ * NOT ATOMIC, and deliberately not: an enrichment landing between this
503
+ * read and this write is still lost. Closing that needs a WATCH/MULTI or
504
+ * a Lua script, which is a larger change than tonight allows. This
505
+ * narrows the window from "every single write" to "a genuine race", and
506
+ * the residual is recorded here rather than left to be re-found.
507
+ */
508
+ let payload = value;
509
+ try {
510
+ const existingRaw = await redis.get(redisKey);
511
+ if (existingRaw) {
512
+ const existing = JSON.parse(existingRaw);
513
+ const incoming = JSON.parse(value);
514
+ if (isPlainObject(existing) && isPlainObject(incoming)) {
515
+ let carried = false;
516
+ for (const preserved of PRESERVED_SESSION_KEYS) {
517
+ if (preserved in existing && !(preserved in incoming)) {
518
+ incoming[preserved] = existing[preserved];
519
+ carried = true;
520
+ }
521
+ }
522
+ if (carried)
523
+ payload = JSON.stringify(incoming);
524
+ }
525
+ }
526
+ }
527
+ catch (mergeErr) {
528
+ /**
529
+ * This adapter also stores `verification:*` and `active-sessions-*`
530
+ * records, which are not necessarily session-shaped JSON. A record we
531
+ * cannot parse is not a session to enrich, so writing through unchanged
532
+ * is correct rather than merely tolerable.
533
+ *
534
+ * WARNED, NOT THROWN, and the asymmetry is the point: this block is an
535
+ * ENRICHMENT-PRESERVING read. Letting it fail the write would convert a
536
+ * degraded read into a lost session — the precise inversion 170033 fix 1
537
+ * above exists to prevent. The `setex` below is outside this try and is
538
+ * still unswallowed, so a genuine write failure still propagates.
539
+ */
540
+ console.warn(`[BETTER_AUTH] secondaryStorage.set: could not preserve enrichment keys on ` +
541
+ `ba:${appSlug}:sha256:${secretFingerprint(key)} — writing Better Auth's payload ` +
542
+ `as-is. If this record held idpTokens they are now gone (273183):`, mergeErr instanceof Error ? mergeErr.message : String(mergeErr));
543
+ }
544
+ /**
545
+ * QA 6952 / R913 - CLAMP, DO NOT DEFER.
546
+ *
547
+ * This was `ttl ?? SESSION_TTL`, which READS as "SESSION_TTL is the single
548
+ * source of session TTL" - what session-store.ts claims, and the mechanism
549
+ * by which the 7d-vs-3d zombie window was supposedly closed for good.
550
+ *
551
+ * IT WAS DEAD CODE. Better Auth ALWAYS supplies a ttl: every one of the
552
+ * eight secondaryStorage.set call sites in
553
+ * better-auth/dist/db/internal-adapter.mjs passes one explicitly (:36,
554
+ * :180, :189, :309, :320, :344, :553, :622). `??` fires only on
555
+ * null/undefined, so the fallback NEVER RAN. Every `ba:` record took Better
556
+ * Auth's 7d default - `session.expiresIn` is not configured in this file -
557
+ * while the canonical store stayed at SESSION_TTL (3d). That 4-day drift IS
558
+ * the zombie window the constant exists to prevent: the guard was bypassed
559
+ * by the very parameter written to defer to it.
560
+ *
561
+ * `Math.min`, NOT a hard SESSION_TTL: this same adapter also stores
562
+ * `verification:*` (:553, :622) and `active-sessions-*` (:180, :320, :344)
563
+ * records whose TTLs are legitimately SHORTER than 3d. Overwriting those
564
+ * would EXTEND them - turning a fix for over-long sessions into a bug that
565
+ * keeps verification codes alive for three days. Clamp DOWN, never up.
566
+ *
567
+ * Clamped down to 3d rather than raising the canonical store to 7d because
568
+ * SESSION_TTL matches the IDP refresh-token lifetime: a session outliving
569
+ * the refresh token sustaining it cannot be refreshed, so the extra days are
570
+ * a husk that reads as authenticated. Fail-closed beats ambiguous.
571
+ *
572
+ * DO NOT "fix" this by making storeIdpTokensInSession preserve Better
573
+ * Auth's 7d. That deletes the last remnant of QA 6952 and makes the drift
574
+ * universal. See test/ba-session-ttl-clamp.test.cjs.
575
+ */
576
+ await redis.setex(redisKey, Math.min(ttl ?? session_store_1.SESSION_TTL, session_store_1.SESSION_TTL), payload);
455
577
  },
456
578
  delete: async (key) => {
457
579
  try {
@@ -0,0 +1,92 @@
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
+ /** Where we send anyone whose requested target we will not honour. */
62
+ export declare const DEFAULT_CALLBACK = "/";
63
+ /**
64
+ * The value to navigate to: a same-origin relative path, NORMALISED by the URL
65
+ * parser, or `fallback` for anything that escapes the origin or loops back into
66
+ * the auth surface. Never throws.
67
+ */
68
+ export declare function safeCallbackPath(raw: unknown, fallback?: string): string;
69
+ /**
70
+ * True when `raw` is a callback target we are willing to honour.
71
+ *
72
+ * DEFINED IN TERMS OF `safeCallbackPath` ON PURPOSE - it used to carry its own
73
+ * copy of the predicate, and that copy was missing the second resolution above
74
+ * while the two were maintained separately. This file's whole history is
75
+ * duplicated predicates drifting apart (see the header). One definition, one
76
+ * place to fix.
77
+ */
78
+ export declare function isSafeCallbackPath(raw: unknown): raw is string;
79
+ /**
80
+ * Pick the post-login destination: an explicit `?callbackUrl=` when it is one we
81
+ * will honour, otherwise the current path, otherwise `/`.
82
+ *
83
+ * This replaces the package's three hand-rolled `getSafeCallbackUrl` /
84
+ * `getCallbackUrl` bodies. Note the ARGUMENT ORDER DEPENDENCY those had: they
85
+ * tested `pathname` FIRST and returned early, so a caller on the login page
86
+ * never reached the attacker-controlled parameter at all - safe, but only
87
+ * because that caller happened to pass a value that tripped the function's own
88
+ * first guard. Nothing in the code named that dependency, and an argument
89
+ * reorder would have opened it. Here BOTH inputs are validated, so the
90
+ * protection no longer depends on which one is passed.
91
+ */
92
+ export declare function resolveCallbackDestination(pathname: string, searchParams?: URLSearchParams | null, fallback?: string): string;
@@ -0,0 +1,183 @@
1
+ "use strict";
2
+ /**
3
+ * ONE definition of "is this callback target safe to navigate to", for the
4
+ * whole package (375656).
5
+ *
6
+ * *** THIS DELEGATES PARSING TO THE URL PARSER. DO NOT REPLACE IT WITH
7
+ * *** startsWith() CHECKS. THAT APPROACH FAILED FOUR TIMES.
8
+ *
9
+ * WHY THIS FILE EXISTS: `@payez/next-mvp` shipped THREE bodies named
10
+ * `makeAuthDecision`, each carrying its own `callbackUrl.startsWith('/account-auth/')`
11
+ * test. That test is a REDIRECT-LOOP guard sitting in the seat where an ORIGIN
12
+ * check belongs - its own comment says "prevents infinite loop" - so
13
+ * `https://evil.example.com` does not start with `/account-auth/`, passes
14
+ * through untouched, and becomes the redirect target. The exported copy
15
+ * (`auth/auth-decision.ts`, re-exported from `index.ts` and `edge/middleware.ts`)
16
+ * is public API, so that is a published open redirect independent of who calls it.
17
+ *
18
+ * HISTORY, because it is the whole argument for the current shape (267097 /
19
+ * 267174 / 267448), carried over verbatim from the portal's `src/lib/safe-callback.ts`
20
+ * where this guard was proven (0 leaks / 21 hostile inputs):
21
+ *
22
+ * v1 `startsWith('/') && !startsWith('//')` -> bypassed by /BACKSLASH host
23
+ * v2 + `!startsWith('/' + BACKSLASH)` -> bypassed by /TAB/host
24
+ * ... and /LF/host, /CR/host
25
+ * v3 resolve once + compare ORIGIN -> bypassed by /..//host
26
+ * and /.//host, /%2e%2e//host
27
+ * v4 + require the RESULT to be a FIXED POINT <- current
28
+ *
29
+ * *** DRIFT, 2026-09-11 (325543). THIS FILE WAS v3 WHILE THE PORTAL'S COPY WAS
30
+ * *** v4, AND THE PACKAGE COPY IS THE ONE THAT SHIPPED. The portal fixed its own
31
+ * *** src/lib/safe-callback.ts (e1f08fea) and deployed; the served bundle STILL
32
+ * *** escaped 5/5 in a real browser, because `src/contexts/ThemeContext.tsx`
33
+ * *** imports the BARE BARREL `@payez/next-mvp`, which pulls dist/index.js ->
34
+ * *** auth/auth-decision -> THIS FILE into the client graph, and because the
35
+ * *** portal's `src/middleware.ts` imports `@payez/next-mvp/edge`, which requires
36
+ * *** THIS FILE server-side on every request. It was read as a stale build
37
+ * *** artifact of the portal's file for most of a day. It was not. It was a
38
+ * *** SECOND DEFINITION, and no rebuild of the portal could have removed it.
39
+ * *** The header below says "ONE definition ... for the whole package" and that
40
+ * *** was true INSIDE this package boundary the entire time. The defect lived in
41
+ * *** the gap BETWEEN two boundaries that each enforced one-definition correctly.
42
+ * *** A guard that exists in N copies is only as strong as its weakest copy, and
43
+ * *** a per-repo grep gate cannot see N. IF YOU CHANGE THE PREDICATE HERE, CHANGE
44
+ * *** IT IN THE PORTAL'S src/lib/safe-callback.ts IN THE SAME HOUR, OR DELETE ONE
45
+ * *** OF THEM.
46
+ *
47
+ * Both bypasses work the same way: the browser NORMALISES the string AFTER the
48
+ * guard has finished looking at it. The WHATWG URL parser converts backslashes
49
+ * to forward slashes and STRIPS all ASCII tab, LF and CR before resolving. So a
50
+ * prefix check can always be defeated by a character that is not there yet when
51
+ * it runs. Each fix bought one character. The list was never provably complete.
52
+ *
53
+ * SO: stop re-implementing the parser and ask it instead. Resolve the input
54
+ * against a synthetic base and compare ORIGINS. Anything that escapes the
55
+ * origin - however it is spelled, including spellings nobody has found yet -
56
+ * fails one comparison. The returned value is the parser's own NORMALISED
57
+ * output, so an accepted path cannot smuggle a control character onward either.
58
+ *
59
+ * A synthetic base is used deliberately so this works identically in a server
60
+ * component (no window), on the client, and in the edge runtime.
61
+ */
62
+ Object.defineProperty(exports, "__esModule", { value: true });
63
+ exports.DEFAULT_CALLBACK = void 0;
64
+ exports.safeCallbackPath = safeCallbackPath;
65
+ exports.isSafeCallbackPath = isSafeCallbackPath;
66
+ exports.resolveCallbackDestination = resolveCallbackDestination;
67
+ /** Where we send anyone whose requested target we will not honour. */
68
+ exports.DEFAULT_CALLBACK = '/';
69
+ /** The auth surface. A callback pointing back into it causes a redirect cycle. */
70
+ const AUTH_PREFIX = '/account-auth/';
71
+ /**
72
+ * Synthetic, unreachable base. Only ever used as the resolution origin for the
73
+ * comparison below - never emitted, never navigated to. `.invalid` is reserved
74
+ * by RFC 2606 and can never resolve.
75
+ */
76
+ const BASE = 'http://safe-callback.invalid';
77
+ /**
78
+ * The value to navigate to: a same-origin relative path, NORMALISED by the URL
79
+ * parser, or `fallback` for anything that escapes the origin or loops back into
80
+ * the auth surface. Never throws.
81
+ */
82
+ function safeCallbackPath(raw, fallback = exports.DEFAULT_CALLBACK) {
83
+ if (typeof raw !== 'string' || raw.length === 0)
84
+ return fallback;
85
+ let url;
86
+ try {
87
+ url = new URL(raw, BASE);
88
+ }
89
+ catch {
90
+ return fallback;
91
+ }
92
+ // Anything that resolved to a different origin escaped, however it was spelled:
93
+ // absolute, protocol-relative //host, backslash /\host, tab/LF/CR variants, or
94
+ // a scheme like javascript: (whose origin is opaque and never equals BASE).
95
+ if (url.origin !== BASE)
96
+ return fallback;
97
+ // Loopbacks into the auth surface cause redirect cycles.
98
+ if (url.pathname.startsWith(AUTH_PREFIX))
99
+ return fallback;
100
+ // The parser's normalised output - control characters already stripped.
101
+ const candidate = url.pathname + url.search + url.hash;
102
+ // *** SECOND RESOLUTION. DO NOT DELETE THIS AS REDUNDANT - IT IS NOT. ***
103
+ //
104
+ // v3 (the origin comparison above) was bypassed too, and differently from v1/v2.
105
+ // The origin check constrains where `raw` RESOLVED TO. It does not constrain how
106
+ // the RESULT IS SPELLED - and the result is resolved a SECOND time, by whoever
107
+ // navigates to it. A returned path that begins with `//` is a PROTOCOL-RELATIVE
108
+ // URL at that second resolution, so it lands off-origin having passed every
109
+ // check here:
110
+ //
111
+ // '/..//evil.example.com' -> pathname '//evil.example.com' -> evil.example.com
112
+ // '/.//evil.example.com' -> pathname '//evil.example.com' -> evil.example.com
113
+ // '/%2e%2e//evil.example.com' -> pathname '//evil.example.com' -> evil.example.com
114
+ //
115
+ // The parser resolves `..` against the synthetic base and, at the root, leaves
116
+ // behind the EMPTY SEGMENT rather than collapsing it. Origin stayed BASE the
117
+ // whole time, so nothing upstream fired.
118
+ //
119
+ // MEASURED, not reasoned: in a real headless Chromium on the live origin
120
+ // https://portal.keelauth.com, 5 spellings resolved to https://evil.example.com
121
+ // (QAPert-NightHawk, 2026-09-11, card 325543), and on 2026-09-11 the SERVED
122
+ // BUNDLE was measured escaping 5/5 through THIS PACKAGE'S copy while the
123
+ // portal's own src/lib/safe-callback.ts was already fixed - the two copies had
124
+ // drifted. See the DRIFT note in this file's header.
125
+ //
126
+ // THE FIX FOLLOWS THIS FILE'S OWN DOCTRINE: do not pattern-match the spellings
127
+ // (that is the startsWith() trap that failed four times, and this would be the
128
+ // fifth). Ask the parser AGAIN about the exact value we are about to hand out,
129
+ // and require it to be a FIXED POINT. Anything whose meaning changes when it is
130
+ // resolved a second time is by definition not a stable same-origin path, however
131
+ // it is spelled - including spellings nobody has found yet.
132
+ let echo;
133
+ try {
134
+ echo = new URL(candidate, BASE);
135
+ }
136
+ catch {
137
+ return fallback;
138
+ }
139
+ if (echo.origin !== BASE)
140
+ return fallback;
141
+ if (echo.pathname.startsWith(AUTH_PREFIX))
142
+ return fallback;
143
+ if (echo.pathname + echo.search + echo.hash !== candidate)
144
+ return fallback;
145
+ return candidate;
146
+ }
147
+ /**
148
+ * Sentinel that `safeCallbackPath` can never legitimately return: it is not a
149
+ * fixed point of the URL parser (the parser would percent-encode the space), so
150
+ * it cannot collide with a genuine accepted value.
151
+ */
152
+ const REJECTED = '\u0000rejected';
153
+ /**
154
+ * True when `raw` is a callback target we are willing to honour.
155
+ *
156
+ * DEFINED IN TERMS OF `safeCallbackPath` ON PURPOSE - it used to carry its own
157
+ * copy of the predicate, and that copy was missing the second resolution above
158
+ * while the two were maintained separately. This file's whole history is
159
+ * duplicated predicates drifting apart (see the header). One definition, one
160
+ * place to fix.
161
+ */
162
+ function isSafeCallbackPath(raw) {
163
+ return typeof raw === 'string' && raw.length > 0 && safeCallbackPath(raw, REJECTED) !== REJECTED;
164
+ }
165
+ /**
166
+ * Pick the post-login destination: an explicit `?callbackUrl=` when it is one we
167
+ * will honour, otherwise the current path, otherwise `/`.
168
+ *
169
+ * This replaces the package's three hand-rolled `getSafeCallbackUrl` /
170
+ * `getCallbackUrl` bodies. Note the ARGUMENT ORDER DEPENDENCY those had: they
171
+ * tested `pathname` FIRST and returned early, so a caller on the login page
172
+ * never reached the attacker-controlled parameter at all - safe, but only
173
+ * because that caller happened to pass a value that tripped the function's own
174
+ * first guard. Nothing in the code named that dependency, and an argument
175
+ * reorder would have opened it. Here BOTH inputs are validated, so the
176
+ * protection no longer depends on which one is passed.
177
+ */
178
+ function resolveCallbackDestination(pathname, searchParams, fallback = exports.DEFAULT_CALLBACK) {
179
+ const requested = searchParams?.get('callbackUrl');
180
+ if (isSafeCallbackPath(requested))
181
+ return safeCallbackPath(requested, fallback);
182
+ return safeCallbackPath(pathname, fallback);
183
+ }
@@ -9,7 +9,6 @@ export type ProfileResponse = {
9
9
  phone_confirmed?: boolean;
10
10
  two_factor_enabled?: boolean;
11
11
  roles?: string[];
12
- contact_information?: Record<string, any>;
13
12
  [key: string]: any;
14
13
  };
15
14
  export declare function useProfile(): import("@tanstack/react-query").UseQueryResult<ProfileResponse, Error>;