@payez/next-mvp 4.13.1 → 4.15.0
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/CHANGELOG.md +33 -1
- package/dist/api-handlers/auth/credential-login.js +46 -0
- package/dist/api-handlers/mfa/first-enrol.d.ts +5 -0
- package/dist/api-handlers/mfa/first-enrol.js +58 -0
- package/dist/api-handlers/mfa/totp-confirm.d.ts +1 -1
- package/dist/api-handlers/mfa/totp-confirm.js +10 -6
- package/dist/api-handlers/test/force-expire.d.ts +4 -5
- package/dist/api-handlers/test/force-expire.js +14 -63
- package/dist/auth/better-auth.d.ts +16 -0
- package/dist/auth/better-auth.js +34 -0
- package/dist/components/auth/CredentialSignInForm.d.ts +6 -1
- package/dist/components/auth/CredentialSignInForm.js +15 -1
- package/dist/components/mfa/FirstEnrolPanel.d.ts +21 -0
- package/dist/components/mfa/FirstEnrolPanel.js +115 -0
- package/dist/lib/credential-sign-in-client.d.ts +4 -0
- package/dist/lib/credential-sign-in-client.js +13 -1
- package/dist/lib/first-enrol.d.ts +66 -0
- package/dist/lib/first-enrol.js +188 -0
- package/dist/lib/idp-client-config.js +1 -1
- package/dist/lib/log-safe.d.ts +6 -0
- package/dist/lib/log-safe.js +9 -0
- package/dist/lib/payez-client-secret.js +2 -1
- package/dist/lib/token-pages-switch.d.ts +23 -0
- package/dist/lib/token-pages-switch.js +48 -0
- package/dist/pages/first-enroll/page.d.ts +7 -0
- package/dist/pages/first-enroll/page.js +15 -0
- package/dist/pages/test-env/JwtInspectPage.d.ts +16 -14
- package/dist/pages/test-env/JwtInspectPage.js +16 -14
- package/dist/pages/test-env/RefreshTokenPage.d.ts +7 -10
- package/dist/pages/test-env/RefreshTokenPage.js +7 -10
- package/dist/pages/test-env/guarded.js +21 -57
- package/dist/pages/test-env/index.d.ts +15 -24
- package/dist/pages/test-env/index.js +15 -26
- package/dist/routes/mfa/first-enrol-confirm.d.ts +10 -0
- package/dist/routes/mfa/first-enrol-confirm.js +14 -0
- package/dist/routes/mfa/first-enrol-enroll.d.ts +10 -0
- package/dist/routes/mfa/first-enrol-enroll.js +14 -0
- package/package.json +26 -1
- package/src/api-handlers/auth/credential-login.ts +49 -0
- package/src/api-handlers/mfa/first-enrol.ts +55 -0
- package/src/api-handlers/mfa/totp-confirm.ts +13 -5
- package/src/api-handlers/test/force-expire.ts +13 -70
- package/src/auth/better-auth.ts +44 -2
- package/src/components/auth/CredentialSignInForm.tsx +18 -1
- package/src/components/mfa/FirstEnrolPanel.tsx +189 -0
- package/src/lib/credential-sign-in-client.ts +13 -1
- package/src/lib/first-enrol.ts +180 -0
- package/src/lib/idp-client-config.ts +2 -2
- package/src/lib/log-safe.ts +9 -0
- package/src/lib/payez-client-secret.ts +2 -1
- package/src/lib/token-pages-switch.ts +44 -0
- package/src/pages/first-enroll/page.tsx +19 -0
- package/src/pages/test-env/JwtInspectPage.tsx +16 -14
- package/src/pages/test-env/RefreshTokenPage.tsx +7 -10
- package/src/pages/test-env/guarded.tsx +21 -55
- package/src/pages/test-env/index.ts +15 -26
- package/src/routes/mfa/first-enrol-confirm.ts +10 -0
- package/src/routes/mfa/first-enrol-enroll.ts +10 -0
|
@@ -6,81 +6,45 @@ exports.RefreshTokenPage = RefreshTokenPage;
|
|
|
6
6
|
exports.EmergencyLogoutPage = EmergencyLogoutPage;
|
|
7
7
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
* ruling 35321). These are what `@payez/next-mvp/pages/test-env` exports. The
|
|
11
|
-
* raw client components are NOT exported.
|
|
9
|
+
* The BARREL that `@payez/next-mvp/pages/test-env` exports: one server-component wrapper per test-env page.
|
|
12
10
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* that should not have happened. It is NOT a licence to mount these freely:
|
|
16
|
-
* setting ENABLE_TEST_ROUTES=true in an environment where these pages are
|
|
17
|
-
* mounted gives you exactly the exposure this package spent 248991 documenting
|
|
18
|
-
* - a live session token and a copy button. If you are not actively debugging,
|
|
19
|
-
* DO NOT MOUNT THESE AT ALL. The safest configuration remains their absence.
|
|
11
|
+
* PAY-3216 (Jon's order, 15 Nov): the ENABLE_TEST_ROUTES check and its notFound() are REMOVED. Each wrapper renders
|
|
12
|
+
* its page unconditionally, as the pages rendered before 1d40a8c. Ruling 35321 is withdrawn.
|
|
20
13
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* NEXT_PUBLIC_ environment variables are only available in the Node.js
|
|
26
|
-
* environment"). A check written inside those files would read `undefined` on
|
|
27
|
-
* the client and deny ALWAYS - killing the tool in dev too - while reading the
|
|
28
|
-
* REAL value during SSR, producing a hydration mismatch. It would look like a
|
|
29
|
-
* gate, satisfy a grep for one, and protect nothing.
|
|
14
|
+
* PAY-3220 (ruled Win 105653 s1): the ONE production switch, PAYEZ_TOKEN_PAGES_OFF (../../lib/token-pages-switch), sits
|
|
15
|
+
* here for the two token-showing pages ONLY. Unset, they render exactly as before. Set, they answer notFound(): a real
|
|
16
|
+
* 404, indistinguishable from a route that was never registered (a 403 would confirm the page exists).
|
|
17
|
+
* TestEnvPage (the index) and EmergencyLogoutPage are NOT covered, on purpose: Jon named the two token-showing pages.
|
|
30
18
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* The test is `=== 'true'`, not truthiness. Unset, empty, "false", "0", "TRUE"
|
|
36
|
-
* and every typo all deny. The UNSAFE state requires a deliberate, exact act;
|
|
37
|
-
* the SAFE state is what you get by doing nothing, including by misconfiguring
|
|
38
|
-
* it. A gate whose default is "open" is not a gate.
|
|
39
|
-
*
|
|
40
|
-
* `notFound()` is deliberate and is not a 403: a real 404 is indistinguishable
|
|
41
|
-
* from a route that was never registered, whereas a 403 CONFIRMS the page
|
|
42
|
-
* exists to anyone who probes for it. That is the same disclosure axis card
|
|
43
|
-
* 245920 was raised about, so shipping the confirming variant here would
|
|
44
|
-
* reintroduce next door the thing being fixed there.
|
|
45
|
-
*
|
|
46
|
-
* notFound() throws (NEXT_HTTP_ERROR_FALLBACK;404) and terminates rendering of
|
|
47
|
-
* the segment, so nothing below it runs and the client component is never sent
|
|
48
|
-
* to the browser. It does not need `return` - it is typed `never`.
|
|
19
|
+
* PER REQUEST: unstable_noStore() makes the segment dynamic, so a consumer's page is never prerendered with the build
|
|
20
|
+
* host's value. It is synchronous (the wrappers stay plain components, so PAY-3216's renderToString rows still hold) and
|
|
21
|
+
* a no-op outside a request. LIMIT: in Next 16 with cacheComponents (Dynamic I/O) it is a no-op too; the production
|
|
22
|
+
* roll's served check (both pages read back as 404 by request) is the row that would show it.
|
|
49
23
|
*/
|
|
50
24
|
const navigation_1 = require("next/navigation");
|
|
25
|
+
const cache_1 = require("next/cache");
|
|
26
|
+
const token_pages_switch_1 = require("../../lib/token-pages-switch");
|
|
51
27
|
const TestEnvPage_1 = require("./TestEnvPage");
|
|
52
28
|
const JwtInspectPage_1 = require("./JwtInspectPage");
|
|
53
29
|
const RefreshTokenPage_1 = require("./RefreshTokenPage");
|
|
54
30
|
const EmergencyLogoutPage_1 = require("./EmergencyLogoutPage");
|
|
55
|
-
/**
|
|
56
|
-
* The single gate. Exact-match on purpose - see FAIL CLOSED above.
|
|
57
|
-
*
|
|
58
|
-
* Read at REQUEST time inside each page rather than once at module scope: a
|
|
59
|
-
* module-scope constant would be captured at build time, so a consumer who
|
|
60
|
-
* changed the variable would keep whatever value was baked into the bundle and
|
|
61
|
-
* would have no way to tell. Reading per render costs an object property
|
|
62
|
-
* lookup and removes an entire class of "I turned it off and it stayed on".
|
|
63
|
-
*/
|
|
64
|
-
function testRoutesEnabled() {
|
|
65
|
-
return process.env.ENABLE_TEST_ROUTES === 'true';
|
|
66
|
-
}
|
|
67
31
|
function TestEnvPage() {
|
|
68
|
-
if (!testRoutesEnabled())
|
|
69
|
-
(0, navigation_1.notFound)();
|
|
70
32
|
return (0, jsx_runtime_1.jsx)(TestEnvPage_1.TestEnvPage, {});
|
|
71
33
|
}
|
|
72
|
-
|
|
73
|
-
|
|
34
|
+
/** PAY-3220: per request, then the switch. notFound() throws, so nothing below it renders and no client code is sent. */
|
|
35
|
+
function refuseWhenTokenPagesOff() {
|
|
36
|
+
(0, cache_1.unstable_noStore)();
|
|
37
|
+
if ((0, token_pages_switch_1.tokenPagesOff)())
|
|
74
38
|
(0, navigation_1.notFound)();
|
|
39
|
+
}
|
|
40
|
+
function JwtInspectPage() {
|
|
41
|
+
refuseWhenTokenPagesOff();
|
|
75
42
|
return (0, jsx_runtime_1.jsx)(JwtInspectPage_1.JwtInspectPage, {});
|
|
76
43
|
}
|
|
77
44
|
function RefreshTokenPage() {
|
|
78
|
-
|
|
79
|
-
(0, navigation_1.notFound)();
|
|
45
|
+
refuseWhenTokenPagesOff();
|
|
80
46
|
return (0, jsx_runtime_1.jsx)(RefreshTokenPage_1.RefreshTokenPage, {});
|
|
81
47
|
}
|
|
82
48
|
function EmergencyLogoutPage() {
|
|
83
|
-
if (!testRoutesEnabled())
|
|
84
|
-
(0, navigation_1.notFound)();
|
|
85
49
|
return (0, jsx_runtime_1.jsx)(EmergencyLogoutPage_1.EmergencyLogoutPage, {});
|
|
86
50
|
}
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* and the block below is a four-line copy-paste that mounts every debug page
|
|
8
8
|
* this package has. It previously carried no warning at all.
|
|
9
9
|
*
|
|
10
|
-
* NONE of these pages has an env gate of its own.
|
|
11
|
-
* handler (
|
|
10
|
+
* NONE of these pages has an env gate of its own. Like the force-expire
|
|
11
|
+
* handler (no flag since PAY-3216) and unlike the idp-token route
|
|
12
12
|
* (ENABLE_TOKEN_INSPECTOR), a mounted page is reachable exactly as far as your
|
|
13
13
|
* middleware allows. MOUNTING IS THE GATE — so gate the WHOLE /test-env
|
|
14
14
|
* segment, per environment, not page by page.
|
|
@@ -22,28 +22,19 @@
|
|
|
22
22
|
* RefreshTokenPage Drives the destructive /api/test/force-expire.
|
|
23
23
|
* EmergencyLogoutPage Mildest. Self-inflicted, recoverable.
|
|
24
24
|
*
|
|
25
|
-
* ──
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* is
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* consumers add one env var to their dev environments and change no code.
|
|
39
|
-
*
|
|
40
|
-
* ── MOUNTING IS STILL THE PRIMARY GATE ──────────────────────────────────────
|
|
41
|
-
* Do not read the env var as making mounting safe. The wrapper is a backstop
|
|
42
|
-
* for a mount that should not have happened. With ENABLE_TEST_ROUTES=true in an
|
|
43
|
-
* environment where these are mounted you have exactly the exposure 248991
|
|
44
|
-
* documented: a live session token, one click from the clipboard. If you are
|
|
45
|
-
* not actively debugging, DO NOT MOUNT THESE AT ALL — the safest configuration
|
|
46
|
-
* is still their absence, and the env var does not change that.
|
|
25
|
+
* ── NO ENV GATE (PAY-3216, Jon's order, 15 Nov) ─────────────────────────────
|
|
26
|
+
* The ENABLE_TEST_ROUTES gate that 1d40a8c (4.2.0, ruling 35321) put on these
|
|
27
|
+
* four pages is REMOVED: they render with no flag set, as before that commit.
|
|
28
|
+
* The ruling is withdrawn. MOUNTING IS THE GATE: a mounted page is reachable
|
|
29
|
+
* exactly as far as your middleware allows. Which of them a production site
|
|
30
|
+
* mounts is the site owner's choice.
|
|
31
|
+
*
|
|
32
|
+
* ── ONE PRODUCTION SWITCH (PAY-3220) ────────────────────────────────────────
|
|
33
|
+
* PAYEZ_TOKEN_PAGES_OFF, UNSET by default (every page works as above). Set on
|
|
34
|
+
* a production host, on Jon's word only, it makes JwtInspectPage,
|
|
35
|
+
* RefreshTokenPage and /api/test/force-expire answer 404. TestEnvPage and
|
|
36
|
+
* EmergencyLogoutPage are not covered. Read server-side per request; see
|
|
37
|
+
* lib/token-pages-switch for the values it accepts.
|
|
47
38
|
*
|
|
48
39
|
* Each page's own docstring carries the detail. If you are not actively
|
|
49
40
|
* debugging, DO NOT MOUNT THESE AT ALL — the safest configuration is their
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* and the block below is a four-line copy-paste that mounts every debug page
|
|
9
9
|
* this package has. It previously carried no warning at all.
|
|
10
10
|
*
|
|
11
|
-
* NONE of these pages has an env gate of its own.
|
|
12
|
-
* handler (
|
|
11
|
+
* NONE of these pages has an env gate of its own. Like the force-expire
|
|
12
|
+
* handler (no flag since PAY-3216) and unlike the idp-token route
|
|
13
13
|
* (ENABLE_TOKEN_INSPECTOR), a mounted page is reachable exactly as far as your
|
|
14
14
|
* middleware allows. MOUNTING IS THE GATE — so gate the WHOLE /test-env
|
|
15
15
|
* segment, per environment, not page by page.
|
|
@@ -23,28 +23,19 @@
|
|
|
23
23
|
* RefreshTokenPage Drives the destructive /api/test/force-expire.
|
|
24
24
|
* EmergencyLogoutPage Mildest. Self-inflicted, recoverable.
|
|
25
25
|
*
|
|
26
|
-
* ──
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* is
|
|
26
|
+
* ── NO ENV GATE (PAY-3216, Jon's order, 15 Nov) ─────────────────────────────
|
|
27
|
+
* The ENABLE_TEST_ROUTES gate that 1d40a8c (4.2.0, ruling 35321) put on these
|
|
28
|
+
* four pages is REMOVED: they render with no flag set, as before that commit.
|
|
29
|
+
* The ruling is withdrawn. MOUNTING IS THE GATE: a mounted page is reachable
|
|
30
|
+
* exactly as far as your middleware allows. Which of them a production site
|
|
31
|
+
* mounts is the site owner's choice.
|
|
32
32
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* consumers add one env var to their dev environments and change no code.
|
|
40
|
-
*
|
|
41
|
-
* ── MOUNTING IS STILL THE PRIMARY GATE ──────────────────────────────────────
|
|
42
|
-
* Do not read the env var as making mounting safe. The wrapper is a backstop
|
|
43
|
-
* for a mount that should not have happened. With ENABLE_TEST_ROUTES=true in an
|
|
44
|
-
* environment where these are mounted you have exactly the exposure 248991
|
|
45
|
-
* documented: a live session token, one click from the clipboard. If you are
|
|
46
|
-
* not actively debugging, DO NOT MOUNT THESE AT ALL — the safest configuration
|
|
47
|
-
* is still their absence, and the env var does not change that.
|
|
33
|
+
* ── ONE PRODUCTION SWITCH (PAY-3220) ────────────────────────────────────────
|
|
34
|
+
* PAYEZ_TOKEN_PAGES_OFF, UNSET by default (every page works as above). Set on
|
|
35
|
+
* a production host, on Jon's word only, it makes JwtInspectPage,
|
|
36
|
+
* RefreshTokenPage and /api/test/force-expire answer 404. TestEnvPage and
|
|
37
|
+
* EmergencyLogoutPage are not covered. Read server-side per request; see
|
|
38
|
+
* lib/token-pages-switch for the values it accepts.
|
|
48
39
|
*
|
|
49
40
|
* Each page's own docstring carries the detail. If you are not actively
|
|
50
41
|
* debugging, DO NOT MOUNT THESE AT ALL — the safest configuration is their
|
|
@@ -67,9 +58,7 @@
|
|
|
67
58
|
*/
|
|
68
59
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
69
60
|
exports.EmergencyLogoutPage = exports.RefreshTokenPage = exports.JwtInspectPage = exports.default = exports.TestEnvPage = void 0;
|
|
70
|
-
//
|
|
71
|
-
// etc. would reintroduce an ungated path to the same pages under a different
|
|
72
|
-
// import, which is the "opt-in gate nobody opts into" the ruling rejected.
|
|
61
|
+
// Through the barrel (./guarded), as on main: it is PAY-3220's seam. Since PAY-3216 it renders each page with no flag.
|
|
73
62
|
var guarded_1 = require("./guarded");
|
|
74
63
|
Object.defineProperty(exports, "TestEnvPage", { enumerable: true, get: function () { return guarded_1.TestEnvPage; } });
|
|
75
64
|
Object.defineProperty(exports, "default", { enumerable: true, get: function () { return guarded_1.TestEnvPage; } });
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AUTH-045 first-enrolment: confirm the first authenticator code, clear the enrolment cookie, return the recovery codes. Mints no session.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```typescript
|
|
6
|
+
* // app/api/auth/first-enrol/confirm/route.ts
|
|
7
|
+
* export { POST } from '@payez/next-mvp/routes/mfa/first-enrol-confirm';
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
export { POST_CONFIRM as POST } from '../../api-handlers/mfa/first-enrol';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.POST = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* AUTH-045 first-enrolment: confirm the first authenticator code, clear the enrolment cookie, return the recovery codes. Mints no session.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* // app/api/auth/first-enrol/confirm/route.ts
|
|
10
|
+
* export { POST } from '@payez/next-mvp/routes/mfa/first-enrol-confirm';
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
var first_enrol_1 = require("../../api-handlers/mfa/first-enrol");
|
|
14
|
+
Object.defineProperty(exports, "POST", { enumerable: true, get: function () { return first_enrol_1.POST_CONFIRM; } });
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AUTH-045 first-enrolment: begin TOTP enrolment for a user the IdP told to enrol (uses ONLY the httpOnly enrolment cookie the credential door parked).
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```typescript
|
|
6
|
+
* // app/api/auth/first-enrol/enroll/route.ts
|
|
7
|
+
* export { POST } from '@payez/next-mvp/routes/mfa/first-enrol-enroll';
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
export { POST_ENROLL as POST } from '../../api-handlers/mfa/first-enrol';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.POST = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* AUTH-045 first-enrolment: begin TOTP enrolment for a user the IdP told to enrol (uses ONLY the httpOnly enrolment cookie the credential door parked).
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* // app/api/auth/first-enrol/enroll/route.ts
|
|
10
|
+
* export { POST } from '@payez/next-mvp/routes/mfa/first-enrol-enroll';
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
var first_enrol_1 = require("../../api-handlers/mfa/first-enrol");
|
|
14
|
+
Object.defineProperty(exports, "POST", { enumerable: true, get: function () { return first_enrol_1.POST_ENROLL; } });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@payez/next-mvp",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.15.0",
|
|
4
4
|
"sideEffects": false,
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -130,6 +130,11 @@
|
|
|
130
130
|
"require": "./dist/lib/session-store.js",
|
|
131
131
|
"default": "./dist/lib/session-store.js"
|
|
132
132
|
},
|
|
133
|
+
"./lib/idp-logout": {
|
|
134
|
+
"types": "./dist/lib/idp-logout.d.ts",
|
|
135
|
+
"require": "./dist/lib/idp-logout.js",
|
|
136
|
+
"default": "./dist/lib/idp-logout.js"
|
|
137
|
+
},
|
|
133
138
|
"./lib/nextauth-secret": {
|
|
134
139
|
"types": "./dist/lib/nextauth-secret.d.ts",
|
|
135
140
|
"require": "./dist/lib/nextauth-secret.js",
|
|
@@ -155,6 +160,11 @@
|
|
|
155
160
|
"require": "./dist/lib/app-slug.js",
|
|
156
161
|
"default": "./dist/lib/app-slug.js"
|
|
157
162
|
},
|
|
163
|
+
"./lib/first-enrol": {
|
|
164
|
+
"types": "./dist/lib/first-enrol.d.ts",
|
|
165
|
+
"require": "./dist/lib/first-enrol.js",
|
|
166
|
+
"default": "./dist/lib/first-enrol.js"
|
|
167
|
+
},
|
|
158
168
|
"./api-handlers/anon/preferences": {
|
|
159
169
|
"types": "./dist/api-handlers/anon/preferences.d.ts",
|
|
160
170
|
"require": "./dist/api-handlers/anon/preferences.js",
|
|
@@ -990,6 +1000,16 @@
|
|
|
990
1000
|
"require": "./dist/routes/mfa/totp-confirm.js",
|
|
991
1001
|
"default": "./dist/routes/mfa/totp-confirm.js"
|
|
992
1002
|
},
|
|
1003
|
+
"./routes/mfa/first-enrol-enroll": {
|
|
1004
|
+
"types": "./dist/routes/mfa/first-enrol-enroll.d.ts",
|
|
1005
|
+
"require": "./dist/routes/mfa/first-enrol-enroll.js",
|
|
1006
|
+
"default": "./dist/routes/mfa/first-enrol-enroll.js"
|
|
1007
|
+
},
|
|
1008
|
+
"./routes/mfa/first-enrol-confirm": {
|
|
1009
|
+
"types": "./dist/routes/mfa/first-enrol-confirm.d.ts",
|
|
1010
|
+
"require": "./dist/routes/mfa/first-enrol-confirm.js",
|
|
1011
|
+
"default": "./dist/routes/mfa/first-enrol-confirm.js"
|
|
1012
|
+
},
|
|
993
1013
|
"./routes/mfa/totp-verify": {
|
|
994
1014
|
"types": "./dist/routes/mfa/totp-verify.d.ts",
|
|
995
1015
|
"require": "./dist/routes/mfa/totp-verify.js",
|
|
@@ -1100,6 +1120,11 @@
|
|
|
1100
1120
|
"require": "./dist/components/mfa/TotpEnrollPanel.js",
|
|
1101
1121
|
"default": "./dist/components/mfa/TotpEnrollPanel.js"
|
|
1102
1122
|
},
|
|
1123
|
+
"./dist/pages/first-enroll": {
|
|
1124
|
+
"types": "./dist/pages/first-enroll/page.d.ts",
|
|
1125
|
+
"require": "./dist/pages/first-enroll/page.js",
|
|
1126
|
+
"default": "./dist/pages/first-enroll/page.js"
|
|
1127
|
+
},
|
|
1103
1128
|
"./dist/pages/enroll": {
|
|
1104
1129
|
"types": "./dist/pages/enroll/page.d.ts",
|
|
1105
1130
|
"require": "./dist/pages/enroll/page.js",
|
|
@@ -49,6 +49,8 @@ import { DEVICE_ID_HEADER, isValidDeviceId } from '../../lib/device-id';
|
|
|
49
49
|
import { classifyTwofaRejection, type TwofaLaneResult } from '../../lib/twofa-rejection';
|
|
50
50
|
import { bearerFetch } from '../../lib/bearer-fetch';
|
|
51
51
|
import { errForLog } from '../../lib/log-safe';
|
|
52
|
+
// AUTH-045: the httpOnly cookie that carries the enrolment-scoped token from the code turn to the two enrolment routes (ONE name, defined in lib/first-enrol).
|
|
53
|
+
import { ENROL_COOKIE, ENROL_COOKIE_MAX_AGE } from '../../lib/first-enrol';
|
|
52
54
|
|
|
53
55
|
// `export const runtime = 'nodejs'` must be written LITERALLY in the app's route file (Next ignores a re-exported segment config).
|
|
54
56
|
|
|
@@ -348,6 +350,53 @@ export async function POST(req: NextRequest) {
|
|
|
348
350
|
const vresult = verifyData?.data?.result ?? verifyData?.data ?? verifyData;
|
|
349
351
|
const vAccess: string | undefined = vresult?.access_token;
|
|
350
352
|
const vRefresh: string | undefined = vresult?.refresh_token;
|
|
353
|
+
// PAY-3083 (AUTH-045): ACCESS WITHOUT REFRESH AT THE CODE TURN IS NOT A FAILURE - IT IS ENROLMENT.
|
|
354
|
+
//
|
|
355
|
+
// The IdP withholds the refresh token for a user who has no usable second factor on THIS client.
|
|
356
|
+
// TOTP enrolment is PER CLIENT (DotNetPert 99446 sec A, client 90717 / user 1175): the password turn
|
|
357
|
+
// answered Require2FA=true, IssueRefreshToken=false, step=enroll_mfa, and this verify turn then
|
|
358
|
+
// accepted the emailed code as CONTACT PROOF and issued an ENROLMENT-SCOPED access token, again with
|
|
359
|
+
// no refresh token. That is exactly what PAY-2798 requires of the IdP - the IdP is RIGHT.
|
|
360
|
+
//
|
|
361
|
+
// This branch used to read "access, no refresh" as an incomplete response and answer 502, so the
|
|
362
|
+
// developer who had just proved their contact was told the server was broken and could never reach
|
|
363
|
+
// the enrolment page: the 502 the field saw as
|
|
364
|
+
// '[ADMIN-LOGIN] verify response missing token { hasAccess: true, hasRefresh: false }'.
|
|
365
|
+
//
|
|
366
|
+
// Send them to ENROLMENT instead, in the ONE contract the door and the form already share for it
|
|
367
|
+
// (PAY-2630): requiresTwoFactor + nextStep 'enroll_mfa'. No session is minted - an enrolment-scoped
|
|
368
|
+
// token is not a signed-in session - and the token value is NEVER logged nor echoed (BAPert 99521 #2).
|
|
369
|
+
//
|
|
370
|
+
// Guard kept: no access token AT ALL (a refresh-only or empty body) is still a broken success and
|
|
371
|
+
// still 502 below - this widening is only for a genuine access-with-no-refresh answer.
|
|
372
|
+
if (vAccess && !vRefresh) {
|
|
373
|
+
const step = vresult?.next_step === 'enroll_mfa' || vresult?.next_step === 'mfa_verify' ? vresult.next_step : 'enroll_mfa';
|
|
374
|
+
const enrolling = step === 'enroll_mfa';
|
|
375
|
+
const answer = NextResponse.json(
|
|
376
|
+
{
|
|
377
|
+
success: false,
|
|
378
|
+
requiresTwoFactor: true,
|
|
379
|
+
acceptedFactors: acceptedFactorsFor('email'),
|
|
380
|
+
sentVia: null,
|
|
381
|
+
codeRequested: false,
|
|
382
|
+
emailSent: false,
|
|
383
|
+
nextStep: step,
|
|
384
|
+
// AUTH-045 (walk 14 Nov 2026): requiresTwoFactor alone is also the password turn's "a code was sent", so the form re-drew the code box
|
|
385
|
+
// and looped. This flag is the ONLY signal that means "go enrol"; it is set on this answer and nowhere else.
|
|
386
|
+
...(enrolling ? { enrolmentRequired: true } : {}),
|
|
387
|
+
},
|
|
388
|
+
{ status: 200 },
|
|
389
|
+
);
|
|
390
|
+
if (enrolling) {
|
|
391
|
+
// No session exists for an enrolment-scoped token, and the enrolment page's actions need one, so the token rides in this httpOnly
|
|
392
|
+
// cookie to the enrolment routes (the portal's PAY-2798 part 2 pattern): never in a body, a URL or a log; strict; 600 s.
|
|
393
|
+
answer.cookies.set(ENROL_COOKIE, vAccess, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', path: '/', maxAge: ENROL_COOKIE_MAX_AGE });
|
|
394
|
+
// The provisional bearer is spent: the enrolment token replaces it.
|
|
395
|
+
answer.cookies.set('adm2fa', '', { path: '/', maxAge: 0 });
|
|
396
|
+
answer.cookies.set('adm2fa_lane', '', { path: '/', maxAge: 0 });
|
|
397
|
+
}
|
|
398
|
+
return answer;
|
|
399
|
+
}
|
|
351
400
|
if (!vAccess || !vRefresh) {
|
|
352
401
|
console.error('[ADMIN-LOGIN] verify response missing tokens', { hasAccess: !!vAccess, hasRefresh: !!vRefresh });
|
|
353
402
|
return NextResponse.json({ success: false, error: 'Authentication service returned an incomplete response' }, { status: 502 });
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
3
|
+
import { getIDPClientConfig } from '../../lib/idp-client-config';
|
|
4
|
+
import { resolveClientKey } from '../../lib/client-key';
|
|
5
|
+
import { NO_STORE, refuseNonJson } from '../../lib/mfa-json-guard';
|
|
6
|
+
import { ENROL_COOKIE, LIMIT_PER_COOKIE, attemptAllowed, runEnrolStep, type EnrolStep } from '../../lib/first-enrol';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* AUTH-045 first-enrolment carry (PAY-2938, BAPert 100969 rows 3-5): the TWO routes a user the IdP told to enrol uses, built on lib/first-enrol.
|
|
10
|
+
*
|
|
11
|
+
* WHO MAY CALL: the browser that holds the httpOnly enrolment cookie the credential door parked (ENROL_COOKIE). This is the ONLY module besides the
|
|
12
|
+
* door that reads that cookie, and it serves ONLY these two operations; every other package route stays session-gated and answers 401 to a caller
|
|
13
|
+
* holding nothing but this cookie (a gate row holds that). No cookie -> 401 SIGN_IN_AGAIN and no IdP call.
|
|
14
|
+
*
|
|
15
|
+
* What follows a confirm (BAPert 100969 row 5): the cookie is cleared and the answer carries the recovery codes; NO session is minted here and no new
|
|
16
|
+
* way to mint one is added. The person signs in again with their password and the authenticator, through the same credential door as before.
|
|
17
|
+
*
|
|
18
|
+
* The token never leaves the server: not in a body, a URL, a log or a Referer. The IdP's prose is never relayed.
|
|
19
|
+
*/
|
|
20
|
+
const digest = (s: string) => createHash('sha256').update(s).digest('hex');
|
|
21
|
+
|
|
22
|
+
const cookieOpts = (maxAge: number) => ({ httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict' as const, path: '/', maxAge });
|
|
23
|
+
|
|
24
|
+
function reply(status: number, body: Record<string, unknown>, clear: boolean): NextResponse {
|
|
25
|
+
const res = NextResponse.json(body, { status, headers: NO_STORE });
|
|
26
|
+
if (clear) res.cookies.set(ENROL_COOKIE, '', cookieOpts(0));
|
|
27
|
+
return res;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function enrolStepPOST(step: EnrolStep) {
|
|
31
|
+
return async function POST(req: NextRequest): Promise<NextResponse> {
|
|
32
|
+
const refused = refuseNonJson(req);
|
|
33
|
+
if (refused) return refused;
|
|
34
|
+
const token = req.cookies.get(ENROL_COOKIE)?.value;
|
|
35
|
+
if (!token) return reply(401, { success: false, code: 'SIGN_IN_AGAIN' }, false);
|
|
36
|
+
if (!attemptAllowed(`${step}|ck|${digest(token)}`, LIMIT_PER_COOKIE)) return reply(429, { success: false, code: 'TOO_MANY', error: 'Too many attempts. Wait a few minutes and try again.' }, true);
|
|
37
|
+
|
|
38
|
+
const idp = process.env.IDP_URL;
|
|
39
|
+
let clientKey = '';
|
|
40
|
+
try {
|
|
41
|
+
const config = await getIDPClientConfig();
|
|
42
|
+
clientKey = resolveClientKey(config, 'first-enrol').key ?? '';
|
|
43
|
+
} catch {
|
|
44
|
+
return reply(503, { success: false, code: 'IDP_CONFIG_UNAVAILABLE' }, false);
|
|
45
|
+
}
|
|
46
|
+
if (!idp || !clientKey) return reply(503, { success: false, code: 'IDP_CONFIG_UNAVAILABLE' }, false);
|
|
47
|
+
|
|
48
|
+
const body = await req.json().catch(() => null);
|
|
49
|
+
const r = await runEnrolStep({ fetchImpl: (u, i) => fetch(u, i), idp, clientKey }, { step, token, body });
|
|
50
|
+
return reply(r.status, r.body, r.cookie === 'clear');
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const POST_ENROLL = enrolStepPOST('enroll');
|
|
55
|
+
export const POST_CONFIRM = enrolStepPOST('confirm');
|
|
@@ -37,10 +37,18 @@ export async function POST(req: NextRequest) {
|
|
|
37
37
|
);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// PAY-3214: the browser gets ONLY `confirmed` and `stepped_up`. When the IdP steps the session up it
|
|
41
|
+
// re-mints and answers { confirmed, stepped_up: true, access_token, refresh_token, token_type }; relaying
|
|
42
|
+
// `data` whole put the person's bearer and refresh token in a browser-readable body. The pair is DROPPED
|
|
43
|
+
// here, not stored: storing it would change the session's MFA state from an ENROLMENT, which
|
|
44
|
+
// totp-verify.ts's header forbids, and that is a ruling not yet given (BAPert-MB 105226 s3-s4). With the
|
|
45
|
+
// pair dropped the session keeps its own token, as on the IdP's own `stepped_up: false` path.
|
|
46
|
+
// `stepped_up` is passed back as the IdP said it (its meaning, with the pair dropped, is s3 Q2 of that ruling).
|
|
40
47
|
const bodyJson: any = result.json;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
48
|
+
const enveloped = bodyJson && typeof bodyJson === 'object' && 'success' in bodyJson && 'data' in bodyJson;
|
|
49
|
+
const data: any = (enveloped ? (bodyJson.success === true ? bodyJson.data : null) : bodyJson) ?? {};
|
|
50
|
+
return NextResponse.json(
|
|
51
|
+
{ confirmed: data.confirmed === true, stepped_up: data.stepped_up === true },
|
|
52
|
+
{ status: 200, headers: NO_STORE },
|
|
53
|
+
);
|
|
46
54
|
}
|
|
@@ -3,6 +3,7 @@ import { getSession as getBetterAuthSession } from '../../server/auth';
|
|
|
3
3
|
import { getSession, updateSession } from '../../lib/session-store';
|
|
4
4
|
import { logExceptionWithCorrelation } from '../../lib/safe-error-response';
|
|
5
5
|
import { tokenLogId } from '../../lib/token-log-id';
|
|
6
|
+
import { tokenPagesOff } from '../../lib/token-pages-switch';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* ⚠️ DESTRUCTIVE TEST-ONLY HANDLER — DO NOT MOUNT IN PRODUCTION. ⚠️
|
|
@@ -20,19 +21,18 @@ import { tokenLogId } from '../../lib/token-log-id';
|
|
|
20
21
|
* mounts a destructive endpoint. Every one of the three surfaces this route
|
|
21
22
|
* ended up on followed exactly these instructions.
|
|
22
23
|
*
|
|
23
|
-
* The handler itself was fixed (244877:
|
|
24
|
-
*
|
|
24
|
+
* The handler itself was fixed (244877: the caller-supplied-token IDOR removed;
|
|
25
|
+
* its ENABLE_TEST_ROUTES gate was later removed again by PAY-3216). But a fixed handler shipped
|
|
25
26
|
* with unqualified mounting instructions still spreads: the guidance was never
|
|
26
27
|
* corrected alongside the code, and a package that documents a dangerous route
|
|
27
28
|
* as a plain convenience is instructing the weaker configuration.
|
|
28
29
|
*
|
|
29
30
|
* ── IF YOU MOUNT IT, MOUNT IT LIKE THIS ───────────────────────────────────
|
|
30
|
-
*
|
|
31
|
-
*
|
|
31
|
+
* There is no env flag (PAY-3216): a mounted handler answers. Mount it only
|
|
32
|
+
* where you mean it to answer.
|
|
32
33
|
*
|
|
33
34
|
* ```typescript
|
|
34
35
|
* // app/api/test/force-expire/route.ts — TEST/DEV ENVIRONMENTS ONLY
|
|
35
|
-
* // Requires ENABLE_TEST_ROUTES=true in that environment; 404s otherwise.
|
|
36
36
|
* export { POST } from '@payez/next-mvp/api-handlers/test/force-expire';
|
|
37
37
|
* ```
|
|
38
38
|
*
|
|
@@ -40,67 +40,12 @@ import { tokenLogId } from '../../lib/token-log-id';
|
|
|
40
40
|
* The safest configuration is the absence of the route.
|
|
41
41
|
*/
|
|
42
42
|
export const POST = async (req: NextRequest) => {
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
// This handler MUTATES session state — it rewrites idpAccessTokenExpires two
|
|
46
|
-
// minutes into the past — and was gated on nothing but "any signed-in
|
|
47
|
-
// session": no role check, no env kill-switch. Because consuming apps mount
|
|
48
|
-
// it with a one-line re-export, EVERY consumer inherited that, including
|
|
49
|
-
// deployments on production hosts.
|
|
50
|
-
//
|
|
51
|
-
// The card was raised against one app's local copy. Sweeping the pattern
|
|
52
|
-
// rather than the instance found the same route in three places — a local
|
|
53
|
-
// copy in the graft, a local copy in idealvibe.online, and idealresume.online
|
|
54
|
-
// re-exporting THIS handler — so the fix belongs here, at the source all
|
|
55
|
-
// three ultimately derive from, not only in whichever copy was noticed first.
|
|
56
|
-
//
|
|
57
|
-
// The gate is the FIRST statement, before any session decode, so a disabled
|
|
58
|
-
// build does not reveal that the route is live. Absent, empty, or any value
|
|
59
|
-
// other than 'true' disables it. Distinguishable body on purpose: a bare 404
|
|
60
|
-
// reads as "route missing / build broken" and sends someone debugging
|
|
61
|
-
// deployment instead of reading a flag.
|
|
62
|
-
//
|
|
63
|
-
// A consumer that genuinely needs this in a test environment sets
|
|
64
|
-
// ENABLE_TEST_ROUTES=true there, which is the same posture the token
|
|
65
|
-
// inspector already uses for a strictly LESS dangerous, read-only route.
|
|
66
|
-
if (process.env.ENABLE_TEST_ROUTES !== 'true') {
|
|
67
|
-
// DISCLOSURE IS CONDITIONAL ON ENVIRONMENT (QAPert-NightHawk 34539, after
|
|
68
|
-
// QAPert caught that my first version shipped the very leak we had just
|
|
69
|
-
// criticised elsewhere).
|
|
70
|
-
//
|
|
71
|
-
// My original body named the flag: "Set ENABLE_TEST_ROUTES=true...". That
|
|
72
|
-
// is the same class of disclosure as TOKEN_INSPECTOR_DISABLED — it confirms
|
|
73
|
-
// the route exists, names the capability, and hands a prober the exact
|
|
74
|
-
// switch that turns it on. Having argued against that pattern one directory
|
|
75
|
-
// over, shipping it here would have been incoherent.
|
|
76
|
-
//
|
|
77
|
-
// Deleting the message outright is also wrong: it is what stops a developer
|
|
78
|
-
// losing an hour deciding whether their build is broken. The tension
|
|
79
|
-
// resolves by AUDIENCE, not by picking a side:
|
|
80
|
-
// production -> 404, EMPTY body. Nothing about the capability, the
|
|
81
|
-
// flag, or even that a handler is here.
|
|
82
|
-
// non-production -> 404 + the helpful message naming the flag.
|
|
83
|
-
//
|
|
84
|
-
// The operator affordance survives in production too, via the log line
|
|
85
|
-
// below: logs are visible to operators and not to probers, which is exactly
|
|
86
|
-
// the split the response body cannot make.
|
|
87
|
-
console.warn(
|
|
88
|
-
'[FORCE-EXPIRE] blocked: ENABLE_TEST_ROUTES is not "true" (test routes default-deny).',
|
|
89
|
-
);
|
|
43
|
+
// PAY-3216 (Jon's order, 15 Nov): the ENABLE_TEST_ROUTES default-deny gate that 072d20e (244877) put here is
|
|
44
|
+
// REMOVED. The handler works with no flag set, as before that commit. Mounting is the gate.
|
|
90
45
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return NextResponse.json(
|
|
96
|
-
{
|
|
97
|
-
error: 'TEST_ROUTES_DISABLED',
|
|
98
|
-
message:
|
|
99
|
-
'Test routes are disabled in this build. Set ENABLE_TEST_ROUTES=true to enable them (development only).',
|
|
100
|
-
},
|
|
101
|
-
{ status: 404 },
|
|
102
|
-
);
|
|
103
|
-
}
|
|
46
|
+
// PAY-3220 (ruled Win 105653 s1): the production switch for the token pages covers this route too, since the refresh
|
|
47
|
+
// page is its one caller. Unset, nothing changes. Set, an empty 404 before any session is read.
|
|
48
|
+
if (tokenPagesOff()) return new NextResponse(null, { status: 404 });
|
|
104
49
|
|
|
105
50
|
try {
|
|
106
51
|
const betterAuthSession = await getBetterAuthSession(req);
|
|
@@ -120,11 +65,9 @@ export const POST = async (req: NextRequest) => {
|
|
|
120
65
|
// now ALWAYS the caller's own, which is also what the graft's local copy
|
|
121
66
|
// has always done.
|
|
122
67
|
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
// each other's sessions" is not acceptable there either — a control that
|
|
127
|
-
// only holds while a feature is switched off is not an access control.
|
|
68
|
+
// A control that only held while a feature was switched off would not be
|
|
69
|
+
// an access control: "unauthenticated users can expire each other's
|
|
70
|
+
// sessions" is not acceptable in any environment that mounts this.
|
|
128
71
|
//
|
|
129
72
|
// Corrects NextPert's own scope claim on card 244877 ("a caller could only
|
|
130
73
|
// ever expire their OWN session"): that was read off the graft's LOCAL
|