@payez/next-mvp 4.4.0 → 4.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api-handlers/mfa/totp-verify.d.ts +3 -1
- package/dist/api-handlers/mfa/totp-verify.js +22 -3
- package/dist/api-handlers/session/refresh-viability.js +2 -2
- package/dist/api-handlers/session/viability.js +1 -1
- package/dist/auth/auth-decision.js +27 -8
- package/dist/auth/better-auth.js +123 -1
- package/dist/auth/safe-callback.d.ts +63 -0
- package/dist/auth/safe-callback.js +112 -0
- package/dist/hooks/useProfile.d.ts +0 -1
- package/dist/lib/session-store.js +56 -7
- package/dist/middleware/create-middleware.js +45 -13
- package/dist/pages/profile/EnhancedProfilePage.js +38 -13
- package/dist/pages/verify-code/page.js +26 -1
- package/dist/routes/auth/viability.d.ts +2 -2
- package/dist/routes/auth/viability.js +2 -2
- package/dist/server/auth-guard.js +26 -1
- package/package.json +1 -1
- package/scripts/check-publish-health.mjs +53 -11
- package/src/api-handlers/mfa/totp-verify.ts +24 -3
- package/src/api-handlers/session/refresh-viability.ts +2 -2
- package/src/api-handlers/session/viability.ts +1 -1
- package/src/auth/auth-decision.ts +28 -8
- package/src/auth/better-auth.ts +128 -1
- package/src/auth/safe-callback.ts +113 -0
- package/src/hooks/useProfile.ts +8 -1
- package/src/lib/session-store.ts +66 -7
- package/src/middleware/create-middleware.ts +45 -13
- package/src/pages/profile/EnhancedProfilePage.tsx +43 -14
- package/src/pages/verify-code/page.tsx +26 -1
- package/src/routes/auth/viability.ts +2 -2
- package/src/server/auth-guard.ts +27 -1
- package/dist/middleware/auth-decision.d.ts +0 -33
- package/dist/middleware/auth-decision.js +0 -65
- package/src/middleware/auth-decision.ts +0 -43
package/src/auth/better-auth.ts
CHANGED
|
@@ -34,6 +34,26 @@ export function secretFingerprint(value: string): string {
|
|
|
34
34
|
return createHash('sha256').update(value).digest('hex').slice(0, 12);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* 273183: keys written onto the `ba:{slug}:{token}` record by an author OTHER
|
|
39
|
+
* than Better Auth, which must survive a Better Auth session write.
|
|
40
|
+
*
|
|
41
|
+
* Today this is exactly `idpTokens`, written by `storeIdpTokensInSession`. The
|
|
42
|
+
* list is a named constant rather than a hardcoded check because the shape of
|
|
43
|
+
* the bug is "a second author appeared and the adapter did not know" — the next
|
|
44
|
+
* enrichment key should be added here, not rediscovered as a strip.
|
|
45
|
+
*/
|
|
46
|
+
const PRESERVED_SESSION_KEYS = ['idpTokens'] as const;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* True for a JSON object we can safely merge into — not null, not an array.
|
|
50
|
+
* `typeof null === 'object'` and arrays are objects, and merging into either
|
|
51
|
+
* would corrupt the record rather than preserve it.
|
|
52
|
+
*/
|
|
53
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
54
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
37
57
|
/**
|
|
38
58
|
* Better Auth social provider config shape.
|
|
39
59
|
*/
|
|
@@ -498,7 +518,114 @@ export function createBetterAuthInstance(
|
|
|
498
518
|
*/
|
|
499
519
|
set: async (key: string, value: string, ttl?: number) => {
|
|
500
520
|
const redis = getRedis();
|
|
501
|
-
|
|
521
|
+
const redisKey = `ba:${appSlug}:${key}`;
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* 273183 — READ-MODIFY-WRITE, BECAUSE THIS RECORD HAS TWO AUTHORS.
|
|
525
|
+
* (Found by DotNetPert-Scout; handed over as Cluster-B root-cause
|
|
526
|
+
* evidence under BAPert's R920(a). Reproduced here before fixing.)
|
|
527
|
+
*
|
|
528
|
+
* Better Auth owns `{session, user}` and serialises exactly that. But
|
|
529
|
+
* `storeIdpTokensInSession` (below, ~:1010) ENRICHES the same key with
|
|
530
|
+
* `idpTokens` via its own read-modify-write. This setter did a blind
|
|
531
|
+
* `setex` of Better Auth's payload, so every write it makes — including
|
|
532
|
+
* each routine `updateAge` session rewrite, not merely an expiry —
|
|
533
|
+
* STRIPPED `idpTokens` back off the record.
|
|
534
|
+
*
|
|
535
|
+
* The consumer then falls back to an identity husk: `mfaVerified:false`,
|
|
536
|
+
* `roles:[]`. It fails CLOSED, so it is not a 369782-class bypass — but
|
|
537
|
+
* per BAPert it is a public-test blocker on its own terms, because a
|
|
538
|
+
* tester who trips the `updateAge` cadence gets a spurious 2FA re-prompt
|
|
539
|
+
* AND SILENTLY LOSES THEIR ROLES mid-session. That is a visible product
|
|
540
|
+
* failure regardless of which direction it fails in.
|
|
541
|
+
*
|
|
542
|
+
* WHY MERGE HERE RATHER THAN MOVE THE DATA. The enriching writer already
|
|
543
|
+
* does a read-modify-write; it was correct. The bug is that ONE of the
|
|
544
|
+
* two authors of this key wrote as though it were the only one. Fixing
|
|
545
|
+
* the adapter fixes it for every future enrichment key, whereas moving
|
|
546
|
+
* `idpTokens` elsewhere would leave the next one to rediscover this.
|
|
547
|
+
*
|
|
548
|
+
* ONLY keys in PRESERVED_KEYS are carried, and ONLY when the incoming
|
|
549
|
+
* payload does not set them — so Better Auth still wins on everything it
|
|
550
|
+
* owns, and an explicit incoming value is never overridden by a stale one.
|
|
551
|
+
*
|
|
552
|
+
* NOT ATOMIC, and deliberately not: an enrichment landing between this
|
|
553
|
+
* read and this write is still lost. Closing that needs a WATCH/MULTI or
|
|
554
|
+
* a Lua script, which is a larger change than tonight allows. This
|
|
555
|
+
* narrows the window from "every single write" to "a genuine race", and
|
|
556
|
+
* the residual is recorded here rather than left to be re-found.
|
|
557
|
+
*/
|
|
558
|
+
let payload = value;
|
|
559
|
+
try {
|
|
560
|
+
const existingRaw = await redis.get(redisKey);
|
|
561
|
+
if (existingRaw) {
|
|
562
|
+
const existing = JSON.parse(existingRaw);
|
|
563
|
+
const incoming = JSON.parse(value);
|
|
564
|
+
if (isPlainObject(existing) && isPlainObject(incoming)) {
|
|
565
|
+
let carried = false;
|
|
566
|
+
for (const preserved of PRESERVED_SESSION_KEYS) {
|
|
567
|
+
if (preserved in existing && !(preserved in incoming)) {
|
|
568
|
+
incoming[preserved] = existing[preserved];
|
|
569
|
+
carried = true;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
if (carried) payload = JSON.stringify(incoming);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
} catch (mergeErr) {
|
|
576
|
+
/**
|
|
577
|
+
* This adapter also stores `verification:*` and `active-sessions-*`
|
|
578
|
+
* records, which are not necessarily session-shaped JSON. A record we
|
|
579
|
+
* cannot parse is not a session to enrich, so writing through unchanged
|
|
580
|
+
* is correct rather than merely tolerable.
|
|
581
|
+
*
|
|
582
|
+
* WARNED, NOT THROWN, and the asymmetry is the point: this block is an
|
|
583
|
+
* ENRICHMENT-PRESERVING read. Letting it fail the write would convert a
|
|
584
|
+
* degraded read into a lost session — the precise inversion 170033 fix 1
|
|
585
|
+
* above exists to prevent. The `setex` below is outside this try and is
|
|
586
|
+
* still unswallowed, so a genuine write failure still propagates.
|
|
587
|
+
*/
|
|
588
|
+
console.warn(
|
|
589
|
+
`[BETTER_AUTH] secondaryStorage.set: could not preserve enrichment keys on ` +
|
|
590
|
+
`ba:${appSlug}:sha256:${secretFingerprint(key)} — writing Better Auth's payload ` +
|
|
591
|
+
`as-is. If this record held idpTokens they are now gone (273183):`,
|
|
592
|
+
mergeErr instanceof Error ? mergeErr.message : String(mergeErr)
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* QA 6952 / R913 - CLAMP, DO NOT DEFER.
|
|
598
|
+
*
|
|
599
|
+
* This was `ttl ?? SESSION_TTL`, which READS as "SESSION_TTL is the single
|
|
600
|
+
* source of session TTL" - what session-store.ts claims, and the mechanism
|
|
601
|
+
* by which the 7d-vs-3d zombie window was supposedly closed for good.
|
|
602
|
+
*
|
|
603
|
+
* IT WAS DEAD CODE. Better Auth ALWAYS supplies a ttl: every one of the
|
|
604
|
+
* eight secondaryStorage.set call sites in
|
|
605
|
+
* better-auth/dist/db/internal-adapter.mjs passes one explicitly (:36,
|
|
606
|
+
* :180, :189, :309, :320, :344, :553, :622). `??` fires only on
|
|
607
|
+
* null/undefined, so the fallback NEVER RAN. Every `ba:` record took Better
|
|
608
|
+
* Auth's 7d default - `session.expiresIn` is not configured in this file -
|
|
609
|
+
* while the canonical store stayed at SESSION_TTL (3d). That 4-day drift IS
|
|
610
|
+
* the zombie window the constant exists to prevent: the guard was bypassed
|
|
611
|
+
* by the very parameter written to defer to it.
|
|
612
|
+
*
|
|
613
|
+
* `Math.min`, NOT a hard SESSION_TTL: this same adapter also stores
|
|
614
|
+
* `verification:*` (:553, :622) and `active-sessions-*` (:180, :320, :344)
|
|
615
|
+
* records whose TTLs are legitimately SHORTER than 3d. Overwriting those
|
|
616
|
+
* would EXTEND them - turning a fix for over-long sessions into a bug that
|
|
617
|
+
* keeps verification codes alive for three days. Clamp DOWN, never up.
|
|
618
|
+
*
|
|
619
|
+
* Clamped down to 3d rather than raising the canonical store to 7d because
|
|
620
|
+
* SESSION_TTL matches the IDP refresh-token lifetime: a session outliving
|
|
621
|
+
* the refresh token sustaining it cannot be refreshed, so the extra days are
|
|
622
|
+
* a husk that reads as authenticated. Fail-closed beats ambiguous.
|
|
623
|
+
*
|
|
624
|
+
* DO NOT "fix" this by making storeIdpTokensInSession preserve Better
|
|
625
|
+
* Auth's 7d. That deletes the last remnant of QA 6952 and makes the drift
|
|
626
|
+
* universal. See test/ba-session-ttl-clamp.test.cjs.
|
|
627
|
+
*/
|
|
628
|
+
await redis.setex(redisKey, Math.min(ttl ?? SESSION_TTL, SESSION_TTL), payload);
|
|
502
629
|
},
|
|
503
630
|
delete: async (key: string) => {
|
|
504
631
|
try {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE definition of "is this callback target safe to navigate to", for the
|
|
3
|
+
* whole package (375656).
|
|
4
|
+
*
|
|
5
|
+
* *** THIS DELEGATES PARSING TO THE URL PARSER. DO NOT REPLACE IT WITH
|
|
6
|
+
* *** startsWith() CHECKS. THAT APPROACH FAILED FOUR TIMES.
|
|
7
|
+
*
|
|
8
|
+
* WHY THIS FILE EXISTS: `@payez/next-mvp` shipped THREE bodies named
|
|
9
|
+
* `makeAuthDecision`, each carrying its own `callbackUrl.startsWith('/account-auth/')`
|
|
10
|
+
* test. That test is a REDIRECT-LOOP guard sitting in the seat where an ORIGIN
|
|
11
|
+
* check belongs - its own comment says "prevents infinite loop" - so
|
|
12
|
+
* `https://evil.example.com` does not start with `/account-auth/`, passes
|
|
13
|
+
* through untouched, and becomes the redirect target. The exported copy
|
|
14
|
+
* (`auth/auth-decision.ts`, re-exported from `index.ts` and `edge/middleware.ts`)
|
|
15
|
+
* is public API, so that is a published open redirect independent of who calls it.
|
|
16
|
+
*
|
|
17
|
+
* HISTORY, because it is the whole argument for the current shape (267097 /
|
|
18
|
+
* 267174 / 267448), carried over verbatim from the portal's `src/lib/safe-callback.ts`
|
|
19
|
+
* where this guard was proven (0 leaks / 21 hostile inputs):
|
|
20
|
+
*
|
|
21
|
+
* v1 `startsWith('/') && !startsWith('//')` -> bypassed by /BACKSLASH host
|
|
22
|
+
* v2 + `!startsWith('/' + BACKSLASH)` -> bypassed by /TAB/host
|
|
23
|
+
* ... and /LF/host, /CR/host
|
|
24
|
+
*
|
|
25
|
+
* Both bypasses work the same way: the browser NORMALISES the string AFTER the
|
|
26
|
+
* guard has finished looking at it. The WHATWG URL parser converts backslashes
|
|
27
|
+
* to forward slashes and STRIPS all ASCII tab, LF and CR before resolving. So a
|
|
28
|
+
* prefix check can always be defeated by a character that is not there yet when
|
|
29
|
+
* it runs. Each fix bought one character. The list was never provably complete.
|
|
30
|
+
*
|
|
31
|
+
* SO: stop re-implementing the parser and ask it instead. Resolve the input
|
|
32
|
+
* against a synthetic base and compare ORIGINS. Anything that escapes the
|
|
33
|
+
* origin - however it is spelled, including spellings nobody has found yet -
|
|
34
|
+
* fails one comparison. The returned value is the parser's own NORMALISED
|
|
35
|
+
* output, so an accepted path cannot smuggle a control character onward either.
|
|
36
|
+
*
|
|
37
|
+
* A synthetic base is used deliberately so this works identically in a server
|
|
38
|
+
* component (no window), on the client, and in the edge runtime.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** Where we send anyone whose requested target we will not honour. */
|
|
42
|
+
export const DEFAULT_CALLBACK = '/';
|
|
43
|
+
|
|
44
|
+
/** The auth surface. A callback pointing back into it causes a redirect cycle. */
|
|
45
|
+
const AUTH_PREFIX = '/account-auth/';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Synthetic, unreachable base. Only ever used as the resolution origin for the
|
|
49
|
+
* comparison below - never emitted, never navigated to. `.invalid` is reserved
|
|
50
|
+
* by RFC 2606 and can never resolve.
|
|
51
|
+
*/
|
|
52
|
+
const BASE = 'http://safe-callback.invalid';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The value to navigate to: a same-origin relative path, NORMALISED by the URL
|
|
56
|
+
* parser, or `fallback` for anything that escapes the origin or loops back into
|
|
57
|
+
* the auth surface. Never throws.
|
|
58
|
+
*/
|
|
59
|
+
export function safeCallbackPath(raw: unknown, fallback: string = DEFAULT_CALLBACK): string {
|
|
60
|
+
if (typeof raw !== 'string' || raw.length === 0) return fallback;
|
|
61
|
+
|
|
62
|
+
let url: URL;
|
|
63
|
+
try {
|
|
64
|
+
url = new URL(raw, BASE);
|
|
65
|
+
} catch {
|
|
66
|
+
return fallback;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Anything that resolved to a different origin escaped, however it was spelled:
|
|
70
|
+
// absolute, protocol-relative //host, backslash /\host, tab/LF/CR variants, or
|
|
71
|
+
// a scheme like javascript: (whose origin is opaque and never equals BASE).
|
|
72
|
+
if (url.origin !== BASE) return fallback;
|
|
73
|
+
|
|
74
|
+
// Loopbacks into the auth surface cause redirect cycles.
|
|
75
|
+
if (url.pathname.startsWith(AUTH_PREFIX)) return fallback;
|
|
76
|
+
|
|
77
|
+
// The parser's normalised output - control characters already stripped.
|
|
78
|
+
return url.pathname + url.search + url.hash;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** True when `raw` is a callback target we are willing to honour. */
|
|
82
|
+
export function isSafeCallbackPath(raw: unknown): raw is string {
|
|
83
|
+
if (typeof raw !== 'string' || raw.length === 0) return false;
|
|
84
|
+
try {
|
|
85
|
+
const url = new URL(raw, BASE);
|
|
86
|
+
return url.origin === BASE && !url.pathname.startsWith(AUTH_PREFIX);
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Pick the post-login destination: an explicit `?callbackUrl=` when it is one we
|
|
94
|
+
* will honour, otherwise the current path, otherwise `/`.
|
|
95
|
+
*
|
|
96
|
+
* This replaces the package's three hand-rolled `getSafeCallbackUrl` /
|
|
97
|
+
* `getCallbackUrl` bodies. Note the ARGUMENT ORDER DEPENDENCY those had: they
|
|
98
|
+
* tested `pathname` FIRST and returned early, so a caller on the login page
|
|
99
|
+
* never reached the attacker-controlled parameter at all - safe, but only
|
|
100
|
+
* because that caller happened to pass a value that tripped the function's own
|
|
101
|
+
* first guard. Nothing in the code named that dependency, and an argument
|
|
102
|
+
* reorder would have opened it. Here BOTH inputs are validated, so the
|
|
103
|
+
* protection no longer depends on which one is passed.
|
|
104
|
+
*/
|
|
105
|
+
export function resolveCallbackDestination(
|
|
106
|
+
pathname: string,
|
|
107
|
+
searchParams?: URLSearchParams | null,
|
|
108
|
+
fallback: string = DEFAULT_CALLBACK
|
|
109
|
+
): string {
|
|
110
|
+
const requested = searchParams?.get('callbackUrl');
|
|
111
|
+
if (isSafeCallbackPath(requested)) return safeCallbackPath(requested, fallback);
|
|
112
|
+
return safeCallbackPath(pathname, fallback);
|
|
113
|
+
}
|
package/src/hooks/useProfile.ts
CHANGED
|
@@ -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/session-store.ts
CHANGED
|
@@ -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:
|
|
445
|
-
twoFactorComplete:
|
|
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
|
-
...
|
|
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
|
-
//
|
|
467
|
-
//
|
|
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:
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
// ---
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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.
|
|
242
|
-
address_line_1: rawProfile.
|
|
243
|
-
address_line_2: rawProfile.
|
|
244
|
-
city: rawProfile.
|
|
245
|
-
state_id: rawProfile.
|
|
246
|
-
state_name: rawProfile.
|
|
247
|
-
postal_code: rawProfile.
|
|
248
|
-
country_code: rawProfile.
|
|
249
|
-
country_name: rawProfile.
|
|
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
|
-
|
|
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 ??
|
|
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 ??
|
|
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:', {
|
package/src/server/auth-guard.ts
CHANGED
|
@@ -127,7 +127,33 @@ export async function authGuard(options?: AuthGuardOptions): Promise<AuthGuardRe
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
if (requires2FA) {
|
|
130
|
-
|
|
130
|
+
// 369858: the legacy `?? (sessionData as any).twoFactorComplete` carry is REMOVED.
|
|
131
|
+
// This is THE access gate - the only one of the 8 carry sites that grants access
|
|
132
|
+
// rather than reporting status - so it reads exactly one source of truth.
|
|
133
|
+
//
|
|
134
|
+
// Removing it is fail-closed. No current writer emits the legacy field alone:
|
|
135
|
+
// session-store.ts:444-445 writes both as a pair from a server-derived stepped-up
|
|
136
|
+
// token, auth/refresh.ts:376 writes it as a MIRROR of mfaVerified, and
|
|
137
|
+
// routes/auth/session.ts:149-157 refuses both on PRESENCE, so a caller cannot
|
|
138
|
+
// assert either. The carry therefore only ever served sessions minted before
|
|
139
|
+
// `mfaVerified` existed, and for those the correct behaviour is re-verify, not
|
|
140
|
+
// grandfather. Cost is one forced re-challenge for a stale legacy session.
|
|
141
|
+
//
|
|
142
|
+
// Severity note, so nobody re-inflates this later: the operator was `??`
|
|
143
|
+
// (nullish), NOT `||`. `false ?? x` is `false`, so an explicitly DENIED session
|
|
144
|
+
// could never be upgraded by a stale flag. This was legacy grandfathering, not a
|
|
145
|
+
// live 2FA bypass. Had it been `||`, it would have been the bypass.
|
|
146
|
+
const mfaVerified = sessionData.mfaVerified ?? false;
|
|
147
|
+
|
|
148
|
+
// 339563 / 369858 §3: THIS IS STILL A FAIL-OPEN AND IS DELIBERATELY LEFT VISIBLE.
|
|
149
|
+
// An absent mfaExpiresAt collapses to 0, so `mfaExpired` is false and the MFA
|
|
150
|
+
// verdict NEVER EXPIRES. That needs no legacy-session hypothesis: it fires on a
|
|
151
|
+
// current `mfaVerified: true` session whose token carried no `mfa_expires` claim -
|
|
152
|
+
// the passwordless/primary-credential lane, named on the write path in the
|
|
153
|
+
// source's own voice at session-store.ts:464-467 ("it IS the case the consumers
|
|
154
|
+
// currently fail OPEN on"). Removing the carry above does NOT touch this.
|
|
155
|
+
// Tracked separately by BAPert; not fixed here because failing closed on absent
|
|
156
|
+
// expiry would deny the passwordless lane outright and that is a product call.
|
|
131
157
|
const mfaExpiresAt = sessionData.mfaExpiresAt || 0;
|
|
132
158
|
const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
|
|
133
159
|
|