@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.
@@ -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
- const authMethods = currentSession.authMethods ||
163
- (currentSession.token?.amr ? JSON.parse(currentSession.token.amr) : ['pwd', 'mfa']);
164
- const authLevel = String(currentSession.authenticationLevel || currentSession.token?.acr || '2');
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
- /** True when `raw` is a callback target we are willing to honour. */
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
- return url.pathname + url.search + url.hash;
81
- }
82
- /** True when `raw` is a callback target we are willing to honour. */
83
- function isSafeCallbackPath(raw) {
84
- if (typeof raw !== 'string' || raw.length === 0)
85
- return false;
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
- const url = new URL(raw, BASE);
88
- return url.origin === BASE && !url.pathname.startsWith(AUTH_PREFIX);
134
+ echo = new URL(candidate, BASE);
89
135
  }
90
136
  catch {
91
- return false;
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[];
@@ -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.2",
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));
@@ -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
- const authMethods = (currentSession as any).authMethods ||
249
- ((currentSession as any).token?.amr ? JSON.parse((currentSession as any).token.amr) : ['pwd', 'mfa']);
250
- const authLevel = String((currentSession as any).authenticationLevel || (currentSession as any).token?.acr || '2');
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
- return url.pathname + url.search + url.hash;
79
- }
99
+ const candidate = url.pathname + url.search + url.hash;
80
100
 
81
- /** True when `raw` is a callback target we are willing to honour. */
82
- export function isSafeCallbackPath(raw: unknown): raw is string {
83
- if (typeof raw !== 'string' || raw.length === 0) return false;
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
- const url = new URL(raw, BASE);
86
- return url.origin === BASE && !url.pathname.startsWith(AUTH_PREFIX);
133
+ echo = new URL(candidate, BASE);
87
134
  } catch {
88
- return false;
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
+ }