@payez/next-mvp 4.12.0 → 4.13.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 +51 -0
- package/dist/api-handlers/auth/credential-login.js +28 -4
- package/dist/api-handlers/mfa/totp-confirm.js +8 -4
- package/dist/api-handlers/mfa/totp-enroll.js +8 -4
- package/dist/api-handlers/session/viability.js +6 -2
- package/dist/auth/better-auth.d.ts +6 -0
- package/dist/auth/better-auth.js +29 -2
- package/dist/auth/session-reset-link.d.ts +32 -0
- package/dist/auth/session-reset-link.js +45 -1
- package/dist/components/auth/CredentialSignInForm.d.ts +7 -1
- package/dist/components/auth/CredentialSignInForm.js +13 -4
- package/dist/components/auth/EnrollAuthenticatorOffer.d.ts +21 -0
- package/dist/components/auth/EnrollAuthenticatorOffer.js +23 -0
- package/dist/components/mfa/QrCode.d.ts +6 -0
- package/dist/components/mfa/QrCode.js +14 -0
- package/dist/components/mfa/TotpEnrollPanel.d.ts +6 -0
- package/dist/components/mfa/TotpEnrollPanel.js +117 -0
- package/dist/edge/middleware.d.ts +1 -1
- package/dist/edge/middleware.js +2 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -1
- package/dist/lib/credential-sign-in-client.d.ts +26 -1
- package/dist/lib/credential-sign-in-client.js +32 -6
- package/dist/lib/ensure-fresh-access-token.js +15 -10
- package/dist/lib/idp-client-config.js +4 -1
- package/dist/lib/login-error-message.d.ts +3 -0
- package/dist/lib/login-error-message.js +12 -1
- package/dist/lib/mfa-json-guard.d.ts +12 -0
- package/dist/lib/mfa-json-guard.js +19 -0
- package/dist/lib/nextauth-secret.js +3 -1
- package/dist/lib/payez-client-secret.d.ts +14 -0
- package/dist/lib/payez-client-secret.js +41 -0
- package/dist/lib/qrcode.d.ts +22 -0
- package/dist/lib/qrcode.js +362 -0
- package/dist/lib/sign-in-failure.d.ts +35 -0
- package/dist/lib/sign-in-failure.js +85 -0
- package/dist/lib/twofa-rejection.d.ts +2 -0
- package/dist/lib/twofa-rejection.js +5 -2
- package/dist/middleware/create-middleware.d.ts +24 -0
- package/dist/middleware/create-middleware.js +56 -9
- package/dist/middleware/prefetch-guard-status.d.ts +21 -0
- package/dist/middleware/prefetch-guard-status.js +42 -0
- package/dist/pages/enroll/page.d.ts +7 -0
- package/dist/pages/enroll/page.js +15 -0
- package/dist/pages/login/page.js +1 -1
- package/dist/routes/auth/session-reset.d.ts +11 -1
- package/dist/routes/auth/session-reset.js +38 -13
- package/dist/routes/auth/viability.js +11 -2
- package/package.json +11 -1
- package/src/api-handlers/auth/credential-login.ts +29 -4
- package/src/api-handlers/mfa/totp-confirm.ts +7 -4
- package/src/api-handlers/mfa/totp-enroll.ts +7 -4
- package/src/api-handlers/session/viability.ts +6 -2
- package/src/auth/better-auth.ts +27 -2
- package/src/auth/session-reset-link.ts +47 -0
- package/src/components/auth/CredentialSignInForm.tsx +21 -3
- package/src/components/auth/EnrollAuthenticatorOffer.tsx +48 -0
- package/src/components/mfa/QrCode.tsx +24 -0
- package/src/components/mfa/TotpEnrollPanel.tsx +203 -0
- package/src/edge/middleware.ts +1 -1
- package/src/index.ts +1 -1
- package/src/lib/credential-sign-in-client.ts +48 -8
- package/src/lib/ensure-fresh-access-token.ts +15 -10
- package/src/lib/idp-client-config.ts +5 -1
- package/src/lib/login-error-message.ts +13 -1
- package/src/lib/mfa-json-guard.ts +19 -0
- package/src/lib/nextauth-secret.ts +3 -1
- package/src/lib/payez-client-secret.ts +39 -0
- package/src/lib/qrcode.ts +348 -0
- package/src/lib/sign-in-failure.ts +88 -0
- package/src/lib/twofa-rejection.ts +7 -2
- package/src/middleware/create-middleware.ts +69 -9
- package/src/middleware/prefetch-guard-status.ts +56 -0
- package/src/pages/enroll/page.tsx +22 -0
- package/src/pages/login/page.tsx +2 -1
- package/src/routes/auth/session-reset.ts +46 -11
- package/src/routes/auth/viability.ts +10 -2
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAY-2218 P1 (BAPert 86123, reworded 86209): say, once, at middleware creation, that the HEADER-based router-prefetch guard cannot see router prefetches
|
|
3
|
+
* on this Next version. After P2 the session-cookie clear no longer depends on it (it keys on Sec-Fetch-Mode/Dest), so this is information, not a call to
|
|
4
|
+
* migrate. No behaviour change: this only logs.
|
|
5
|
+
*
|
|
6
|
+
* MEASURED (adapter.js read in each version's own node_modules): Next 15.5.9 and 16.1.5 delete the flight headers (rsc, next-router-prefetch, ...)
|
|
7
|
+
* from the request middleware sees UNCONDITIONALLY, and 15.5.9 also strips `_rsc` from the URL, so isPrefetchRequest() is false for every router
|
|
8
|
+
* prefetch and no config can change that. Next 16.2.x skips the strip when `skipProxyUrlNormalize: true`.
|
|
9
|
+
*
|
|
10
|
+
* NOT warned, on purpose: Next >= 16.2 (the flag is a build-time define that user middleware code cannot reliably read back, so a warning there
|
|
11
|
+
* would be a guess) and Next 14 (not measured). Unparseable or missing version: silent.
|
|
12
|
+
*/
|
|
13
|
+
export type PrefetchGuardStatus = {
|
|
14
|
+
dead: boolean;
|
|
15
|
+
reason: string;
|
|
16
|
+
};
|
|
17
|
+
export declare function prefetchGuardStatus(nextVersion: string | undefined | null): PrefetchGuardStatus;
|
|
18
|
+
/** Logs PAYEZ_PREFETCH_GUARD_DEAD once per process when the guard is dead on this Next version. Returns true when it logged. */
|
|
19
|
+
export declare function warnPrefetchGuardDeadOnce(warn: (message: string, data?: unknown) => void, nextVersion?: string | undefined | null): boolean;
|
|
20
|
+
/** Test seam: forget that the warning was logged. */
|
|
21
|
+
export declare function resetPrefetchGuardWarningForTests(): void;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.prefetchGuardStatus = prefetchGuardStatus;
|
|
4
|
+
exports.warnPrefetchGuardDeadOnce = warnPrefetchGuardDeadOnce;
|
|
5
|
+
exports.resetPrefetchGuardWarningForTests = resetPrefetchGuardWarningForTests;
|
|
6
|
+
function prefetchGuardStatus(nextVersion) {
|
|
7
|
+
const m = /^(\d+)\.(\d+)\./.exec(String(nextVersion ?? '').trim());
|
|
8
|
+
if (!m)
|
|
9
|
+
return { dead: false, reason: 'unknown version' };
|
|
10
|
+
const major = Number(m[1]);
|
|
11
|
+
const minor = Number(m[2]);
|
|
12
|
+
if (major === 15 || (major === 16 && minor < 2)) {
|
|
13
|
+
return { dead: true, reason: 'strips the router-prefetch headers (rsc, next-router-prefetch) before middleware runs and has no opt-out' };
|
|
14
|
+
}
|
|
15
|
+
return { dead: false, reason: 'not measured as dead' };
|
|
16
|
+
}
|
|
17
|
+
function detectNextVersion() {
|
|
18
|
+
try {
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
20
|
+
return require('next/package.json').version;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
let warned = false;
|
|
27
|
+
/** Logs PAYEZ_PREFETCH_GUARD_DEAD once per process when the guard is dead on this Next version. Returns true when it logged. */
|
|
28
|
+
function warnPrefetchGuardDeadOnce(warn, nextVersion = detectNextVersion()) {
|
|
29
|
+
if (warned)
|
|
30
|
+
return false;
|
|
31
|
+
const s = prefetchGuardStatus(nextVersion);
|
|
32
|
+
if (!s.dead)
|
|
33
|
+
return false;
|
|
34
|
+
warned = true;
|
|
35
|
+
warn(`PAYEZ_PREFETCH_GUARD_DEAD next=${nextVersion}: this Next version ${s.reason}, so isPrefetchRequest() cannot see a router prefetch. ` +
|
|
36
|
+
"The session-cookie clear relies on the browser's Sec-Fetch-Mode/Dest instead (PAY-2218 P2); non-browser clients and browsers that send no Sec-Fetch headers are not covered.");
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
/** Test seam: forget that the warning was logged. */
|
|
40
|
+
function resetPrefetchGuardWarningForTests() {
|
|
41
|
+
warned = false;
|
|
42
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAY-2631: /account-auth/enroll in a generated app. The page SHELL is public (every /account-auth/* path is public by default);
|
|
3
|
+
* what needs a session is each action: enrol and confirm answer 401 without one and the panel then says to sign in (measured on a
|
|
4
|
+
* scaffolded app: POST enrol with no cookie -> 401, no secret in the body). The scaffold lists the path on the 2FA bypass list
|
|
5
|
+
* because public routes still redirect a 2FA-incomplete session to verify-code, which sends enroll_mfa users back here: a loop.
|
|
6
|
+
*/
|
|
7
|
+
export default function EnrollPage(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
'use client';
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.default = EnrollPage;
|
|
5
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
6
|
+
const TotpEnrollPanel_1 = require("../../components/mfa/TotpEnrollPanel");
|
|
7
|
+
/**
|
|
8
|
+
* PAY-2631: /account-auth/enroll in a generated app. The page SHELL is public (every /account-auth/* path is public by default);
|
|
9
|
+
* what needs a session is each action: enrol and confirm answer 401 without one and the panel then says to sign in (measured on a
|
|
10
|
+
* scaffolded app: POST enrol with no cookie -> 401, no secret in the body). The scaffold lists the path on the 2FA bypass list
|
|
11
|
+
* because public routes still redirect a 2FA-incomplete session to verify-code, which sends enroll_mfa users back here: a loop.
|
|
12
|
+
*/
|
|
13
|
+
function EnrollPage() {
|
|
14
|
+
return ((0, jsx_runtime_1.jsxs)("main", { style: { maxWidth: 480, margin: '48px auto', padding: '0 16px' }, children: [(0, jsx_runtime_1.jsx)("h1", { children: "Set up an authenticator app" }), (0, jsx_runtime_1.jsx)(TotpEnrollPanel_1.TotpEnrollPanel, {}), (0, jsx_runtime_1.jsx)("p", { style: { marginTop: 24 }, children: (0, jsx_runtime_1.jsx)("a", { href: "/", children: "Continue to the app" }) })] }));
|
|
15
|
+
}
|
package/dist/pages/login/page.js
CHANGED
|
@@ -180,7 +180,7 @@ function LoginForm({ altAuthSlot }) {
|
|
|
180
180
|
.themed-status-submitting svg {
|
|
181
181
|
color: ${buttonColor} !important;
|
|
182
182
|
}
|
|
183
|
-
` } }), (0, jsx_runtime_1.jsx)("div", { className: `min-h-screen flex items-center justify-center px-4 ${colors.background}`, children: (0, jsx_runtime_1.jsx)("div", { className: "max-w-md w-full", children: (0, jsx_runtime_1.jsxs)("div", { className: `rounded-2xl shadow-xl p-8 ${colors.card} ${colors.border} text-gray-800`, children: [(0, jsx_runtime_1.jsxs)("div", { className: "text-center mb-8", children: [(0, jsx_runtime_1.jsx)("h1", { className: "text-3xl font-bold mb-2", style: { color: 'inherit' }, children: branding.tagline || 'Welcome Back' }), (0, jsx_runtime_1.jsx)("p", { className: colors.muted, children: "Sign in to your account" })] }), shownError && ((0, jsx_runtime_1.jsx)("div", { role: "alert", "data-testid": "login-error", className: "mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700", children: shownError })), googleEnabled && ((0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: (0, jsx_runtime_1.jsxs)("button", { type: "button", onClick: () => void handleGoogleSignIn(), disabled: googleLoading || isSubmitting, "data-testid": "google-sign-in", className: "w-full px-4 py-3 mb-6 rounded-lg border border-gray-300 font-semibold transition-colors flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50", style: { backgroundColor: 'white', color: '#1f2937' }, children: [googleLoading ? ((0, jsx_runtime_1.jsxs)("svg", { className: "animate-spin w-5 h-5", fill: "none", viewBox: "0 0 24 24", children: [(0, jsx_runtime_1.jsx)("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), (0, jsx_runtime_1.jsx)("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" })] })) : ((0, jsx_runtime_1.jsxs)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", children: [(0, jsx_runtime_1.jsx)("path", { d: "M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z", fill: "#4285F4" }), (0, jsx_runtime_1.jsx)("path", { d: "M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z", fill: "#34A853" }), (0, jsx_runtime_1.jsx)("path", { d: "M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z", fill: "#FBBC05" }), (0, jsx_runtime_1.jsx)("path", { d: "M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z", fill: "#EA4335" })] })), googleLoading ? 'Redirecting to Google…' : 'Continue with Google'] }) })), altAuthSlot && (0, jsx_runtime_1.jsx)("div", { className: "mt-4", children: altAuthSlot }), (0, jsx_runtime_1.jsx)("div", { className: `mt-6 text-center text-sm ${colors.muted}`, children: (0, jsx_runtime_1.jsx)("a", { href: "/account-auth/recovery", className: "themed-link hover:underline", children: "Start account recovery" }) })] }) }) })] }));
|
|
183
|
+
` } }), (0, jsx_runtime_1.jsx)("div", { className: `min-h-screen flex items-center justify-center px-4 ${colors.background}`, children: (0, jsx_runtime_1.jsx)("div", { className: "max-w-md w-full", children: (0, jsx_runtime_1.jsxs)("div", { className: `rounded-2xl shadow-xl p-8 ${colors.card} ${colors.border} text-gray-800`, children: [(0, jsx_runtime_1.jsxs)("div", { className: "text-center mb-8", children: [(0, jsx_runtime_1.jsx)("h1", { className: "text-3xl font-bold mb-2", style: { color: 'inherit' }, children: branding.tagline || 'Welcome Back' }), (0, jsx_runtime_1.jsx)("p", { className: colors.muted, children: "Sign in to your account" })] }), shownError && ((0, jsx_runtime_1.jsx)("div", { role: "alert", "data-testid": "login-error", "data-error-code": loginError === undefined ? ((0, login_error_message_1.loginErrorCode)(urlError) ?? undefined) : undefined, className: "mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700", children: shownError })), googleEnabled && ((0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: (0, jsx_runtime_1.jsxs)("button", { type: "button", onClick: () => void handleGoogleSignIn(), disabled: googleLoading || isSubmitting, "data-testid": "google-sign-in", className: "w-full px-4 py-3 mb-6 rounded-lg border border-gray-300 font-semibold transition-colors flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50", style: { backgroundColor: 'white', color: '#1f2937' }, children: [googleLoading ? ((0, jsx_runtime_1.jsxs)("svg", { className: "animate-spin w-5 h-5", fill: "none", viewBox: "0 0 24 24", children: [(0, jsx_runtime_1.jsx)("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), (0, jsx_runtime_1.jsx)("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" })] })) : ((0, jsx_runtime_1.jsxs)("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", children: [(0, jsx_runtime_1.jsx)("path", { d: "M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z", fill: "#4285F4" }), (0, jsx_runtime_1.jsx)("path", { d: "M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z", fill: "#34A853" }), (0, jsx_runtime_1.jsx)("path", { d: "M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z", fill: "#FBBC05" }), (0, jsx_runtime_1.jsx)("path", { d: "M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z", fill: "#EA4335" })] })), googleLoading ? 'Redirecting to Google…' : 'Continue with Google'] }) })), altAuthSlot && (0, jsx_runtime_1.jsx)("div", { className: "mt-4", children: altAuthSlot }), (0, jsx_runtime_1.jsx)("div", { className: `mt-6 text-center text-sm ${colors.muted}`, children: (0, jsx_runtime_1.jsx)("a", { href: "/account-auth/recovery", className: "themed-link hover:underline", children: "Start account recovery" }) })] }) }) })] }));
|
|
184
184
|
}
|
|
185
185
|
function LoginPageFallback() {
|
|
186
186
|
const colors = (0, useTheme_1.useColors)();
|
|
@@ -18,4 +18,14 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { NextRequest, NextResponse } from 'next/server';
|
|
20
20
|
export declare const dynamic = "force-dynamic";
|
|
21
|
-
export
|
|
21
|
+
export interface SessionResetOptions {
|
|
22
|
+
/**
|
|
23
|
+
* PAY-2218 opt-in, OFF by default (BAPert 83102). OFF: only a link signed with the env secret (BETTER_AUTH_SECRET / NEXTAUTH_SECRET) clears, so every app that can
|
|
24
|
+
* see the secret stays strict. ON: a link signed with the key derived from this request's own session cookie also clears. Turn it on only where the EDGE middleware
|
|
25
|
+
* cannot see the env secret, and set `sessionResetCookieKey: true` on createMvpMiddleware too, or the route refuses the link the middleware signed.
|
|
26
|
+
*/
|
|
27
|
+
cookieKey?: boolean;
|
|
28
|
+
}
|
|
29
|
+
export declare function createSessionResetHandler(options?: SessionResetOptions): (request: NextRequest) => Promise<NextResponse>;
|
|
30
|
+
/** The strict default: env-signed links only. An app that opted in uses createSessionResetHandler({ cookieKey: true }). */
|
|
31
|
+
export declare const GET: (request: NextRequest) => Promise<NextResponse>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.dynamic = void 0;
|
|
4
|
-
exports.
|
|
3
|
+
exports.GET = exports.dynamic = void 0;
|
|
4
|
+
exports.createSessionResetHandler = createSessionResetHandler;
|
|
5
5
|
/**
|
|
6
6
|
* PAY-2218 PR 2: the session-reset route. Mount it at app/api/auth/session-reset/route.ts:
|
|
7
7
|
*
|
|
@@ -26,15 +26,40 @@ const session_cookie_clear_1 = require("../../auth/session-cookie-clear");
|
|
|
26
26
|
const app_slug_1 = require("../../lib/app-slug");
|
|
27
27
|
const session_reset_link_1 = require("../../auth/session-reset-link");
|
|
28
28
|
exports.dynamic = 'force-dynamic';
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
(0,
|
|
39
|
-
|
|
29
|
+
function createSessionResetHandler(options = {}) {
|
|
30
|
+
const cookieKey = options.cookieKey === true;
|
|
31
|
+
let splitLogged = false;
|
|
32
|
+
let halfSetLogged = false;
|
|
33
|
+
return async function GET(request) {
|
|
34
|
+
if ((0, create_middleware_1.isPrefetchRequest)(request)) {
|
|
35
|
+
return new server_1.NextResponse(null, { status: 204, headers: { 'cache-control': 'no-store' } });
|
|
36
|
+
}
|
|
37
|
+
const bound = (0, session_reset_link_1.boundSessionCookie)((n) => request.cookies.get(n)?.value, (0, app_slug_1.getSessionCookieName)(), (0, app_slug_1.getSecureSessionCookieName)());
|
|
38
|
+
const env = (0, session_reset_link_1.resetSecret)();
|
|
39
|
+
const { params, signed, via } = cookieKey
|
|
40
|
+
? await (0, session_reset_link_1.readResetRequestAnyKey)(request.nextUrl.searchParams, env, bound)
|
|
41
|
+
: { ...(await (0, session_reset_link_1.readResetRequest)(request.nextUrl.searchParams, env, bound)), via: null };
|
|
42
|
+
// The dev-93 prefetch walk reads this line: a link that verified ONLY with the cookie-derived key while this route sees the env secret is the
|
|
43
|
+
// edge-blind / route-sighted split deployment. Once per process, no values.
|
|
44
|
+
if (cookieKey && signed && via === 'cookie' && env && !splitLogged) {
|
|
45
|
+
splitLogged = true;
|
|
46
|
+
console.info('[next-mvp] session-reset: a link verified with the cookie-derived key while the env secret is visible to this route (the edge middleware signed without it: split deployment)');
|
|
47
|
+
}
|
|
48
|
+
// HALF-SET (NightHawk 83161): this route is strict (cookieKey OFF) but the middleware signed with the cookie-derived key (sessionResetCookieKey ON). The link is refused
|
|
49
|
+
// and the stale cookie survives. Probe ONLY refused links, for the cookie-derived key alone, and say so once per process. No values.
|
|
50
|
+
if (!cookieKey && !signed && !halfSetLogged) {
|
|
51
|
+
const probe = await (0, session_reset_link_1.readResetRequestAnyKey)(request.nextUrl.searchParams, undefined, bound);
|
|
52
|
+
if (probe.signed) {
|
|
53
|
+
halfSetLogged = true;
|
|
54
|
+
console.warn('[next-mvp] session-reset: refused a link signed with the cookie-derived key because this route has cookieKey OFF; the middleware has sessionResetCookieKey ON. Set createSessionResetHandler({ cookieKey: true }) or turn the middleware option off');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const response = server_1.NextResponse.redirect(new URL((0, session_reset_link_1.loginLocation)(params), request.url), 307);
|
|
58
|
+
response.headers.set('cache-control', 'no-store');
|
|
59
|
+
if (signed)
|
|
60
|
+
(0, session_cookie_clear_1.expireSessionCookies)(response, Array.from(request.cookies, ([name]) => name));
|
|
61
|
+
return response;
|
|
62
|
+
};
|
|
40
63
|
}
|
|
64
|
+
/** The strict default: env-signed links only. An app that opted in uses createSessionResetHandler({ cookieKey: true }). */
|
|
65
|
+
exports.GET = createSessionResetHandler();
|
|
@@ -187,6 +187,9 @@ async function GET(req) {
|
|
|
187
187
|
// stack just resolved the cookie cleanly. Keeps the two viability impls
|
|
188
188
|
// (this route + `api-handlers/session/viability.ts`) in lockstep.
|
|
189
189
|
let session = sessionToken ? await (0, session_store_1.getSession)(sessionToken) : null;
|
|
190
|
+
// PAY-2467: a row read from the CANONICAL store is the raw JSON the callback wrote, so an `mfaVerified` boolean on it is a
|
|
191
|
+
// MEASUREMENT (an absent key is a legacy row). Only the BA-record fallback collapses absent to false and needs mfaSignalPresent.
|
|
192
|
+
const fromCanonicalStore = !!session;
|
|
190
193
|
if (sessionToken && !session) {
|
|
191
194
|
session = await (0, session_store_1.getBetterAuthSession)(sessionToken);
|
|
192
195
|
if (session) {
|
|
@@ -278,9 +281,15 @@ async function GET(req) {
|
|
|
278
281
|
* middleware actually gates on; `tenantRequiresTwoFactor` stays pure —
|
|
279
282
|
* it names TENANT policy, and this override is not tenant state.
|
|
280
283
|
*/
|
|
281
|
-
const oauthSessionNeedsTwoFactor =
|
|
284
|
+
const oauthSessionNeedsTwoFactor =
|
|
285
|
+
// PAY-2467: the signal is present on the BA-fallback path (mfaSignalPresent) OR when the canonical row itself carries a boolean.
|
|
286
|
+
// Before this, the override was dead on the canonical path every consumer mounts, so gating rested on tenant config alone.
|
|
287
|
+
(session?.mfaSignalPresent === true || (fromCanonicalStore && typeof session?.mfaVerified === 'boolean')) &&
|
|
282
288
|
session?.mfaVerified === false &&
|
|
283
|
-
!(session.idpRefreshToken || session?.refreshToken)
|
|
289
|
+
!(session.idpRefreshToken || session?.refreshToken) &&
|
|
290
|
+
// An IdP DISCARD (refresh.ts / ensure-fresh-access-token.ts write idpRefreshToken '' with refreshTokenClearedReason 'IDP_DISCARD_TOKEN:<code>') leaves
|
|
291
|
+
// mfaVerified false and no refresh token too, but that is a DEAD session, not a pending second factor: it must not be sent to verify-code (QAPert 84816).
|
|
292
|
+
!String(session?.refreshTokenClearedReason ?? '').startsWith('IDP_DISCARD_TOKEN');
|
|
284
293
|
// --- Session revocation status (PAY-1882 step 4 producer half) ---
|
|
285
294
|
/*
|
|
286
295
|
* Resolve the four-state revocation status for this user (BAPert 66320).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@payez/next-mvp",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.13.0",
|
|
4
4
|
"sideEffects": false,
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -1084,6 +1084,16 @@
|
|
|
1084
1084
|
"types": "./dist/lib/credential-sign-in-client.d.ts",
|
|
1085
1085
|
"require": "./dist/lib/credential-sign-in-client.js",
|
|
1086
1086
|
"default": "./dist/lib/credential-sign-in-client.js"
|
|
1087
|
+
},
|
|
1088
|
+
"./components/mfa/TotpEnrollPanel": {
|
|
1089
|
+
"types": "./dist/components/mfa/TotpEnrollPanel.d.ts",
|
|
1090
|
+
"require": "./dist/components/mfa/TotpEnrollPanel.js",
|
|
1091
|
+
"default": "./dist/components/mfa/TotpEnrollPanel.js"
|
|
1092
|
+
},
|
|
1093
|
+
"./dist/pages/enroll": {
|
|
1094
|
+
"types": "./dist/pages/enroll/page.d.ts",
|
|
1095
|
+
"require": "./dist/pages/enroll/page.js",
|
|
1096
|
+
"default": "./dist/pages/enroll/page.js"
|
|
1087
1097
|
}
|
|
1088
1098
|
},
|
|
1089
1099
|
"peerDependencies": {
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
* lib/session-store.js").
|
|
39
39
|
*/
|
|
40
40
|
import { NextRequest, NextResponse } from 'next/server';
|
|
41
|
+
import { capPolicyDetail, classifySignInFailure, signInFailureMessage, type SignInFailureCode } from '../../lib/sign-in-failure';
|
|
41
42
|
import { SignJWT } from 'jose';
|
|
42
43
|
import { getIDPClientConfig } from '../../lib/idp-client-config';
|
|
43
44
|
import { resolveClientKey } from '../../lib/client-key';
|
|
@@ -325,10 +326,15 @@ export async function POST(req: NextRequest) {
|
|
|
325
326
|
// (MfaController.VerifyTotp answers a 15-minute lockout as 400 {code:"locked"}), which status-only
|
|
326
327
|
// classification told to "try again".
|
|
327
328
|
const rejection = classifyTwofaRejection(laneResults, idpResponse.headers.get('retry-after'));
|
|
329
|
+
// PAY-2568 follow-up (BAPert 85795): the page takes its sentence from a CODE in the closed table, so this lane must send one.
|
|
330
|
+
// The branch already decided the cause (classifyTwofaRejection); the code follows its status, the IdP's wording is never used.
|
|
331
|
+
const rejectionCode: SignInFailureCode =
|
|
332
|
+
rejection.status === 429 ? (rejection.rateLimited ? 'second_factor_rate_limited' : 'too_many_attempts') : rejection.status === 502 ? 'second_factor_unavailable' : 'invalid_second_factor';
|
|
328
333
|
return NextResponse.json(
|
|
329
334
|
{
|
|
330
335
|
success: false,
|
|
331
|
-
|
|
336
|
+
code: rejectionCode,
|
|
337
|
+
error: signInFailureMessage(rejectionCode),
|
|
332
338
|
...(rejection.retryAfter !== undefined ? { retryAfter: rejection.retryAfter } : {}),
|
|
333
339
|
// PAY-2284: the card shows a real "email me a code" action only when this is true.
|
|
334
340
|
...(rejection.offerEmailCode ? { offerEmailCode: true } : {}),
|
|
@@ -389,19 +395,25 @@ export async function POST(req: NextRequest) {
|
|
|
389
395
|
if (idpResponse.status === 429) {
|
|
390
396
|
const retryAfter = idpResponse.headers.get('retry-after');
|
|
391
397
|
return NextResponse.json(
|
|
392
|
-
{
|
|
398
|
+
{
|
|
399
|
+
success: false,
|
|
400
|
+
code: 'too_many_attempts',
|
|
401
|
+
error: signInFailureMessage('too_many_attempts'),
|
|
402
|
+
// BAPert 85807: the page prints this number as minutes, so only a real Retry-After is sent; no invented default.
|
|
403
|
+
...(retryAfter && Number.isFinite(parseInt(retryAfter, 10)) && parseInt(retryAfter, 10) > 0 ? { retryAfter: parseInt(retryAfter, 10) } : {}),
|
|
404
|
+
},
|
|
393
405
|
{ status: 429 }
|
|
394
406
|
);
|
|
395
407
|
}
|
|
396
408
|
|
|
397
409
|
if (!idpResponse.ok) {
|
|
398
410
|
const f = logIdpAuthRefusal('non-ok', idpResponse.status, data);
|
|
399
|
-
return NextResponse.json(
|
|
411
|
+
return NextResponse.json(refusalBody(f, idpResponse.status), { status: idpResponse.status });
|
|
400
412
|
}
|
|
401
413
|
|
|
402
414
|
if (data?.success === false) {
|
|
403
415
|
const f = logIdpAuthRefusal('envelope-success-false', idpResponse.status, data);
|
|
404
|
-
return NextResponse.json(
|
|
416
|
+
return NextResponse.json(refusalBody(f, idpResponse.status), { status: 200 });
|
|
405
417
|
}
|
|
406
418
|
|
|
407
419
|
// Unwrap the IDP's canonical envelope, same shape login.js normalizes.
|
|
@@ -761,6 +773,19 @@ function publicRefusalMessage(f: IdpFailure): string {
|
|
|
761
773
|
return UNIFORM_REFUSAL;
|
|
762
774
|
}
|
|
763
775
|
|
|
776
|
+
/**
|
|
777
|
+
* The browser-facing body of a refusal (BAPert 85415): a stable machine `code` from the closed table in lib/sign-in-failure, and
|
|
778
|
+
* the fixed sentence for it. The IdP's own message text is never copied in. The one exception is the allowlisted policy refusals
|
|
779
|
+
* (SsoRequired / SsoRequiredBreakGlass / InvalidRequest): a property of the email domain or the request, not of an account, and the
|
|
780
|
+
* user cannot proceed without being told; they carry code 'policy_message' and the allowlisted detail (see SAFE_TO_SHOW).
|
|
781
|
+
*/
|
|
782
|
+
function refusalBody(f: IdpFailure, status: number): { success: false; code: string; error: string } {
|
|
783
|
+
const policy = f.code && SAFE_TO_SHOW.has(f.code) ? capPolicyDetail(f.detail) : null;
|
|
784
|
+
if (policy) return { success: false, code: 'policy_message', error: policy };
|
|
785
|
+
const code = classifySignInFailure(status, f.code);
|
|
786
|
+
return { success: false, code, error: signInFailureMessage(code) as string };
|
|
787
|
+
}
|
|
788
|
+
|
|
764
789
|
function logIdpAuthRefusal(branch: string, httpStatus: number, data: unknown): IdpFailure {
|
|
765
790
|
const f = extractIdpFailure(data);
|
|
766
791
|
const d = (data ?? {}) as Record<string, unknown>;
|
|
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|
|
2
2
|
import { idpFetchJSON } from '../../lib/idp-fetch';
|
|
3
3
|
import { ENV_CONFIG } from '../../config/env';
|
|
4
4
|
import { projectIdpError } from '../../lib/safe-error-response';
|
|
5
|
+
import { NO_STORE, refuseNonJson } from '../../lib/mfa-json-guard';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Confirm TOTP enrolment with the first authenticator code
|
|
@@ -10,6 +11,8 @@ import { projectIdpError } from '../../lib/safe-error-response';
|
|
|
10
11
|
* the method ACTIVE.
|
|
11
12
|
*/
|
|
12
13
|
export async function POST(req: NextRequest) {
|
|
14
|
+
const refused = refuseNonJson(req);
|
|
15
|
+
if (refused) return refused;
|
|
13
16
|
let body = '{}';
|
|
14
17
|
try {
|
|
15
18
|
const raw = await req.text();
|
|
@@ -30,14 +33,14 @@ export async function POST(req: NextRequest) {
|
|
|
30
33
|
error: { code: 'UPSTREAM_SERVICE_ERROR', status: result.status, details: projectIdpError(result.json) },
|
|
31
34
|
meta: { attemptedRefresh: result.attemptedRefresh },
|
|
32
35
|
},
|
|
33
|
-
{ status: result.status },
|
|
36
|
+
{ status: result.status, headers: NO_STORE },
|
|
34
37
|
);
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
const bodyJson: any = result.json;
|
|
38
41
|
if (bodyJson && typeof bodyJson === 'object' && 'success' in bodyJson && 'data' in bodyJson) {
|
|
39
|
-
if (bodyJson.success === true) return NextResponse.json(bodyJson.data, { status: 200 });
|
|
40
|
-
return NextResponse.json(bodyJson, { status: 200 });
|
|
42
|
+
if (bodyJson.success === true) return NextResponse.json(bodyJson.data, { status: 200, headers: NO_STORE });
|
|
43
|
+
return NextResponse.json(bodyJson, { status: 200, headers: NO_STORE });
|
|
41
44
|
}
|
|
42
|
-
return NextResponse.json(bodyJson ?? {}, { status: 200 });
|
|
45
|
+
return NextResponse.json(bodyJson ?? {}, { status: 200, headers: NO_STORE });
|
|
43
46
|
}
|
|
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|
|
2
2
|
import { idpFetchJSON } from '../../lib/idp-fetch';
|
|
3
3
|
import { ENV_CONFIG } from '../../config/env';
|
|
4
4
|
import { projectIdpError } from '../../lib/safe-error-response';
|
|
5
|
+
import { NO_STORE, refuseNonJson } from '../../lib/mfa-json-guard';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Begin TOTP enrolment (POST /api/Account/mfa/totp/enroll, user JWT).
|
|
@@ -10,6 +11,8 @@ import { projectIdpError } from '../../lib/safe-error-response';
|
|
|
10
11
|
* as "Active" (MFA honesty fixes, 2026-08-01).
|
|
11
12
|
*/
|
|
12
13
|
export async function POST(req: NextRequest) {
|
|
14
|
+
const refused = refuseNonJson(req);
|
|
15
|
+
if (refused) return refused;
|
|
13
16
|
let body = '{}';
|
|
14
17
|
try {
|
|
15
18
|
const raw = await req.text();
|
|
@@ -30,14 +33,14 @@ export async function POST(req: NextRequest) {
|
|
|
30
33
|
error: { code: 'UPSTREAM_SERVICE_ERROR', status: result.status, details: projectIdpError(result.json) },
|
|
31
34
|
meta: { attemptedRefresh: result.attemptedRefresh },
|
|
32
35
|
},
|
|
33
|
-
{ status: result.status },
|
|
36
|
+
{ status: result.status, headers: NO_STORE },
|
|
34
37
|
);
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
const bodyJson: any = result.json;
|
|
38
41
|
if (bodyJson && typeof bodyJson === 'object' && 'success' in bodyJson && 'data' in bodyJson) {
|
|
39
|
-
if (bodyJson.success === true) return NextResponse.json(bodyJson.data, { status: 200 });
|
|
40
|
-
return NextResponse.json(bodyJson, { status: 200 });
|
|
42
|
+
if (bodyJson.success === true) return NextResponse.json(bodyJson.data, { status: 200, headers: NO_STORE });
|
|
43
|
+
return NextResponse.json(bodyJson, { status: 200, headers: NO_STORE });
|
|
41
44
|
}
|
|
42
|
-
return NextResponse.json(bodyJson ?? {}, { status: 200 });
|
|
45
|
+
return NextResponse.json(bodyJson ?? {}, { status: 200, headers: NO_STORE });
|
|
43
46
|
}
|
|
@@ -76,6 +76,8 @@ export async function GET(req: NextRequest) {
|
|
|
76
76
|
if (betterAuthSession && sessionToken) {
|
|
77
77
|
// Try legacy session store first, then Better Auth format
|
|
78
78
|
let sessionData = await getRedisSession(sessionToken);
|
|
79
|
+
// PAY-2467: see routes/auth/viability.ts - a boolean mfaVerified on the raw canonical row is a measurement.
|
|
80
|
+
const fromCanonicalStore = !!sessionData;
|
|
79
81
|
if (!sessionData) {
|
|
80
82
|
// Better Auth stores sessions with ba:{appSlug}:{token} prefix
|
|
81
83
|
sessionData = await getBetterAuthSession(sessionToken);
|
|
@@ -127,9 +129,11 @@ export async function GET(req: NextRequest) {
|
|
|
127
129
|
// keep gating on `mfaVerified`, which stays fail-closed at all five sites. R807 is
|
|
128
130
|
// void if this signal reaches an auth decision.
|
|
129
131
|
if (
|
|
130
|
-
sessionData.mfaSignalPresent === true &&
|
|
132
|
+
(sessionData.mfaSignalPresent === true || (fromCanonicalStore && typeof sessionData.mfaVerified === 'boolean')) &&
|
|
131
133
|
sessionData.mfaVerified === false &&
|
|
132
|
-
!sessionData.idpRefreshToken
|
|
134
|
+
!sessionData.idpRefreshToken &&
|
|
135
|
+
// same exception as routes/auth/viability: an IdP-discarded refresh token is a dead session, not a pending second factor
|
|
136
|
+
!String((sessionData as any)?.refreshTokenClearedReason ?? '').startsWith('IDP_DISCARD_TOKEN')
|
|
133
137
|
) {
|
|
134
138
|
requires2FA = true;
|
|
135
139
|
}
|
package/src/auth/better-auth.ts
CHANGED
|
@@ -1563,6 +1563,20 @@ async function abortOAuthSignIn(
|
|
|
1563
1563
|
return new Response(null, { status: 302, headers });
|
|
1564
1564
|
}
|
|
1565
1565
|
|
|
1566
|
+
/**
|
|
1567
|
+
* PAY-2568: the Better Auth error code of an /api/auth/error redirect (`?error=<code>`), or null when absent or not a plain
|
|
1568
|
+
* token. The value rides a URL, so only a short [A-Za-z0-9_.-] token is ever returned for logging. Location may be relative
|
|
1569
|
+
* or absolute; the base is a placeholder used only for parsing.
|
|
1570
|
+
*/
|
|
1571
|
+
export function authErrorCodeFromLocation(location: string): string | null {
|
|
1572
|
+
try {
|
|
1573
|
+
const code = new URL(location, 'http://placeholder.invalid').searchParams.get('error');
|
|
1574
|
+
return code && /^[A-Za-z0-9_.-]{1,64}$/.test(code) ? code : null;
|
|
1575
|
+
} catch {
|
|
1576
|
+
return null;
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1566
1580
|
/**
|
|
1567
1581
|
* Create a production-ready GET handler for the auth catch-all route.
|
|
1568
1582
|
*
|
|
@@ -1600,7 +1614,15 @@ export function createAuthGetHandler(loginPath: string = '/account-auth/login')
|
|
|
1600
1614
|
if (response.status === 302) {
|
|
1601
1615
|
const location = response.headers.get('location') || '';
|
|
1602
1616
|
if (location.includes('/api/auth/error') || location.includes('please_restart')) {
|
|
1603
|
-
|
|
1617
|
+
// PAY-2568: log WHICH error. Better Auth routes every callback failure to /api/auth/error?error=<code>
|
|
1618
|
+
// (signup_disabled, state_mismatch, ...); the old fixed 'OAuth state error' sentence made distinct faults
|
|
1619
|
+
// look identical. Only the code is logged - never the Location (it can carry other params).
|
|
1620
|
+
const authErrorCode = authErrorCodeFromLocation(location);
|
|
1621
|
+
if (authErrorCode) {
|
|
1622
|
+
console.warn('[BETTER_AUTH] auth error code=' + authErrorCode + ', redirecting to login');
|
|
1623
|
+
} else {
|
|
1624
|
+
console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
|
|
1625
|
+
}
|
|
1604
1626
|
// *** DO NOT "TIDY" THIS BACK TO Response.redirect(). IT THROWS. (328439) ***
|
|
1605
1627
|
// Response.redirect() REQUIRES an absolute URL — measured, on the leg's own
|
|
1606
1628
|
// runtime and again on node 24:
|
|
@@ -1637,7 +1659,10 @@ export function createAuthGetHandler(loginPath: string = '/account-auth/login')
|
|
|
1637
1659
|
// Works whether loginPath is relative (package default '/account-auth/login',
|
|
1638
1660
|
// and the graft passes '/sign-in') or absolute — an absolute Location is also
|
|
1639
1661
|
// legal, so a consumer passing a full URL still behaves.
|
|
1640
|
-
|
|
1662
|
+
// PAY-2554: the redirect used to carry NO code, so the login page showed nothing and looked like nothing happened.
|
|
1663
|
+
// It now carries the same neutral OAUTH_SIGN_IN_FAILED the abort path uses (the login page maps it to one sentence;
|
|
1664
|
+
// no provider text, URL or internal detail goes on the URL).
|
|
1665
|
+
return new Response(null, { status: 302, headers: { location: `${loginPath}${loginPath.includes('?') ? '&' : '?'}error=${OAUTH_SIGN_IN_FAILED_ERROR}` } });
|
|
1641
1666
|
}
|
|
1642
1667
|
}
|
|
1643
1668
|
|
|
@@ -91,6 +91,32 @@ export function resetSecret(): string | undefined {
|
|
|
91
91
|
return process.env.BETTER_AUTH_SECRET || process.env.NEXTAUTH_SECRET || undefined;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* The domain-separation label for the cookie-derived FALLBACK key (PAY-2218, BAPert 83028). It is the only place the session cookie is used as
|
|
96
|
+
* key material, and it is never the raw SHA-256 of the cookie (that is the binding, a different value for a different purpose).
|
|
97
|
+
*/
|
|
98
|
+
export const RESET_COOKIE_KEY_CONTEXT = 'payez:session-reset:v1:cookie-derived';
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The secret the reset link is signed and verified with.
|
|
102
|
+
*
|
|
103
|
+
* 1. The env secret (BETTER_AUTH_SECRET / NEXTAUTH_SECRET) when the edge can see it. ALWAYS FIRST: where it is present nothing changes.
|
|
104
|
+
* 2. Otherwise a key derived from the session cookie the link is bound to: HMAC(key = cookie value, message = RESET_COOKIE_KEY_CONTEXT).
|
|
105
|
+
* Why that is safe: the cookie is HttpOnly and is the user's credential, so a third-party page that links a victim to the route cannot
|
|
106
|
+
* produce a valid signature (the logout-CSRF protection the signature exists for), and anyone who holds the cookie can already act as
|
|
107
|
+
* the user, logging out included. The edge holds only a one-way value computed per request from a cookie it already received.
|
|
108
|
+
* RESIDUAL RISK, ACCEPTED (BAPert 83028): an attacker who controls a sibling subdomain can plant a cookie value they know under the
|
|
109
|
+
* session cookie name and sign a link that clears THAT value. The effect is a forced logout, and an attacker in that position can already
|
|
110
|
+
* set or shadow cookies and log the victim out, so the scheme adds no capability.
|
|
111
|
+
* 3. Neither: undefined ("cannot sign"; callers fall back to the direct clear).
|
|
112
|
+
* Never log the cookie value or the returned key.
|
|
113
|
+
*/
|
|
114
|
+
export async function resetSigningSecret(envSecret: string | undefined, sessionCookieValue: string | undefined): Promise<string | undefined> {
|
|
115
|
+
if (envSecret) return envSecret;
|
|
116
|
+
if (!sessionCookieValue) return undefined;
|
|
117
|
+
return b64url(await hmacBytes(encoder.encode(sessionCookieValue), RESET_COOKIE_KEY_CONTEXT));
|
|
118
|
+
}
|
|
119
|
+
|
|
94
120
|
/** The session cookie a reset is bound to: the __Secure- one when present, else the plain one. */
|
|
95
121
|
export function boundSessionCookie(get: (name: string) => string | undefined, plainName: string, secureName: string): string | undefined {
|
|
96
122
|
return get(secureName) || get(plainName) || undefined;
|
|
@@ -137,6 +163,27 @@ export async function signedResetHref(
|
|
|
137
163
|
return `${route}?${q.toString()}`;
|
|
138
164
|
}
|
|
139
165
|
|
|
166
|
+
/**
|
|
167
|
+
* VERIFY side, ONLY for an app that opted in (sessionResetCookieKey + createSessionResetHandler({ cookieKey: true }); BAPert 83102). PAY-2218, BAPert 83074 option a: the signer and the verifier can see different things. The edge middleware may be BLIND to the env
|
|
168
|
+
* secret (it signs cookie-derived) while the Node route handler is SIGHTED (the IdP-supplied secret is in its process.env). So the route accepts a link
|
|
169
|
+
* signed with EITHER sound key: the env secret first (when it has one), then the key derived from THIS request's session cookie. Both are sound: the
|
|
170
|
+
* cookie-derived link is still bound to the request's own session cookie, carries its expiry inside the MAC, and is compared in constant time. A link
|
|
171
|
+
* signed with neither key, or with a key derived from a different cookie, is refused.
|
|
172
|
+
*/
|
|
173
|
+
export async function readResetRequestAnyKey(
|
|
174
|
+
search: URLSearchParams,
|
|
175
|
+
envSecret: string | undefined,
|
|
176
|
+
sessionCookieValue: string | undefined,
|
|
177
|
+
nowMs: number = Date.now()
|
|
178
|
+
): Promise<{ params: ResetParams; signed: boolean; via: 'env' | 'cookie' | null }> {
|
|
179
|
+
if (envSecret) {
|
|
180
|
+
const viaEnv = await readResetRequest(search, envSecret, sessionCookieValue, nowMs);
|
|
181
|
+
if (viaEnv.signed) return { ...viaEnv, via: 'env' };
|
|
182
|
+
}
|
|
183
|
+
const viaCookie = await readResetRequest(search, await resetSigningSecret(undefined, sessionCookieValue), sessionCookieValue, nowMs);
|
|
184
|
+
return { ...viaCookie, via: viaCookie.signed ? 'cookie' : null };
|
|
185
|
+
}
|
|
186
|
+
|
|
140
187
|
/**
|
|
141
188
|
* Read a reset request's query. `signed` is true only for a valid, unexpired signature over the normalised params AND
|
|
142
189
|
* this request's own session cookie (`sessionCookieValue`). With no session cookie there is nothing to clear: false.
|
|
@@ -6,11 +6,14 @@ import { safeCallbackPath } from '../../auth/safe-callback';
|
|
|
6
6
|
import { authorizeResumeHref } from '../../lib/authorize-resume';
|
|
7
7
|
import { NO_CODE_REQUEST, codeStepInstruction, type CodeRequest } from '../../lib/code-step-copy';
|
|
8
8
|
import { DevCodeNotice } from './DevCodeNotice';
|
|
9
|
+
import { EnrollAuthenticatorOffer } from './EnrollAuthenticatorOffer';
|
|
9
10
|
import {
|
|
10
11
|
credentialSignIn,
|
|
12
|
+
postSignInDestination,
|
|
11
13
|
requestEmailCode,
|
|
12
14
|
validateCredentials,
|
|
13
15
|
type FetchLike,
|
|
16
|
+
type NextStep,
|
|
14
17
|
} from '../../lib/credential-sign-in-client';
|
|
15
18
|
|
|
16
19
|
/**
|
|
@@ -42,6 +45,12 @@ interface Props {
|
|
|
42
45
|
/** Test seam: navigation after success. */
|
|
43
46
|
onSignedIn?: (destination: string) => void;
|
|
44
47
|
showForgotPassword?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* PAY-2630: the enrolment page a user the IdP told to enrol is taken to after signing in. NO DEFAULT: the offer renders only when
|
|
50
|
+
* the host passes this, because the host is the one that serves the page (the CLI scaffold writes no /account/security, so a
|
|
51
|
+
* default would 404 a generated app's user right after a good sign-in).
|
|
52
|
+
*/
|
|
53
|
+
enrollHref?: string;
|
|
45
54
|
}
|
|
46
55
|
|
|
47
56
|
const inputStyle: React.CSSProperties = {
|
|
@@ -64,27 +73,32 @@ export function resolveDestination(explicit?: string): string {
|
|
|
64
73
|
return safeCallbackPath(authorizeResumeHref(returnUrl), safeCallbackPath(raw, '/dashboard'));
|
|
65
74
|
}
|
|
66
75
|
|
|
67
|
-
export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword = false }: Props) {
|
|
76
|
+
export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showForgotPassword = false, enrollHref }: Props) {
|
|
68
77
|
const [step, setStep] = useState<'credentials' | 'code'>('credentials');
|
|
69
78
|
const [email, setEmail] = useState('');
|
|
70
79
|
const [password, setPassword] = useState('');
|
|
71
80
|
const [code, setCode] = useState('');
|
|
72
81
|
const [busy, setBusy] = useState(false);
|
|
73
82
|
const [error, setError] = useState<string | null>(null);
|
|
83
|
+
const [errorCode, setErrorCode] = useState<string | null>(null);
|
|
74
84
|
const [fieldError, setFieldError] = useState<string | null>(null);
|
|
75
85
|
const [codeRequest, setCodeRequest] = useState<CodeRequest>(NO_CODE_REQUEST);
|
|
76
86
|
const [offerEmailCode, setOfferEmailCode] = useState(false);
|
|
77
87
|
const [devCode, setDevCode] = useState<string | null>(null);
|
|
78
88
|
const [emailCodeMsg, setEmailCodeMsg] = useState<string | null>(null);
|
|
89
|
+
// PAY-2630: what the IdP said is outstanding on this client, and whether the user kept the 'take me to enrol' offer ticked.
|
|
90
|
+
const [nextStep, setNextStep] = useState<NextStep>(null);
|
|
91
|
+
const [enrolAfter, setEnrolAfter] = useState(true);
|
|
79
92
|
|
|
80
93
|
async function submit(withCode?: string) {
|
|
81
94
|
setBusy(true);
|
|
82
95
|
setError(null);
|
|
96
|
+
setErrorCode(null);
|
|
83
97
|
setOfferEmailCode(false);
|
|
84
98
|
try {
|
|
85
99
|
const r = await credentialSignIn({ email, password, code: withCode }, fetchImpl);
|
|
86
100
|
if (r.kind === 'success') {
|
|
87
|
-
const destination = resolveDestination(callbackUrl);
|
|
101
|
+
const destination = postSignInDestination(resolveDestination(callbackUrl), nextStep, enrolAfter, enrollHref);
|
|
88
102
|
if (onSignedIn) onSignedIn(destination);
|
|
89
103
|
else window.location.href = destination;
|
|
90
104
|
return;
|
|
@@ -92,10 +106,12 @@ export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showF
|
|
|
92
106
|
if (r.kind === 'two_factor') {
|
|
93
107
|
setCodeRequest(r.codeRequest);
|
|
94
108
|
setDevCode(r.devCode);
|
|
109
|
+
setNextStep(r.nextStep);
|
|
95
110
|
setStep('code');
|
|
96
111
|
return;
|
|
97
112
|
}
|
|
98
113
|
setError(r.message);
|
|
114
|
+
setErrorCode(r.code);
|
|
99
115
|
if (r.kind === 'error') setOfferEmailCode(r.offerEmailCode);
|
|
100
116
|
} finally {
|
|
101
117
|
setBusy(false);
|
|
@@ -128,6 +144,7 @@ export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showF
|
|
|
128
144
|
setStep('credentials');
|
|
129
145
|
setCodeRequest(NO_CODE_REQUEST);
|
|
130
146
|
setDevCode(null);
|
|
147
|
+
setNextStep(null);
|
|
131
148
|
setCode('');
|
|
132
149
|
setFieldError(null);
|
|
133
150
|
setError(null);
|
|
@@ -156,7 +173,7 @@ export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showF
|
|
|
156
173
|
|
|
157
174
|
const problem = fieldError ?? error;
|
|
158
175
|
const problemLine = problem && (
|
|
159
|
-
<p role="alert" data-testid="credential-sign-in-error" className="text-sm mt-3" style={{ color: 'var(--color-error, #ef4444)' }}>
|
|
176
|
+
<p role="alert" data-testid="credential-sign-in-error" data-error-code={fieldError ? 'invalid_input' : (errorCode ?? undefined)} className="text-sm mt-3" style={{ color: 'var(--color-error, #ef4444)' }}>
|
|
160
177
|
{problem}
|
|
161
178
|
</p>
|
|
162
179
|
);
|
|
@@ -198,6 +215,7 @@ export function CredentialSignInForm({ callbackUrl, fetchImpl, onSignedIn, showF
|
|
|
198
215
|
{busy ? 'Verifying…' : 'Verify'}
|
|
199
216
|
</button>
|
|
200
217
|
</form>
|
|
218
|
+
<EnrollAuthenticatorOffer nextStep={enrollHref ? nextStep : null} checked={enrolAfter} onChange={setEnrolAfter} disabled={busy} />
|
|
201
219
|
<button type="button" onClick={onBack} disabled={busy} className="text-sm underline mt-4" style={{ background: 'none', border: 'none', padding: 0, color: 'var(--text-muted, #6b7280)' }}>
|
|
202
220
|
Back
|
|
203
221
|
</button>
|