@payez/next-mvp 4.4.2 → 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/auth/safe-callback.d.ts +30 -1
- package/dist/auth/safe-callback.js +80 -9
- package/dist/lib/acr.d.ts +64 -0
- package/dist/lib/acr.js +109 -0
- package/package.json +2 -1
- package/scripts/module-reach.mjs +280 -0
- package/src/api/auth-handler.ts +19 -4
- package/src/auth/safe-callback.ts +80 -8
- package/src/lib/acr.ts +109 -0
package/dist/api/auth-handler.js
CHANGED
|
@@ -17,6 +17,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
17
17
|
exports.createAuthHandler = createAuthHandler;
|
|
18
18
|
const server_1 = require("next/server");
|
|
19
19
|
const auth_1 = require("../server/auth");
|
|
20
|
+
const acr_1 = require("../lib/acr");
|
|
20
21
|
const nanoid_1 = require("nanoid");
|
|
21
22
|
const session_store_1 = require("../lib/session-store");
|
|
22
23
|
/**
|
|
@@ -158,10 +159,18 @@ function createAuthHandler(options = {}) {
|
|
|
158
159
|
console.error('[AUTH_HANDLER] No refresh token available');
|
|
159
160
|
return { success: false, error: 'No refresh token' };
|
|
160
161
|
}
|
|
161
|
-
// Extract authentication methods from session
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
162
|
+
// Extract authentication methods from session.
|
|
163
|
+
//
|
|
164
|
+
// 375639: these two lines used to read
|
|
165
|
+
// authMethods = ... : ['pwd', 'mfa']
|
|
166
|
+
// authLevel = String(... || ... || '2')
|
|
167
|
+
// so a session that knew NOTHING about how the user authenticated told
|
|
168
|
+
// the IDP that a second factor had happened AND named 'mfa' as the
|
|
169
|
+
// method. The two fabrications corroborated each other, so a downstream
|
|
170
|
+
// check cross-validating amr against acr would have accepted the pair.
|
|
171
|
+
// Absent is not satisfied; both now resolve fail-closed, in one place.
|
|
172
|
+
const authMethods = (0, acr_1.resolveAuthMethods)(currentSession.authMethods, currentSession.token?.amr);
|
|
173
|
+
const authLevel = (0, acr_1.resolveAcrLevel)(currentSession.authenticationLevel, currentSession.token?.acr);
|
|
165
174
|
const twoFactorMethod = currentSession.twoFactorMethod || 'authenticator';
|
|
166
175
|
// Build refresh request body
|
|
167
176
|
const refreshRequestBody = {
|
|
@@ -21,6 +21,27 @@
|
|
|
21
21
|
* v1 `startsWith('/') && !startsWith('//')` -> bypassed by /BACKSLASH host
|
|
22
22
|
* v2 + `!startsWith('/' + BACKSLASH)` -> bypassed by /TAB/host
|
|
23
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.
|
|
24
45
|
*
|
|
25
46
|
* Both bypasses work the same way: the browser NORMALISES the string AFTER the
|
|
26
47
|
* guard has finished looking at it. The WHATWG URL parser converts backslashes
|
|
@@ -45,7 +66,15 @@ export declare const DEFAULT_CALLBACK = "/";
|
|
|
45
66
|
* the auth surface. Never throws.
|
|
46
67
|
*/
|
|
47
68
|
export declare function safeCallbackPath(raw: unknown, fallback?: string): string;
|
|
48
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* True when `raw` is a callback target we are willing to honour.
|
|
71
|
+
*
|
|
72
|
+
* DEFINED IN TERMS OF `safeCallbackPath` ON PURPOSE - it used to carry its own
|
|
73
|
+
* copy of the predicate, and that copy was missing the second resolution above
|
|
74
|
+
* while the two were maintained separately. This file's whole history is
|
|
75
|
+
* duplicated predicates drifting apart (see the header). One definition, one
|
|
76
|
+
* place to fix.
|
|
77
|
+
*/
|
|
49
78
|
export declare function isSafeCallbackPath(raw: unknown): raw is string;
|
|
50
79
|
/**
|
|
51
80
|
* Pick the post-login destination: an explicit `?callbackUrl=` when it is one we
|
|
@@ -22,6 +22,27 @@
|
|
|
22
22
|
* v1 `startsWith('/') && !startsWith('//')` -> bypassed by /BACKSLASH host
|
|
23
23
|
* v2 + `!startsWith('/' + BACKSLASH)` -> bypassed by /TAB/host
|
|
24
24
|
* ... and /LF/host, /CR/host
|
|
25
|
+
* v3 resolve once + compare ORIGIN -> bypassed by /..//host
|
|
26
|
+
* and /.//host, /%2e%2e//host
|
|
27
|
+
* v4 + require the RESULT to be a FIXED POINT <- current
|
|
28
|
+
*
|
|
29
|
+
* *** DRIFT, 2026-09-11 (325543). THIS FILE WAS v3 WHILE THE PORTAL'S COPY WAS
|
|
30
|
+
* *** v4, AND THE PACKAGE COPY IS THE ONE THAT SHIPPED. The portal fixed its own
|
|
31
|
+
* *** src/lib/safe-callback.ts (e1f08fea) and deployed; the served bundle STILL
|
|
32
|
+
* *** escaped 5/5 in a real browser, because `src/contexts/ThemeContext.tsx`
|
|
33
|
+
* *** imports the BARE BARREL `@payez/next-mvp`, which pulls dist/index.js ->
|
|
34
|
+
* *** auth/auth-decision -> THIS FILE into the client graph, and because the
|
|
35
|
+
* *** portal's `src/middleware.ts` imports `@payez/next-mvp/edge`, which requires
|
|
36
|
+
* *** THIS FILE server-side on every request. It was read as a stale build
|
|
37
|
+
* *** artifact of the portal's file for most of a day. It was not. It was a
|
|
38
|
+
* *** SECOND DEFINITION, and no rebuild of the portal could have removed it.
|
|
39
|
+
* *** The header below says "ONE definition ... for the whole package" and that
|
|
40
|
+
* *** was true INSIDE this package boundary the entire time. The defect lived in
|
|
41
|
+
* *** the gap BETWEEN two boundaries that each enforced one-definition correctly.
|
|
42
|
+
* *** A guard that exists in N copies is only as strong as its weakest copy, and
|
|
43
|
+
* *** a per-repo grep gate cannot see N. IF YOU CHANGE THE PREDICATE HERE, CHANGE
|
|
44
|
+
* *** IT IN THE PORTAL'S src/lib/safe-callback.ts IN THE SAME HOUR, OR DELETE ONE
|
|
45
|
+
* *** OF THEM.
|
|
25
46
|
*
|
|
26
47
|
* Both bypasses work the same way: the browser NORMALISES the string AFTER the
|
|
27
48
|
* guard has finished looking at it. The WHATWG URL parser converts backslashes
|
|
@@ -77,19 +98,69 @@ function safeCallbackPath(raw, fallback = exports.DEFAULT_CALLBACK) {
|
|
|
77
98
|
if (url.pathname.startsWith(AUTH_PREFIX))
|
|
78
99
|
return fallback;
|
|
79
100
|
// The parser's normalised output - control characters already stripped.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
101
|
+
const candidate = url.pathname + url.search + url.hash;
|
|
102
|
+
// *** SECOND RESOLUTION. DO NOT DELETE THIS AS REDUNDANT - IT IS NOT. ***
|
|
103
|
+
//
|
|
104
|
+
// v3 (the origin comparison above) was bypassed too, and differently from v1/v2.
|
|
105
|
+
// The origin check constrains where `raw` RESOLVED TO. It does not constrain how
|
|
106
|
+
// the RESULT IS SPELLED - and the result is resolved a SECOND time, by whoever
|
|
107
|
+
// navigates to it. A returned path that begins with `//` is a PROTOCOL-RELATIVE
|
|
108
|
+
// URL at that second resolution, so it lands off-origin having passed every
|
|
109
|
+
// check here:
|
|
110
|
+
//
|
|
111
|
+
// '/..//evil.example.com' -> pathname '//evil.example.com' -> evil.example.com
|
|
112
|
+
// '/.//evil.example.com' -> pathname '//evil.example.com' -> evil.example.com
|
|
113
|
+
// '/%2e%2e//evil.example.com' -> pathname '//evil.example.com' -> evil.example.com
|
|
114
|
+
//
|
|
115
|
+
// The parser resolves `..` against the synthetic base and, at the root, leaves
|
|
116
|
+
// behind the EMPTY SEGMENT rather than collapsing it. Origin stayed BASE the
|
|
117
|
+
// whole time, so nothing upstream fired.
|
|
118
|
+
//
|
|
119
|
+
// MEASURED, not reasoned: in a real headless Chromium on the live origin
|
|
120
|
+
// https://portal.keelauth.com, 5 spellings resolved to https://evil.example.com
|
|
121
|
+
// (QAPert-NightHawk, 2026-09-11, card 325543), and on 2026-09-11 the SERVED
|
|
122
|
+
// BUNDLE was measured escaping 5/5 through THIS PACKAGE'S copy while the
|
|
123
|
+
// portal's own src/lib/safe-callback.ts was already fixed - the two copies had
|
|
124
|
+
// drifted. See the DRIFT note in this file's header.
|
|
125
|
+
//
|
|
126
|
+
// THE FIX FOLLOWS THIS FILE'S OWN DOCTRINE: do not pattern-match the spellings
|
|
127
|
+
// (that is the startsWith() trap that failed four times, and this would be the
|
|
128
|
+
// fifth). Ask the parser AGAIN about the exact value we are about to hand out,
|
|
129
|
+
// and require it to be a FIXED POINT. Anything whose meaning changes when it is
|
|
130
|
+
// resolved a second time is by definition not a stable same-origin path, however
|
|
131
|
+
// it is spelled - including spellings nobody has found yet.
|
|
132
|
+
let echo;
|
|
86
133
|
try {
|
|
87
|
-
|
|
88
|
-
return url.origin === BASE && !url.pathname.startsWith(AUTH_PREFIX);
|
|
134
|
+
echo = new URL(candidate, BASE);
|
|
89
135
|
}
|
|
90
136
|
catch {
|
|
91
|
-
return
|
|
137
|
+
return fallback;
|
|
92
138
|
}
|
|
139
|
+
if (echo.origin !== BASE)
|
|
140
|
+
return fallback;
|
|
141
|
+
if (echo.pathname.startsWith(AUTH_PREFIX))
|
|
142
|
+
return fallback;
|
|
143
|
+
if (echo.pathname + echo.search + echo.hash !== candidate)
|
|
144
|
+
return fallback;
|
|
145
|
+
return candidate;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Sentinel that `safeCallbackPath` can never legitimately return: it is not a
|
|
149
|
+
* fixed point of the URL parser (the parser would percent-encode the space), so
|
|
150
|
+
* it cannot collide with a genuine accepted value.
|
|
151
|
+
*/
|
|
152
|
+
const REJECTED = '\u0000rejected';
|
|
153
|
+
/**
|
|
154
|
+
* True when `raw` is a callback target we are willing to honour.
|
|
155
|
+
*
|
|
156
|
+
* DEFINED IN TERMS OF `safeCallbackPath` ON PURPOSE - it used to carry its own
|
|
157
|
+
* copy of the predicate, and that copy was missing the second resolution above
|
|
158
|
+
* while the two were maintained separately. This file's whole history is
|
|
159
|
+
* duplicated predicates drifting apart (see the header). One definition, one
|
|
160
|
+
* place to fix.
|
|
161
|
+
*/
|
|
162
|
+
function isSafeCallbackPath(raw) {
|
|
163
|
+
return typeof raw === 'string' && raw.length > 0 && safeCallbackPath(raw, REJECTED) !== REJECTED;
|
|
93
164
|
}
|
|
94
165
|
/**
|
|
95
166
|
* Pick the post-login destination: an explicit `?callbackUrl=` when it is one we
|
|
@@ -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
|
+
}
|
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": {
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* module-reach.mjs - module-graph reachability census over the BUILT dist/.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS
|
|
6
|
+
* ---------------
|
|
7
|
+
* Cards 372358 / 373665 rest on numbers like "40 consumer-mountable entry points can
|
|
8
|
+
* reach server/auth". Those numbers were originally produced by a script that was run
|
|
9
|
+
* inline and never committed, so nobody could reproduce them. A number nobody can
|
|
10
|
+
* re-derive is a rumour. This file is that script, made durable and self-testing.
|
|
11
|
+
*
|
|
12
|
+
* WHAT IT MEASURES
|
|
13
|
+
* ----------------
|
|
14
|
+
* Over dist/**\/*.js (the artifact a consumer actually installs, NOT src/):
|
|
15
|
+
* - the module graph (nodes + edges)
|
|
16
|
+
* - which modules transitively REACH a target module (reverse reachability)
|
|
17
|
+
* - which of those are MOUNTABLE - i.e. named in package.json "exports", so a
|
|
18
|
+
* consumer can import them directly and thereby pull the target in
|
|
19
|
+
*
|
|
20
|
+
* HOW EDGES ARE FOUND - and why this shape was chosen
|
|
21
|
+
* ---------------------------------------------------
|
|
22
|
+
* We do NOT pattern-match `require(...)`. Three real defects came from doing that:
|
|
23
|
+
* 1. QUOTE STYLE - a pattern written with " misses ' (tsc emits either).
|
|
24
|
+
* 2. SIBLING PATHS - a pattern anchored on `./lib/` misses a plain `./auth`.
|
|
25
|
+
* 3. DYNAMIC IMPORT - `await import('x')`, and tsc's downlevel
|
|
26
|
+
* `Promise.resolve().then(() => __importStar(require('x')))`, are not `require(` calls
|
|
27
|
+
* in the shape a naive pattern expects.
|
|
28
|
+
* Instead: extract EVERY string literal (', ", and substitution-free `), and keep the ones
|
|
29
|
+
* that RESOLVE to a real file on disk. Keyword-agnostic and quote-agnostic by construction,
|
|
30
|
+
* so all three modes above are caught without enumerating them.
|
|
31
|
+
*
|
|
32
|
+
* THE HONESTY SIGNAL
|
|
33
|
+
* ------------------
|
|
34
|
+
* A relative specifier that does NOT resolve is counted as UNRESOLVED. Unresolved > 0 means
|
|
35
|
+
* the graph is incomplete and every count derived from it is a lower bound. The tool says so
|
|
36
|
+
* loudly rather than quietly under-reporting. Counts are reported over CALL SITES where
|
|
37
|
+
* relevant, never deduplicated to "files that mention it" - that conflation is its own defect.
|
|
38
|
+
*
|
|
39
|
+
* BOUND: this is a STATIC graph over emitted JS. It proves a module is REACHABLE, not that it
|
|
40
|
+
* is EXECUTED at runtime, and not that reaching it has any security consequence. Reachability
|
|
41
|
+
* is necessary for consequence, never sufficient. Do not quote this tool as proof of impact.
|
|
42
|
+
*
|
|
43
|
+
* USAGE
|
|
44
|
+
* node scripts/module-reach.mjs --to server/auth
|
|
45
|
+
* node scripts/module-reach.mjs --to server/auth --json
|
|
46
|
+
* node scripts/module-reach.mjs --graph-only
|
|
47
|
+
* node scripts/module-reach.mjs --to server/auth --pkg <dir> (default: package root)
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import fs from 'node:fs';
|
|
51
|
+
import path from 'node:path';
|
|
52
|
+
|
|
53
|
+
const CANDIDATE_EXTS = ['', '.js', '.mjs', '.cjs', '.json'];
|
|
54
|
+
const INDEX_FILES = ['index.js', 'index.mjs', 'index.cjs'];
|
|
55
|
+
|
|
56
|
+
/** Extract every string literal. Handles ' " and substitution-free `. Escape-aware. */
|
|
57
|
+
export function extractStringLiterals(src) {
|
|
58
|
+
const out = [];
|
|
59
|
+
const n = src.length;
|
|
60
|
+
let i = 0;
|
|
61
|
+
while (i < n) {
|
|
62
|
+
const ch = src[i];
|
|
63
|
+
// skip line comments
|
|
64
|
+
if (ch === '/' && src[i + 1] === '/') {
|
|
65
|
+
while (i < n && src[i] !== '\n') i++;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
// skip block comments
|
|
69
|
+
if (ch === '/' && src[i + 1] === '*') {
|
|
70
|
+
i += 2;
|
|
71
|
+
while (i < n && !(src[i] === '*' && src[i + 1] === '/')) i++;
|
|
72
|
+
i += 2;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
76
|
+
const quote = ch;
|
|
77
|
+
let j = i + 1;
|
|
78
|
+
let buf = '';
|
|
79
|
+
let sawSubstitution = false;
|
|
80
|
+
while (j < n) {
|
|
81
|
+
const c = src[j];
|
|
82
|
+
if (c === '\\') { buf += src[j + 1] ?? ''; j += 2; continue; }
|
|
83
|
+
if (c === quote) break;
|
|
84
|
+
if (quote === '`' && c === '$' && src[j + 1] === '{') sawSubstitution = true;
|
|
85
|
+
if (quote !== '`' && (c === '\n' || c === '\r')) break; // unterminated
|
|
86
|
+
buf += c;
|
|
87
|
+
j++;
|
|
88
|
+
}
|
|
89
|
+
// A template literal WITH ${...} is not a static specifier - we cannot resolve it,
|
|
90
|
+
// and pretending otherwise would invent an edge. Dropped, and counted by the caller.
|
|
91
|
+
if (!sawSubstitution) out.push(buf);
|
|
92
|
+
i = j + 1;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
i++;
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Resolve a relative specifier from `fromFile` to a real file, or null. */
|
|
101
|
+
export function resolveSpecifier(fromFile, spec, exists) {
|
|
102
|
+
const base = path.resolve(path.dirname(fromFile), spec);
|
|
103
|
+
for (const ext of CANDIDATE_EXTS) {
|
|
104
|
+
const cand = base + ext;
|
|
105
|
+
if (exists(cand)) return cand;
|
|
106
|
+
}
|
|
107
|
+
for (const idx of INDEX_FILES) {
|
|
108
|
+
const cand = path.join(base, idx);
|
|
109
|
+
if (exists(cand)) return cand;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const isRelative = (s) => s === '.' || s === '..' || s.startsWith('./') || s.startsWith('../');
|
|
115
|
+
|
|
116
|
+
function walkJs(dir, acc = []) {
|
|
117
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
118
|
+
const p = path.join(dir, e.name);
|
|
119
|
+
if (e.isDirectory()) walkJs(p, acc);
|
|
120
|
+
else if (e.isFile() && /\.(js|mjs|cjs)$/.test(e.name)) acc.push(p);
|
|
121
|
+
}
|
|
122
|
+
return acc;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function buildGraph(distDir, opts = {}) {
|
|
126
|
+
const readFile = opts.readFile || ((f) => fs.readFileSync(f, 'utf8'));
|
|
127
|
+
const exists = opts.exists || ((f) => { try { return fs.statSync(f).isFile(); } catch { return false; } });
|
|
128
|
+
const files = (opts.files || walkJs(distDir)).map((f) => path.resolve(f));
|
|
129
|
+
|
|
130
|
+
const rel = (f) => path.relative(distDir, f).split(path.sep).join('/');
|
|
131
|
+
const forward = new Map(); // module -> Set(module)
|
|
132
|
+
const reverse = new Map(); // module -> Set(module)
|
|
133
|
+
const unresolved = []; // {from, spec} - CALL SITES, not deduped
|
|
134
|
+
let edgeSites = 0;
|
|
135
|
+
let externalSites = 0;
|
|
136
|
+
let dotValueSites = 0;
|
|
137
|
+
|
|
138
|
+
for (const f of files) { forward.set(rel(f), new Set()); reverse.set(rel(f), new Set()); }
|
|
139
|
+
|
|
140
|
+
for (const f of files) {
|
|
141
|
+
const from = rel(f);
|
|
142
|
+
for (const spec of extractStringLiterals(readFile(f))) {
|
|
143
|
+
if (!isRelative(spec)) {
|
|
144
|
+
// bare specifier: 'next/server', 'jose'. External to this package, not a defect.
|
|
145
|
+
if (/^[@a-zA-Z]/.test(spec) && !spec.includes(' ') && spec.length < 100) externalSites++;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const target = resolveSpecifier(f, spec, exists);
|
|
149
|
+
if (!target) {
|
|
150
|
+
// A LONE "." or ".." that does not resolve is the CHARACTER, not a specifier:
|
|
151
|
+
// verified in this dist - every occurrence is `token.split('.')`. `require('.')`
|
|
152
|
+
// is still legal, so we only discount it AFTER resolution has actually failed.
|
|
153
|
+
if (spec === '.' || spec === '..') { dotValueSites++; continue; }
|
|
154
|
+
unresolved.push({ from, spec });
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const to = rel(target);
|
|
158
|
+
if (!forward.has(to)) continue; // resolved outside the scanned set
|
|
159
|
+
edgeSites++;
|
|
160
|
+
forward.get(from).add(to);
|
|
161
|
+
reverse.get(to).add(from);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let edgeCount = 0;
|
|
166
|
+
for (const s of forward.values()) edgeCount += s.size;
|
|
167
|
+
|
|
168
|
+
return { distDir, modules: [...forward.keys()].sort(), forward, reverse, unresolved, edgeCount, edgeSites, externalSites, dotValueSites };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Reverse-transitive closure: every module that can reach `target`. */
|
|
172
|
+
export function reachers(graph, target) {
|
|
173
|
+
if (!graph.reverse.has(target)) return null;
|
|
174
|
+
const depth = new Map([[target, 0]]);
|
|
175
|
+
const queue = [target];
|
|
176
|
+
while (queue.length) {
|
|
177
|
+
const cur = queue.shift();
|
|
178
|
+
for (const prev of graph.reverse.get(cur) || []) {
|
|
179
|
+
if (!depth.has(prev)) { depth.set(prev, depth.get(cur) + 1); queue.push(prev); }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
depth.delete(target);
|
|
183
|
+
return depth;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Every dist/*.js named as a target in package.json "exports". */
|
|
187
|
+
export function exportTargets(pkgJson) {
|
|
188
|
+
const out = new Map(); // distRelPath -> Set(subpath)
|
|
189
|
+
const visit = (subpath, node) => {
|
|
190
|
+
if (typeof node === 'string') {
|
|
191
|
+
if (!node.endsWith('.js')) return;
|
|
192
|
+
// normalise './dist/lib/x.js' and './lib/x.js' (both appear) to 'lib/x.js'
|
|
193
|
+
let p = node.replace(/^\.\//, '');
|
|
194
|
+
p = p.replace(/^dist\//, '');
|
|
195
|
+
if (!out.has(p)) out.set(p, new Set());
|
|
196
|
+
out.get(p).add(subpath);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (node && typeof node === 'object') for (const v of Object.values(node)) visit(subpath, v);
|
|
200
|
+
};
|
|
201
|
+
for (const [subpath, node] of Object.entries(pkgJson.exports || {})) visit(subpath, node);
|
|
202
|
+
if (typeof pkgJson.main === 'string') visit('. (main)', './' + pkgJson.main);
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function main(argv) {
|
|
207
|
+
const arg = (k) => { const i = argv.indexOf(k); return i >= 0 ? argv[i + 1] : undefined; };
|
|
208
|
+
const pkgDir = path.resolve(arg('--pkg') || path.join(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')), '..'));
|
|
209
|
+
const distDir = path.join(pkgDir, 'dist');
|
|
210
|
+
const json = argv.includes('--json');
|
|
211
|
+
const to = arg('--to');
|
|
212
|
+
|
|
213
|
+
if (!fs.existsSync(distDir)) {
|
|
214
|
+
console.error(`[module-reach] FATAL: no dist at ${distDir}. Run \`npm run build\` first.`);
|
|
215
|
+
console.error('[module-reach] Refusing to report a census over a tree that does not exist.');
|
|
216
|
+
process.exit(2);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const pkgJson = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8'));
|
|
220
|
+
const graph = buildGraph(distDir);
|
|
221
|
+
const exports_ = exportTargets(pkgJson);
|
|
222
|
+
|
|
223
|
+
const report = {
|
|
224
|
+
package: pkgJson.name, version: pkgJson.version, distDir,
|
|
225
|
+
modules: graph.modules.length, edges: graph.edgeCount, edgeSites: graph.edgeSites,
|
|
226
|
+
externalSites: graph.externalSites,
|
|
227
|
+
unresolvedSites: graph.unresolved.length,
|
|
228
|
+
exportSubpaths: Object.keys(pkgJson.exports || {}).length,
|
|
229
|
+
exportTargetFiles: exports_.size,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
if (to) {
|
|
233
|
+
const candidates = [to, `${to}.js`, `${to}/index.js`].map((t) => t.replace(/^\.\//, ''));
|
|
234
|
+
const target = candidates.find((c) => graph.reverse.has(c));
|
|
235
|
+
if (!target) {
|
|
236
|
+
console.error(`[module-reach] FATAL: target not in dist graph: ${to}`);
|
|
237
|
+
console.error(`[module-reach] tried: ${candidates.join(', ')}`);
|
|
238
|
+
process.exit(2);
|
|
239
|
+
}
|
|
240
|
+
const depth = reachers(graph, target);
|
|
241
|
+
const all = [...depth.keys()].sort();
|
|
242
|
+
const direct = all.filter((m) => depth.get(m) === 1);
|
|
243
|
+
const mountable = all.filter((m) => exports_.has(m));
|
|
244
|
+
report.target = target;
|
|
245
|
+
report.directRequirers = direct.length;
|
|
246
|
+
report.reaching = all.length;
|
|
247
|
+
report.mountable = mountable.length;
|
|
248
|
+
report.targetIsItselfMountable = exports_.has(target);
|
|
249
|
+
report.directRequirerList = direct;
|
|
250
|
+
report.mountableList = mountable.map((m) => ({ module: m, subpaths: [...exports_.get(m)] }));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (json) { console.log(JSON.stringify(report, null, 2)); return; }
|
|
254
|
+
|
|
255
|
+
console.log(`[module-reach] ${report.package}@${report.version}`);
|
|
256
|
+
console.log(` dist modules ......... ${report.modules}`);
|
|
257
|
+
console.log(` edges (unique) ....... ${report.edges}`);
|
|
258
|
+
console.log(` edge call sites ...... ${report.edgeSites}`);
|
|
259
|
+
console.log(` external specifiers .. ${report.externalSites} (bare, outside this package)`);
|
|
260
|
+
console.log(` export subpaths ...... ${report.exportSubpaths} -> ${report.exportTargetFiles} distinct dist files`);
|
|
261
|
+
if (report.unresolvedSites === 0) {
|
|
262
|
+
console.log(` unresolved ........... 0 <- graph is complete; counts below are exact`);
|
|
263
|
+
} else {
|
|
264
|
+
console.log(` unresolved ........... ${report.unresolvedSites} *** GRAPH INCOMPLETE - ALL COUNTS BELOW ARE LOWER BOUNDS ***`);
|
|
265
|
+
for (const u of graph.unresolved.slice(0, 20)) console.log(` ${u.from} -> ${u.spec}`);
|
|
266
|
+
}
|
|
267
|
+
if (report.target) {
|
|
268
|
+
console.log('');
|
|
269
|
+
console.log(` TARGET: ${report.target}${report.targetIsItselfMountable ? ' (itself a public export)' : ''}`);
|
|
270
|
+
console.log(` direct requirers ..... ${report.directRequirers}`);
|
|
271
|
+
console.log(` transitively reaching ${report.reaching}`);
|
|
272
|
+
console.log(` ...of which MOUNTABLE ${report.mountable} <- consumer-reachable entry points`);
|
|
273
|
+
console.log('');
|
|
274
|
+
console.log(' BOUND: static reachability over emitted JS. Proves reachable, NOT executed,');
|
|
275
|
+
console.log(' and NOT that reaching it has any consequence. Do not quote as proof of impact.');
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const invokedDirectly = process.argv[1] && import.meta.url.endsWith(path.basename(process.argv[1]));
|
|
280
|
+
if (invokedDirectly) main(process.argv.slice(2));
|
package/src/api/auth-handler.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { NextRequest, NextResponse } from 'next/server';
|
|
17
17
|
import { getSession as getBetterAuthSession } from '../server/auth';
|
|
18
|
+
import { resolveAcrLevel, resolveAuthMethods } from '../lib/acr';
|
|
18
19
|
import { nanoid } from 'nanoid';
|
|
19
20
|
import {
|
|
20
21
|
getSession,
|
|
@@ -244,10 +245,24 @@ export function createAuthHandler(options: AuthHandlerOptions = {}) {
|
|
|
244
245
|
return { success: false, error: 'No refresh token' };
|
|
245
246
|
}
|
|
246
247
|
|
|
247
|
-
// Extract authentication methods from session
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
248
|
+
// Extract authentication methods from session.
|
|
249
|
+
//
|
|
250
|
+
// 375639: these two lines used to read
|
|
251
|
+
// authMethods = ... : ['pwd', 'mfa']
|
|
252
|
+
// authLevel = String(... || ... || '2')
|
|
253
|
+
// so a session that knew NOTHING about how the user authenticated told
|
|
254
|
+
// the IDP that a second factor had happened AND named 'mfa' as the
|
|
255
|
+
// method. The two fabrications corroborated each other, so a downstream
|
|
256
|
+
// check cross-validating amr against acr would have accepted the pair.
|
|
257
|
+
// Absent is not satisfied; both now resolve fail-closed, in one place.
|
|
258
|
+
const authMethods = resolveAuthMethods(
|
|
259
|
+
(currentSession as any).authMethods,
|
|
260
|
+
(currentSession as any).token?.amr
|
|
261
|
+
);
|
|
262
|
+
const authLevel = resolveAcrLevel(
|
|
263
|
+
(currentSession as any).authenticationLevel,
|
|
264
|
+
(currentSession as any).token?.acr
|
|
265
|
+
);
|
|
251
266
|
const twoFactorMethod = (currentSession as any).twoFactorMethod || 'authenticator';
|
|
252
267
|
|
|
253
268
|
// Build refresh request body
|
|
@@ -21,6 +21,27 @@
|
|
|
21
21
|
* v1 `startsWith('/') && !startsWith('//')` -> bypassed by /BACKSLASH host
|
|
22
22
|
* v2 + `!startsWith('/' + BACKSLASH)` -> bypassed by /TAB/host
|
|
23
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.
|
|
24
45
|
*
|
|
25
46
|
* Both bypasses work the same way: the browser NORMALISES the string AFTER the
|
|
26
47
|
* guard has finished looking at it. The WHATWG URL parser converts backslashes
|
|
@@ -75,18 +96,69 @@ export function safeCallbackPath(raw: unknown, fallback: string = DEFAULT_CALLBA
|
|
|
75
96
|
if (url.pathname.startsWith(AUTH_PREFIX)) return fallback;
|
|
76
97
|
|
|
77
98
|
// The parser's normalised output - control characters already stripped.
|
|
78
|
-
|
|
79
|
-
}
|
|
99
|
+
const candidate = url.pathname + url.search + url.hash;
|
|
80
100
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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;
|
|
84
132
|
try {
|
|
85
|
-
|
|
86
|
-
return url.origin === BASE && !url.pathname.startsWith(AUTH_PREFIX);
|
|
133
|
+
echo = new URL(candidate, BASE);
|
|
87
134
|
} catch {
|
|
88
|
-
return
|
|
135
|
+
return fallback;
|
|
89
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;
|
|
90
162
|
}
|
|
91
163
|
|
|
92
164
|
/**
|
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
|
+
}
|