@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,64 @@
|
|
|
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
|
+
* The assurance level used when the session and token say NOTHING about it.
|
|
33
|
+
*
|
|
34
|
+
* '1' = a single factor. It does not assert that a second factor happened.
|
|
35
|
+
* It must never be '2' (or higher): that is the value the IDP reads as
|
|
36
|
+
* "a second factor was completed", and an absent claim is not evidence of one.
|
|
37
|
+
*/
|
|
38
|
+
export declare const ACR_WHEN_ABSENT = "1";
|
|
39
|
+
/**
|
|
40
|
+
* The authentication-methods list used when nothing is known.
|
|
41
|
+
*
|
|
42
|
+
* Empty asserts nothing. It must never contain 'mfa' (or any second-factor
|
|
43
|
+
* method): claiming a method that was never exercised is the same fabrication
|
|
44
|
+
* as ACR '2', and because the two corroborate each other a downstream check
|
|
45
|
+
* that cross-validates amr against acr would accept the fabricated pair.
|
|
46
|
+
*/
|
|
47
|
+
export declare const AMR_WHEN_ABSENT: readonly string[];
|
|
48
|
+
/**
|
|
49
|
+
* Resolve an assurance level from any number of candidate sources, in
|
|
50
|
+
* priority order, falling back to the level that asserts no second factor.
|
|
51
|
+
*
|
|
52
|
+
* Accepts the candidates the refresh lane actually has: the stored
|
|
53
|
+
* `session.authenticationLevel` first, then the `acr` claim on the token.
|
|
54
|
+
*/
|
|
55
|
+
export declare function resolveAcrLevel(...candidates: unknown[]): string;
|
|
56
|
+
/**
|
|
57
|
+
* Resolve the authentication-methods list.
|
|
58
|
+
*
|
|
59
|
+
* `raw` may arrive as a JSON string (the shape stored on the token) or as an
|
|
60
|
+
* array (the shape held on the session). Anything unparseable resolves to the
|
|
61
|
+
* empty list rather than to an assumed set of methods - a malformed claim is
|
|
62
|
+
* not evidence that mfa happened.
|
|
63
|
+
*/
|
|
64
|
+
export declare function resolveAuthMethods(...candidates: unknown[]): string[];
|
package/dist/lib/acr.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* CARD 375639 - the one place this package decides what an ABSENT assurance
|
|
4
|
+
* signal means.
|
|
5
|
+
*
|
|
6
|
+
* Card 375276 fixed this question inside `payez_idp_admin_web/src` (commit
|
|
7
|
+
* d9c6ac0, BAPert R980 remedy (a)) and fenced its own scope explicitly:
|
|
8
|
+
*
|
|
9
|
+
* "fallbacks on the stored `session.authenticationLevel` (refresh lane)
|
|
10
|
+
* are deliberately NOT covered ... That gap is 375276's remaining half."
|
|
11
|
+
*
|
|
12
|
+
* `src/api/auth-handler.ts` IS that remaining half, and it is in the shared
|
|
13
|
+
* package rather than in one app, so every consumer of the published
|
|
14
|
+
* `@payez/next-mvp/api/auth-handler` export inherits it.
|
|
15
|
+
*
|
|
16
|
+
* WHY BOTH SIGNALS LIVE IN ONE MODULE
|
|
17
|
+
* -----------------------------------
|
|
18
|
+
* `acr` (how strong the authentication was) and `amr` (which methods were
|
|
19
|
+
* used) are sent to the IDP together in a single refresh body. Defaulting one
|
|
20
|
+
* without the other produces an INCOHERENT assertion - "no second factor
|
|
21
|
+
* happened, and the method used was mfa" - so they are resolved side by side
|
|
22
|
+
* and a change to one is forced to confront the other.
|
|
23
|
+
*
|
|
24
|
+
* MEASURED CONVENTION IN THIS PACKAGE (census, 2026-09-11)
|
|
25
|
+
* -------------------------------------------------------
|
|
26
|
+
* acr absent-defaults: '0' x1, '1' x3, '2' x1 <- the '2' was auth-handler
|
|
27
|
+
* amr absent-defaults: [] x5, ['pwd','mfa'] x1 <- the list was auth-handler
|
|
28
|
+
*
|
|
29
|
+
* Both fail-open values were outliers at a single site, not the convention.
|
|
30
|
+
* These constants adopt the majority, fail-closed convention.
|
|
31
|
+
*/
|
|
32
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
33
|
+
exports.AMR_WHEN_ABSENT = exports.ACR_WHEN_ABSENT = void 0;
|
|
34
|
+
exports.resolveAcrLevel = resolveAcrLevel;
|
|
35
|
+
exports.resolveAuthMethods = resolveAuthMethods;
|
|
36
|
+
/**
|
|
37
|
+
* The assurance level used when the session and token say NOTHING about it.
|
|
38
|
+
*
|
|
39
|
+
* '1' = a single factor. It does not assert that a second factor happened.
|
|
40
|
+
* It must never be '2' (or higher): that is the value the IDP reads as
|
|
41
|
+
* "a second factor was completed", and an absent claim is not evidence of one.
|
|
42
|
+
*/
|
|
43
|
+
exports.ACR_WHEN_ABSENT = '1';
|
|
44
|
+
/**
|
|
45
|
+
* The authentication-methods list used when nothing is known.
|
|
46
|
+
*
|
|
47
|
+
* Empty asserts nothing. It must never contain 'mfa' (or any second-factor
|
|
48
|
+
* method): claiming a method that was never exercised is the same fabrication
|
|
49
|
+
* as ACR '2', and because the two corroborate each other a downstream check
|
|
50
|
+
* that cross-validates amr against acr would accept the fabricated pair.
|
|
51
|
+
*/
|
|
52
|
+
exports.AMR_WHEN_ABSENT = [];
|
|
53
|
+
/**
|
|
54
|
+
* True when an assurance signal carries no information.
|
|
55
|
+
*
|
|
56
|
+
* Deliberately NOT `!value`. The `||` idiom this replaces cannot tell
|
|
57
|
+
* "the token said nothing" from "the token said 0", and numeric 0 is a
|
|
58
|
+
* meaningful assurance level (unauthenticated) that must survive as '0'
|
|
59
|
+
* rather than being promoted to the fallback.
|
|
60
|
+
*/
|
|
61
|
+
function isAbsent(value) {
|
|
62
|
+
return value === undefined || value === null || value === '';
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Resolve an assurance level from any number of candidate sources, in
|
|
66
|
+
* priority order, falling back to the level that asserts no second factor.
|
|
67
|
+
*
|
|
68
|
+
* Accepts the candidates the refresh lane actually has: the stored
|
|
69
|
+
* `session.authenticationLevel` first, then the `acr` claim on the token.
|
|
70
|
+
*/
|
|
71
|
+
function resolveAcrLevel(...candidates) {
|
|
72
|
+
for (const candidate of candidates) {
|
|
73
|
+
if (!isAbsent(candidate)) {
|
|
74
|
+
return String(candidate);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return exports.ACR_WHEN_ABSENT;
|
|
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
|
+
function resolveAuthMethods(...candidates) {
|
|
88
|
+
for (const candidate of candidates) {
|
|
89
|
+
if (isAbsent(candidate))
|
|
90
|
+
continue;
|
|
91
|
+
if (Array.isArray(candidate)) {
|
|
92
|
+
return candidate.map(String);
|
|
93
|
+
}
|
|
94
|
+
if (typeof candidate === 'string') {
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(candidate);
|
|
97
|
+
if (Array.isArray(parsed)) {
|
|
98
|
+
return parsed.map(String);
|
|
99
|
+
}
|
|
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 [...exports.AMR_WHEN_ABSENT];
|
|
109
|
+
}
|
|
@@ -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:
|
|
400
|
-
twoFactorComplete:
|
|
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
|
-
...
|
|
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
|
-
//
|
|
420
|
-
//
|
|
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:
|
|
529
|
+
mfaVerified: mfaEvidencePresent,
|
|
482
530
|
...mfaLifetime,
|
|
531
|
+
...(mfaEvidencePresent ? {} : { nextStep: 'mfa_verify' }),
|
|
483
532
|
});
|
|
484
533
|
}
|
|
485
534
|
/**
|
|
@@ -21,6 +21,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
21
21
|
exports.createMvpMiddleware = createMvpMiddleware;
|
|
22
22
|
const server_1 = require("next/server");
|
|
23
23
|
const route_config_1 = require("../auth/route-config");
|
|
24
|
+
const safe_callback_1 = require("../auth/safe-callback");
|
|
24
25
|
const internal_api_url_1 = require("../edge/internal-api-url");
|
|
25
26
|
const app_slug_1 = require("../lib/app-slug");
|
|
26
27
|
const rbac_check_1 = require("./rbac-check");
|
|
@@ -57,16 +58,23 @@ const VERIFY_CODE_PAGE = '/account-auth/verify-code';
|
|
|
57
58
|
function isLoginPage(pathname) {
|
|
58
59
|
return pathname === LOGIN_PAGE;
|
|
59
60
|
}
|
|
60
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* Ensures callback URLs never point to auth pages (prevents redirect loops) AND
|
|
63
|
+
* never point off-origin (prevents open redirect).
|
|
64
|
+
*
|
|
65
|
+
* 375656: the previous body tested `pathname` FIRST and returned '/' early, so
|
|
66
|
+
* the two call sites that pass an attacker-controlled `searchParams` are both
|
|
67
|
+
* inside `if (onLoginPage)` branches - and `isLoginPage` is exact equality against
|
|
68
|
+
* `LOGIN_PAGE`, which starts with `/account-auth/`. The early return therefore
|
|
69
|
+
* fired before the parameter was ever read. That made this function SAFE, but
|
|
70
|
+
* safe because of which argument the caller happened to pass, not because the
|
|
71
|
+
* attacker-controlled value was ever checked: `existing` was returned raw on the
|
|
72
|
+
* strength of a `startsWith` test that `https://evil.example.com` passes.
|
|
73
|
+
* Nothing named that dependency, and reordering the arguments would have opened
|
|
74
|
+
* it silently. Both inputs are now validated by the same parser-based guard.
|
|
75
|
+
*/
|
|
61
76
|
function getSafeCallbackUrl(pathname, searchParams) {
|
|
62
|
-
|
|
63
|
-
return '/';
|
|
64
|
-
}
|
|
65
|
-
const existing = searchParams?.get('callbackUrl');
|
|
66
|
-
if (existing && !existing.startsWith('/account-auth/')) {
|
|
67
|
-
return existing;
|
|
68
|
-
}
|
|
69
|
-
return pathname;
|
|
77
|
+
return (0, safe_callback_1.resolveCallbackDestination)(pathname, searchParams);
|
|
70
78
|
}
|
|
71
79
|
/** Clear all session cookies on a response */
|
|
72
80
|
function clearSessionCookies(response, request) {
|
|
@@ -276,14 +284,38 @@ function createMvpMiddleware(options = {}) {
|
|
|
276
284
|
}
|
|
277
285
|
// --- Fetch session viability from internal API ---
|
|
278
286
|
const { sessionStatus, sessionPointer } = await checkViability(request, viabilityEndpoint, log);
|
|
279
|
-
// ---
|
|
287
|
+
// --- Stale session (cookie exists but session doesn't): OBSERVE ONLY, DO NOT DECIDE ---
|
|
288
|
+
//
|
|
289
|
+
// *** CARD 349607. THIS BLOCK USED TO `return` HERE, AND THAT RETURN WAS A BYPASS. ***
|
|
290
|
+
//
|
|
291
|
+
// makeAuthDecision() below already handles this exact condition at its
|
|
292
|
+
// `if (!sessionStatus.exists)` branch, and it handles it with TWO guard
|
|
293
|
+
// clauses that this early return did not have:
|
|
294
|
+
//
|
|
295
|
+
// if (onLoginPage) return { type: 'allow' }; // no redirect loop
|
|
296
|
+
// if (pathname.startsWith('/api/'))return { type: 'allow' }; // no HTML 307 at fetch()
|
|
297
|
+
//
|
|
298
|
+
// Returning here skipped BOTH. The `/api/` case is the reported harm: a
|
|
299
|
+
// stale-session XHR was answered with a 307 to an HTML login page, so the
|
|
300
|
+
// failure is invisible to `fetch()` - the caller sees an opaque redirect
|
|
301
|
+
// instead of an auth error, which is the same "correct logic preempted by
|
|
302
|
+
// an earlier return" shape recorded on 307290.
|
|
303
|
+
//
|
|
304
|
+
// This was NOT a missing feature one level down - the decision engine's
|
|
305
|
+
// branch is a strict superset of the behaviour this block implemented.
|
|
306
|
+
// The early return was a DUPLICATE that had drifted from the original.
|
|
307
|
+
// Adding a single `/api/` exemption here would have re-created the
|
|
308
|
+
// duplication (and left the onLoginPage divergence in place), so the
|
|
309
|
+
// duplicate is removed instead and the engine is left as the ONE decider.
|
|
310
|
+
//
|
|
311
|
+
// The warning is deliberately KEPT: it is the only operational signal that
|
|
312
|
+
// stale sessions are occurring, and it costs nothing to log before
|
|
313
|
+
// delegating. Do not re-introduce a `return` in this block.
|
|
280
314
|
if (!isPublic && sessionStatus.exists === false && sessionPointer.sessionToken) {
|
|
281
|
-
|
|
282
|
-
log.warn('Stale session detected', {
|
|
315
|
+
log.warn('Stale session detected (delegating to decision engine)', {
|
|
283
316
|
sessionToken: sessionPointer.sessionToken?.substring(0, 8) + '...',
|
|
284
317
|
pathname
|
|
285
318
|
});
|
|
286
|
-
return redirectTo(request, `${LOGIN_PAGE}?callbackUrl=${encodeURIComponent(safeCallback)}`, true);
|
|
287
319
|
}
|
|
288
320
|
// --- Run decision engine ---
|
|
289
321
|
const decision = makeAuthDecision({
|
|
@@ -21,6 +21,12 @@ const jsx_runtime_1 = require("react/jsx-runtime");
|
|
|
21
21
|
const react_1 = __importDefault(require("react"));
|
|
22
22
|
const useProfile_1 = require("../../hooks/useProfile");
|
|
23
23
|
const useTheme_1 = require("../../theme/useTheme");
|
|
24
|
+
// An all-null object is not data. The member profile DTOs are always instantiated
|
|
25
|
+
// server-side (UserService.GetUserProfileAsync), so `obj ? ... : undefined` cannot
|
|
26
|
+
// tell "has an address" from "has an empty address shell".
|
|
27
|
+
function hasAnyValue(obj) {
|
|
28
|
+
return !!obj && Object.values(obj).some(v => v !== null && v !== undefined && v !== '');
|
|
29
|
+
}
|
|
24
30
|
function EditableSection({ title, children, onEdit, isEditing, onSave, onCancel, isDarkMode }) {
|
|
25
31
|
const cardBg = isDarkMode ? 'bg-slate-800' : 'bg-white';
|
|
26
32
|
const borderColor = isDarkMode ? 'border-slate-700' : 'border-gray-200';
|
|
@@ -105,20 +111,39 @@ function EnhancedProfilePage() {
|
|
|
105
111
|
title: undefined,
|
|
106
112
|
bio: undefined,
|
|
107
113
|
},
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
114
|
+
// Both blocks used to be read off `rawProfile.contact_information`, which the
|
|
115
|
+
// MEMBER endpoint NEVER emits - that name belongs to AdminUserProfileResponse
|
|
116
|
+
// (its only 2 occurrences in PayEz-Core are in that admin DTO).
|
|
117
|
+
// /api/account/profile returns UserProfileResponse: `contact_info` plus a
|
|
118
|
+
// SEPARATE TOP-LEVEL `address`. Both guards therefore always took the false
|
|
119
|
+
// branch and both blocks rendered permanently empty for every member.
|
|
120
|
+
//
|
|
121
|
+
// NOT a rename: `address` is a SIBLING of contact_info, not nested inside it,
|
|
122
|
+
// so contact_information -> contact_info alone fixes Contact and leaves ADDRESS
|
|
123
|
+
// DEAD. Inner names are snake_case on both DTOs; the old camelCase reads were
|
|
124
|
+
// shaped for the ADMIN payload, which is where those keys really are camelCase.
|
|
125
|
+
// GUARD ON CONTENT, NOT PRESENCE. UserService.GetUserProfileAsync ALWAYS
|
|
126
|
+
// instantiates both DTOs (ContactInfo = new ProfileContactInfoDto {...},
|
|
127
|
+
// Address = new AddressDto {...}), so both arrive NON-NULL with all-null
|
|
128
|
+
// members when the member has nothing on file. A truthiness guard always
|
|
129
|
+
// passes and would render a card full of blank fields - the hazard QAPert
|
|
130
|
+
// named in 41360 for the naive rename, which applies just as much to
|
|
131
|
+
// reading the CORRECT fields.
|
|
132
|
+
contact_info: hasAnyValue(rawProfile.contact_info) ? {
|
|
133
|
+
secondary_email: rawProfile.contact_info?.secondary_email,
|
|
134
|
+
secondary_phone: rawProfile.contact_info?.secondary_phone,
|
|
135
|
+
website: rawProfile.contact_info?.website,
|
|
136
|
+
preferred_contact_method: rawProfile.contact_info?.preferred_contact_method,
|
|
112
137
|
} : undefined,
|
|
113
|
-
address: rawProfile.
|
|
114
|
-
address_line_1: rawProfile.
|
|
115
|
-
address_line_2: rawProfile.
|
|
116
|
-
city: rawProfile.
|
|
117
|
-
state_id: rawProfile.
|
|
118
|
-
state_name: rawProfile.
|
|
119
|
-
postal_code: rawProfile.
|
|
120
|
-
country_code: rawProfile.
|
|
121
|
-
country_name: rawProfile.
|
|
138
|
+
address: hasAnyValue(rawProfile.address) ? {
|
|
139
|
+
address_line_1: rawProfile.address?.address_line_1,
|
|
140
|
+
address_line_2: rawProfile.address?.address_line_2,
|
|
141
|
+
city: rawProfile.address?.city,
|
|
142
|
+
state_id: rawProfile.address?.state_id,
|
|
143
|
+
state_name: rawProfile.address?.state_name,
|
|
144
|
+
postal_code: rawProfile.address?.postal_code,
|
|
145
|
+
country_code: rawProfile.address?.country_code,
|
|
146
|
+
country_name: rawProfile.address?.country_name,
|
|
122
147
|
} : undefined,
|
|
123
148
|
two_factor_enabled: rawProfile.two_factor_enabled,
|
|
124
149
|
roles: rawProfile.roles,
|
|
@@ -37,6 +37,7 @@ const navigation_1 = require("next/navigation");
|
|
|
37
37
|
const better_auth_client_1 = require("../../client/better-auth-client");
|
|
38
38
|
const react_2 = require("react");
|
|
39
39
|
const useTheme_1 = require("../../theme/useTheme");
|
|
40
|
+
const safe_callback_1 = require("../../auth/safe-callback");
|
|
40
41
|
/**
|
|
41
42
|
* Session storage key to track that user intentionally navigated to verify-code.
|
|
42
43
|
* Prevents auto-redirect back to dashboard when session refreshes in background.
|
|
@@ -45,7 +46,31 @@ const VERIFY_IN_PROGRESS_KEY = 'idealvibe_2fa_verify_in_progress';
|
|
|
45
46
|
function VerifyCodeForm({ enrollHref }) {
|
|
46
47
|
const router = (0, navigation_1.useRouter)();
|
|
47
48
|
const searchParams = (0, navigation_1.useSearchParams)();
|
|
48
|
-
|
|
49
|
+
/**
|
|
50
|
+
* 375936 - ORIGIN-GUARDED AT THE READ, NOT AT THE SINK.
|
|
51
|
+
*
|
|
52
|
+
* This value ends up in `window.location.href` after a SUCCESSFUL 2FA
|
|
53
|
+
* ceremony (see the setTimeout further down). That is a full navigation, so
|
|
54
|
+
* an absolute URL here is a cross-origin redirect - the user completes a
|
|
55
|
+
* genuine login and is then dropped on an attacker-controlled page at the
|
|
56
|
+
* exact moment they are most primed to trust what they see.
|
|
57
|
+
*
|
|
58
|
+
* This used to be a raw `searchParams?.get('callbackUrl') || '/dashboard'`.
|
|
59
|
+
* The middleware does NOT protect this page: create-middleware's
|
|
60
|
+
* `needsTwoFactorRedirect` returns false for VERIFY_CODE_PAGE, so a direct
|
|
61
|
+
* request arrives here with its query string untouched.
|
|
62
|
+
*
|
|
63
|
+
* Do NOT "simplify" this back to a `startsWith('/account-auth/')` test. The
|
|
64
|
+
* three such tests below are LOOP prevention, not ORIGIN prevention -
|
|
65
|
+
* `https://evil.example` passes every one of them. safeCallbackPath asks the
|
|
66
|
+
* URL parser instead, so protocol-relative (`//host`), backslash (`/\host`)
|
|
67
|
+
* and TAB/LF/CR spellings all fail one origin comparison rather than needing
|
|
68
|
+
* their own blacklist entry.
|
|
69
|
+
*
|
|
70
|
+
* Guarding at the READ rather than at the sink means every later use of
|
|
71
|
+
* `callbackUrl` on this page inherits the guarantee.
|
|
72
|
+
*/
|
|
73
|
+
const callbackUrl = (0, safe_callback_1.safeCallbackPath)(searchParams?.get('callbackUrl'), '/dashboard');
|
|
49
74
|
const { data: sessionData, isPending } = better_auth_client_1.authClient.useSession();
|
|
50
75
|
const session = sessionData;
|
|
51
76
|
const status = isPending ? 'loading' : session ? 'authenticated' : 'unauthenticated';
|
|
@@ -35,12 +35,12 @@ export declare function GET(req: NextRequest): Promise<NextResponse<{
|
|
|
35
35
|
authenticated: boolean;
|
|
36
36
|
sessionToken: any;
|
|
37
37
|
tenantRequiresTwoFactor: boolean;
|
|
38
|
-
userHasCompletedTenantTwoFactorRequirements:
|
|
38
|
+
userHasCompletedTenantTwoFactorRequirements: boolean;
|
|
39
39
|
userStillNeedsTwoFactor: boolean;
|
|
40
40
|
tenantAllowed2FAMethods: string[] | null;
|
|
41
41
|
nextStep: "enroll_mfa" | "mfa_verify" | null | undefined;
|
|
42
42
|
requires2FA: boolean;
|
|
43
|
-
twoFactorComplete:
|
|
43
|
+
twoFactorComplete: boolean;
|
|
44
44
|
accessTokenExpired: boolean;
|
|
45
45
|
expiresAt: string;
|
|
46
46
|
roles: string[];
|
|
@@ -280,7 +280,7 @@ async function GET(req) {
|
|
|
280
280
|
const mfaExpiresAt = session?.mfaExpiresAt || 0;
|
|
281
281
|
const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
|
|
282
282
|
// Check mfaVerified (normalized name) with fallback to twoFactorComplete for compatibility
|
|
283
|
-
const mfaVerifiedInSession = session?.mfaVerified ??
|
|
283
|
+
const mfaVerifiedInSession = session?.mfaVerified ?? false;
|
|
284
284
|
// User has completed 2FA requirements if: they verified AND it hasn't expired
|
|
285
285
|
const userHasCompletedTenantTwoFactorRequirements = mfaVerifiedInSession && !mfaExpired;
|
|
286
286
|
// userStillNeedsTwoFactor = inverse of completed (matches session callback logic)
|
|
@@ -322,7 +322,7 @@ async function GET(req) {
|
|
|
322
322
|
const mfaExpiresAt = session?.mfaExpiresAt || 0;
|
|
323
323
|
const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
|
|
324
324
|
// Check mfaVerified (normalized name) with fallback to twoFactorComplete for compatibility
|
|
325
|
-
const mfaVerifiedInSession = session?.mfaVerified ??
|
|
325
|
+
const mfaVerifiedInSession = session?.mfaVerified ?? false;
|
|
326
326
|
// DEBUG: Log what we're reading from the session
|
|
327
327
|
console.log('[VIABILITY] Session 2FA state:', {
|
|
328
328
|
sessionToken: sessionToken?.substring(0, 8) + '...',
|
|
@@ -87,7 +87,32 @@ async function authGuard(options) {
|
|
|
87
87
|
(0, navigation_1.redirect)(serviceUnavailableUrl);
|
|
88
88
|
}
|
|
89
89
|
if (requires2FA) {
|
|
90
|
-
|
|
90
|
+
// 369858: the legacy `?? (sessionData as any).twoFactorComplete` carry is REMOVED.
|
|
91
|
+
// This is THE access gate - the only one of the 8 carry sites that grants access
|
|
92
|
+
// rather than reporting status - so it reads exactly one source of truth.
|
|
93
|
+
//
|
|
94
|
+
// Removing it is fail-closed. No current writer emits the legacy field alone:
|
|
95
|
+
// session-store.ts:444-445 writes both as a pair from a server-derived stepped-up
|
|
96
|
+
// token, auth/refresh.ts:376 writes it as a MIRROR of mfaVerified, and
|
|
97
|
+
// routes/auth/session.ts:149-157 refuses both on PRESENCE, so a caller cannot
|
|
98
|
+
// assert either. The carry therefore only ever served sessions minted before
|
|
99
|
+
// `mfaVerified` existed, and for those the correct behaviour is re-verify, not
|
|
100
|
+
// grandfather. Cost is one forced re-challenge for a stale legacy session.
|
|
101
|
+
//
|
|
102
|
+
// Severity note, so nobody re-inflates this later: the operator was `??`
|
|
103
|
+
// (nullish), NOT `||`. `false ?? x` is `false`, so an explicitly DENIED session
|
|
104
|
+
// could never be upgraded by a stale flag. This was legacy grandfathering, not a
|
|
105
|
+
// live 2FA bypass. Had it been `||`, it would have been the bypass.
|
|
106
|
+
const mfaVerified = sessionData.mfaVerified ?? false;
|
|
107
|
+
// 339563 / 369858 §3: THIS IS STILL A FAIL-OPEN AND IS DELIBERATELY LEFT VISIBLE.
|
|
108
|
+
// An absent mfaExpiresAt collapses to 0, so `mfaExpired` is false and the MFA
|
|
109
|
+
// verdict NEVER EXPIRES. That needs no legacy-session hypothesis: it fires on a
|
|
110
|
+
// current `mfaVerified: true` session whose token carried no `mfa_expires` claim -
|
|
111
|
+
// the passwordless/primary-credential lane, named on the write path in the
|
|
112
|
+
// source's own voice at session-store.ts:464-467 ("it IS the case the consumers
|
|
113
|
+
// currently fail OPEN on"). Removing the carry above does NOT touch this.
|
|
114
|
+
// Tracked separately by BAPert; not fixed here because failing closed on absent
|
|
115
|
+
// expiry would deny the passwordless lane outright and that is a product call.
|
|
91
116
|
const mfaExpiresAt = sessionData.mfaExpiresAt || 0;
|
|
92
117
|
const mfaExpired = mfaExpiresAt > 0 && mfaExpiresAt < Date.now();
|
|
93
118
|
if (!mfaVerified || mfaExpired) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@payez/next-mvp",
|
|
3
|
-
"version": "4.4.
|
|
3
|
+
"version": "4.4.3",
|
|
4
4
|
"sideEffects": false,
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"dev:local": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/dev-local.ps1",
|
|
25
25
|
"dev:broker": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/dev-broker.ps1",
|
|
26
26
|
"check:publish-health": "node scripts/check-publish-health.mjs",
|
|
27
|
+
"census:reach": "node scripts/module-reach.mjs",
|
|
27
28
|
"build:incremental": "npx tsc --project tsconfig.json"
|
|
28
29
|
},
|
|
29
30
|
"exports": {
|