@payez/next-mvp 4.4.1 → 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 (33) 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/src/api-handlers/mfa/totp-verify.ts +24 -3
  19. package/src/api-handlers/session/refresh-viability.ts +2 -2
  20. package/src/api-handlers/session/viability.ts +1 -1
  21. package/src/auth/auth-decision.ts +28 -8
  22. package/src/auth/better-auth.ts +128 -1
  23. package/src/auth/safe-callback.ts +113 -0
  24. package/src/hooks/useProfile.ts +8 -1
  25. package/src/lib/session-store.ts +66 -7
  26. package/src/middleware/create-middleware.ts +45 -13
  27. package/src/pages/profile/EnhancedProfilePage.tsx +43 -14
  28. package/src/pages/verify-code/page.tsx +26 -1
  29. package/src/routes/auth/viability.ts +2 -2
  30. package/src/server/auth-guard.ts +27 -1
  31. package/dist/middleware/auth-decision.d.ts +0 -33
  32. package/dist/middleware/auth-decision.js +0 -65
  33. package/src/middleware/auth-decision.ts +0 -43
@@ -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,63 @@
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
+ /** Where we send anyone whose requested target we will not honour. */
41
+ export declare const DEFAULT_CALLBACK = "/";
42
+ /**
43
+ * The value to navigate to: a same-origin relative path, NORMALISED by the URL
44
+ * parser, or `fallback` for anything that escapes the origin or loops back into
45
+ * the auth surface. Never throws.
46
+ */
47
+ export declare function safeCallbackPath(raw: unknown, fallback?: string): string;
48
+ /** True when `raw` is a callback target we are willing to honour. */
49
+ export declare function isSafeCallbackPath(raw: unknown): raw is string;
50
+ /**
51
+ * Pick the post-login destination: an explicit `?callbackUrl=` when it is one we
52
+ * will honour, otherwise the current path, otherwise `/`.
53
+ *
54
+ * This replaces the package's three hand-rolled `getSafeCallbackUrl` /
55
+ * `getCallbackUrl` bodies. Note the ARGUMENT ORDER DEPENDENCY those had: they
56
+ * tested `pathname` FIRST and returned early, so a caller on the login page
57
+ * never reached the attacker-controlled parameter at all - safe, but only
58
+ * because that caller happened to pass a value that tripped the function's own
59
+ * first guard. Nothing in the code named that dependency, and an argument
60
+ * reorder would have opened it. Here BOTH inputs are validated, so the
61
+ * protection no longer depends on which one is passed.
62
+ */
63
+ export declare function resolveCallbackDestination(pathname: string, searchParams?: URLSearchParams | null, fallback?: string): string;
@@ -0,0 +1,112 @@
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
+ *
26
+ * Both bypasses work the same way: the browser NORMALISES the string AFTER the
27
+ * guard has finished looking at it. The WHATWG URL parser converts backslashes
28
+ * to forward slashes and STRIPS all ASCII tab, LF and CR before resolving. So a
29
+ * prefix check can always be defeated by a character that is not there yet when
30
+ * it runs. Each fix bought one character. The list was never provably complete.
31
+ *
32
+ * SO: stop re-implementing the parser and ask it instead. Resolve the input
33
+ * against a synthetic base and compare ORIGINS. Anything that escapes the
34
+ * origin - however it is spelled, including spellings nobody has found yet -
35
+ * fails one comparison. The returned value is the parser's own NORMALISED
36
+ * output, so an accepted path cannot smuggle a control character onward either.
37
+ *
38
+ * A synthetic base is used deliberately so this works identically in a server
39
+ * component (no window), on the client, and in the edge runtime.
40
+ */
41
+ Object.defineProperty(exports, "__esModule", { value: true });
42
+ exports.DEFAULT_CALLBACK = void 0;
43
+ exports.safeCallbackPath = safeCallbackPath;
44
+ exports.isSafeCallbackPath = isSafeCallbackPath;
45
+ exports.resolveCallbackDestination = resolveCallbackDestination;
46
+ /** Where we send anyone whose requested target we will not honour. */
47
+ exports.DEFAULT_CALLBACK = '/';
48
+ /** The auth surface. A callback pointing back into it causes a redirect cycle. */
49
+ const AUTH_PREFIX = '/account-auth/';
50
+ /**
51
+ * Synthetic, unreachable base. Only ever used as the resolution origin for the
52
+ * comparison below - never emitted, never navigated to. `.invalid` is reserved
53
+ * by RFC 2606 and can never resolve.
54
+ */
55
+ const BASE = 'http://safe-callback.invalid';
56
+ /**
57
+ * The value to navigate to: a same-origin relative path, NORMALISED by the URL
58
+ * parser, or `fallback` for anything that escapes the origin or loops back into
59
+ * the auth surface. Never throws.
60
+ */
61
+ function safeCallbackPath(raw, fallback = exports.DEFAULT_CALLBACK) {
62
+ if (typeof raw !== 'string' || raw.length === 0)
63
+ return fallback;
64
+ let url;
65
+ try {
66
+ url = new URL(raw, BASE);
67
+ }
68
+ catch {
69
+ return fallback;
70
+ }
71
+ // Anything that resolved to a different origin escaped, however it was spelled:
72
+ // absolute, protocol-relative //host, backslash /\host, tab/LF/CR variants, or
73
+ // a scheme like javascript: (whose origin is opaque and never equals BASE).
74
+ if (url.origin !== BASE)
75
+ return fallback;
76
+ // Loopbacks into the auth surface cause redirect cycles.
77
+ if (url.pathname.startsWith(AUTH_PREFIX))
78
+ return fallback;
79
+ // The parser's normalised output - control characters already stripped.
80
+ return url.pathname + url.search + url.hash;
81
+ }
82
+ /** True when `raw` is a callback target we are willing to honour. */
83
+ function isSafeCallbackPath(raw) {
84
+ if (typeof raw !== 'string' || raw.length === 0)
85
+ return false;
86
+ try {
87
+ const url = new URL(raw, BASE);
88
+ return url.origin === BASE && !url.pathname.startsWith(AUTH_PREFIX);
89
+ }
90
+ catch {
91
+ return false;
92
+ }
93
+ }
94
+ /**
95
+ * Pick the post-login destination: an explicit `?callbackUrl=` when it is one we
96
+ * will honour, otherwise the current path, otherwise `/`.
97
+ *
98
+ * This replaces the package's three hand-rolled `getSafeCallbackUrl` /
99
+ * `getCallbackUrl` bodies. Note the ARGUMENT ORDER DEPENDENCY those had: they
100
+ * tested `pathname` FIRST and returned early, so a caller on the login page
101
+ * never reached the attacker-controlled parameter at all - safe, but only
102
+ * because that caller happened to pass a value that tripped the function's own
103
+ * first guard. Nothing in the code named that dependency, and an argument
104
+ * reorder would have opened it. Here BOTH inputs are validated, so the
105
+ * protection no longer depends on which one is passed.
106
+ */
107
+ function resolveCallbackDestination(pathname, searchParams, fallback = exports.DEFAULT_CALLBACK) {
108
+ const requested = searchParams?.get('callbackUrl');
109
+ if (isSafeCallbackPath(requested))
110
+ return safeCallbackPath(requested, fallback);
111
+ return safeCallbackPath(pathname, fallback);
112
+ }
@@ -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>;
@@ -389,6 +389,26 @@ async function transitionTo2FASession(sessionToken, tokens, mfaMethod) {
389
389
  console.log('[transitionTo2FASession] Extracted bearerKeyId (kid) from new JWT header:', bearerKeyId);
390
390
  }
391
391
  }
392
+ // 339563: derive the expiry ONCE - it is both a field to write and the
393
+ // evidence test below. Re-deriving it twice invites the two reads to drift.
394
+ const mfaLifetime = mfaLifetimeFromAccessToken(newAccessToken);
395
+ // *** R908(e). ABSENCE OF THE EXPIRY IS NOT A COMPLETED CEREMONY. ***
396
+ //
397
+ // `mfaExpiresAt` is the only evidence we hold that a real MFA ceremony took
398
+ // place and when it lapses. When the stepped-up token carries no `mfa_expires`
399
+ // claim we do NOT know that this session is MFA-verified, and R908(e) binds
400
+ // the consumer to the third arm:
401
+ //
402
+ // producers degrade -> consumers RE-CHALLENGE -> nobody infers a
403
+ // negative from a silence.
404
+ //
405
+ // So: never mint `mfaVerified: true` off a token that carries no expiry (that
406
+ // is forging evidence of a ceremony we did not observe - 12188/12390), and
407
+ // never hard-refuse the user either (R486: null is "cannot determine", not
408
+ // "not 2FA-backed"). Leave the session un-verified and set `nextStep:
409
+ // 'mfa_verify'`, which is the field the gate already reads to mean "a usable
410
+ // method exists; challenge them" (SessionModel.ts:136).
411
+ const mfaEvidencePresent = mfaLifetime.mfaExpiresAt !== undefined;
392
412
  // Support both old and new field names in input
393
413
  // CRITICAL: Set BOTH mfaVerified (new) AND twoFactorComplete (legacy) for compatibility
394
414
  // auth.ts session callback reads twoFactorComplete, so we must set it here
@@ -396,15 +416,28 @@ async function transitionTo2FASession(sessionToken, tokens, mfaMethod) {
396
416
  idpAccessToken: newAccessToken,
397
417
  idpRefreshToken: tokens.idpRefreshToken || tokens.refreshToken,
398
418
  idpAccessTokenExpires: tokens.idpAccessTokenExpires || tokens.accessTokenExpires,
399
- mfaVerified: true,
400
- twoFactorComplete: true, // Legacy field - required by auth.ts session callback
419
+ mfaVerified: mfaEvidencePresent,
420
+ twoFactorComplete: mfaEvidencePresent, // Legacy field - required by auth.ts session callback
401
421
  mfaMethod: mfaMethod,
402
422
  // Update bearerKeyId if extracted from new token
403
423
  ...(bearerKeyId && { bearerKeyId }),
404
424
  // 339563: write the expiry in the SAME update that sets the flag. We already
405
425
  // hold the stepped-up token here and already decode its header above.
406
- ...mfaLifetimeFromAccessToken(newAccessToken),
426
+ ...mfaLifetime,
427
+ // R908(e): no evidence -> route to the challenge, do not grant and do not eject.
428
+ ...(mfaEvidencePresent ? {} : { nextStep: 'mfa_verify' }),
407
429
  };
430
+ if (!mfaEvidencePresent) {
431
+ console.warn('[transitionTo2FASession] R908(e): the stepped-up token carries NO mfa_expires ' +
432
+ 'claim, so there is no evidence of an MFA ceremony to record. NOT setting ' +
433
+ 'mfaVerified; setting nextStep=mfa_verify so the consumer re-challenges. ' +
434
+ 'If this fires for a lane that DID complete a real ceremony, the defect is ' +
435
+ 'on the emit side (R908(b)) - do not "fix" it by minting the flag here.', {
436
+ sessionToken: sessionToken?.substring(0, 8) + '...',
437
+ mfaMethod,
438
+ hasAccessToken: !!newAccessToken,
439
+ });
440
+ }
408
441
  const refreshExpires = tokens.idpRefreshTokenExpires || tokens.refreshTokenExpires;
409
442
  if (refreshExpires !== undefined) {
410
443
  updates.idpRefreshTokenExpires = refreshExpires;
@@ -415,11 +448,13 @@ async function transitionTo2FASession(sessionToken, tokens, mfaMethod) {
415
448
  mfaMethod: updates.mfaMethod,
416
449
  hasIdpAccessToken: !!updates.idpAccessToken,
417
450
  // 339563: surface the expiry we derived. `mfaExpiresAt: undefined` here is
418
- // the passwordless/primary-credential lane (token carried no mfa_expires),
419
- // NOT a bug - but it IS the case the consumers currently fail OPEN on, so
420
- // it needs to be visible in the log rather than inferred from its absence.
451
+ // the passwordless/primary-credential lane (token carried no mfa_expires).
452
+ // Since R908(e) that absence no longer fails OPEN: it withholds mfaVerified
453
+ // and sets nextStep=mfa_verify instead, so log both together - the pair is
454
+ // the whole decision.
421
455
  mfaExpiresAt: updates.mfaExpiresAt,
422
456
  mfaExpiresAtISO: updates.mfaExpiresAt ? new Date(updates.mfaExpiresAt).toISOString() : undefined,
457
+ nextStep: updates.nextStep,
423
458
  });
424
459
  const result = await updateSession(sessionToken, updates);
425
460
  console.log('[transitionTo2FASession] Result:', {
@@ -477,9 +512,23 @@ async function mark2FAComplete(sessionToken, accessToken) {
477
512
  'this session has no MFA expiry. Expected on a primary-credential lane ' +
478
513
  '(e.g. passwordless email); unexpected on an ordinary 2FA completion.');
479
514
  }
515
+ // R908(e): identical rule to transitionTo2FASession above - no expiry derived
516
+ // means no observed ceremony, so we re-challenge rather than mint the flag.
517
+ // This path matters more than its sibling: `mark2FAComplete(sessionToken)`
518
+ // with the accessToken omitted used to write a bare `mfaVerified: true` that
519
+ // NO consumer could ever expire, and it is reachable from route handlers that
520
+ // take their tokens from the request body.
521
+ const mfaEvidencePresent = mfaLifetime.mfaExpiresAt !== undefined;
522
+ if (!mfaEvidencePresent) {
523
+ console.warn('[mark2FAComplete] R908(e): no mfa_expires could be derived, so mfaVerified ' +
524
+ 'is NOT being set. Writing nextStep=mfa_verify instead so the consumer ' +
525
+ 're-challenges. A caller that needs the grant must pass the stepped-up ' +
526
+ 'access token that carries the claim.');
527
+ }
480
528
  return updateSession(sessionToken, {
481
- mfaVerified: true,
529
+ mfaVerified: mfaEvidencePresent,
482
530
  ...mfaLifetime,
531
+ ...(mfaEvidencePresent ? {} : { nextStep: 'mfa_verify' }),
483
532
  });
484
533
  }
485
534
  /**