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