@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.
Files changed (77) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/api-handlers/auth/credential-login.js +28 -4
  3. package/dist/api-handlers/mfa/totp-confirm.js +8 -4
  4. package/dist/api-handlers/mfa/totp-enroll.js +8 -4
  5. package/dist/api-handlers/session/viability.js +6 -2
  6. package/dist/auth/better-auth.d.ts +6 -0
  7. package/dist/auth/better-auth.js +29 -2
  8. package/dist/auth/session-reset-link.d.ts +32 -0
  9. package/dist/auth/session-reset-link.js +45 -1
  10. package/dist/components/auth/CredentialSignInForm.d.ts +7 -1
  11. package/dist/components/auth/CredentialSignInForm.js +13 -4
  12. package/dist/components/auth/EnrollAuthenticatorOffer.d.ts +21 -0
  13. package/dist/components/auth/EnrollAuthenticatorOffer.js +23 -0
  14. package/dist/components/mfa/QrCode.d.ts +6 -0
  15. package/dist/components/mfa/QrCode.js +14 -0
  16. package/dist/components/mfa/TotpEnrollPanel.d.ts +6 -0
  17. package/dist/components/mfa/TotpEnrollPanel.js +117 -0
  18. package/dist/edge/middleware.d.ts +1 -1
  19. package/dist/edge/middleware.js +2 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.js +2 -1
  22. package/dist/lib/credential-sign-in-client.d.ts +26 -1
  23. package/dist/lib/credential-sign-in-client.js +32 -6
  24. package/dist/lib/ensure-fresh-access-token.js +15 -10
  25. package/dist/lib/idp-client-config.js +4 -1
  26. package/dist/lib/login-error-message.d.ts +3 -0
  27. package/dist/lib/login-error-message.js +12 -1
  28. package/dist/lib/mfa-json-guard.d.ts +12 -0
  29. package/dist/lib/mfa-json-guard.js +19 -0
  30. package/dist/lib/nextauth-secret.js +3 -1
  31. package/dist/lib/payez-client-secret.d.ts +14 -0
  32. package/dist/lib/payez-client-secret.js +41 -0
  33. package/dist/lib/qrcode.d.ts +22 -0
  34. package/dist/lib/qrcode.js +362 -0
  35. package/dist/lib/sign-in-failure.d.ts +35 -0
  36. package/dist/lib/sign-in-failure.js +85 -0
  37. package/dist/lib/twofa-rejection.d.ts +2 -0
  38. package/dist/lib/twofa-rejection.js +5 -2
  39. package/dist/middleware/create-middleware.d.ts +24 -0
  40. package/dist/middleware/create-middleware.js +56 -9
  41. package/dist/middleware/prefetch-guard-status.d.ts +21 -0
  42. package/dist/middleware/prefetch-guard-status.js +42 -0
  43. package/dist/pages/enroll/page.d.ts +7 -0
  44. package/dist/pages/enroll/page.js +15 -0
  45. package/dist/pages/login/page.js +1 -1
  46. package/dist/routes/auth/session-reset.d.ts +11 -1
  47. package/dist/routes/auth/session-reset.js +38 -13
  48. package/dist/routes/auth/viability.js +11 -2
  49. package/package.json +11 -1
  50. package/src/api-handlers/auth/credential-login.ts +29 -4
  51. package/src/api-handlers/mfa/totp-confirm.ts +7 -4
  52. package/src/api-handlers/mfa/totp-enroll.ts +7 -4
  53. package/src/api-handlers/session/viability.ts +6 -2
  54. package/src/auth/better-auth.ts +27 -2
  55. package/src/auth/session-reset-link.ts +47 -0
  56. package/src/components/auth/CredentialSignInForm.tsx +21 -3
  57. package/src/components/auth/EnrollAuthenticatorOffer.tsx +48 -0
  58. package/src/components/mfa/QrCode.tsx +24 -0
  59. package/src/components/mfa/TotpEnrollPanel.tsx +203 -0
  60. package/src/edge/middleware.ts +1 -1
  61. package/src/index.ts +1 -1
  62. package/src/lib/credential-sign-in-client.ts +48 -8
  63. package/src/lib/ensure-fresh-access-token.ts +15 -10
  64. package/src/lib/idp-client-config.ts +5 -1
  65. package/src/lib/login-error-message.ts +13 -1
  66. package/src/lib/mfa-json-guard.ts +19 -0
  67. package/src/lib/nextauth-secret.ts +3 -1
  68. package/src/lib/payez-client-secret.ts +39 -0
  69. package/src/lib/qrcode.ts +348 -0
  70. package/src/lib/sign-in-failure.ts +88 -0
  71. package/src/lib/twofa-rejection.ts +7 -2
  72. package/src/middleware/create-middleware.ts +69 -9
  73. package/src/middleware/prefetch-guard-status.ts +56 -0
  74. package/src/pages/enroll/page.tsx +22 -0
  75. package/src/pages/login/page.tsx +2 -1
  76. package/src/routes/auth/session-reset.ts +46 -11
  77. package/src/routes/auth/viability.ts +10 -2
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+ 'use client';
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.STEP_UP_SENTENCE = void 0;
5
+ exports.TotpEnrollPanel = TotpEnrollPanel;
6
+ const jsx_runtime_1 = require("react/jsx-runtime");
7
+ const react_1 = require("react");
8
+ const QrCode_1 = require("./QrCode");
9
+ const two_factor_verify_failure_1 = require("../../lib/two-factor-verify-failure");
10
+ exports.STEP_UP_SENTENCE = 'This session was signed in with an emailed or texted code only. Sign in with your current authenticator, then try again.';
11
+ async function readJson(res) {
12
+ return res.json().catch(() => null);
13
+ }
14
+ function TotpEnrollPanel({ fetchImpl, onConfirmed } = {}) {
15
+ const doFetch = fetchImpl ?? ((...a) => fetch(...a));
16
+ const [stage, setStage] = (0, react_1.useState)('idle');
17
+ const [enroll, setEnroll] = (0, react_1.useState)(null);
18
+ const [code, setCode] = (0, react_1.useState)('');
19
+ const [error, setError] = (0, react_1.useState)(null);
20
+ const [busy, setBusy] = (0, react_1.useState)(false);
21
+ async function begin() {
22
+ setBusy(true);
23
+ setError(null);
24
+ try {
25
+ const res = await doFetch('/api/mfa/totp/enroll', {
26
+ method: 'POST',
27
+ headers: { 'Content-Type': 'application/json' },
28
+ body: JSON.stringify({ friendly_name: 'This device' }),
29
+ });
30
+ if (res.ok) {
31
+ const body = (await readJson(res));
32
+ if (!body || !body.secret || !body.otpauth_uri || body.enrollment_id === undefined) {
33
+ setError('Something went wrong on our end. Try again.');
34
+ return;
35
+ }
36
+ setEnroll({ secret: body.secret, otpauth_uri: body.otpauth_uri, enrollment_id: body.enrollment_id });
37
+ setCode('');
38
+ setStage('enrolling');
39
+ return;
40
+ }
41
+ if (res.status === 401) {
42
+ setStage('signed-out');
43
+ return;
44
+ }
45
+ const json = await readJson(res);
46
+ if ((0, two_factor_verify_failure_1.stepUpRefusal)(res.status, json)) {
47
+ setError(exports.STEP_UP_SENTENCE);
48
+ return;
49
+ }
50
+ if (res.status === 403) {
51
+ setError('This account already has a second factor set up, so an authenticator app cannot be added here. Sign in with your existing method.');
52
+ return;
53
+ }
54
+ setError('Something went wrong on our end. Try again.');
55
+ }
56
+ finally {
57
+ setBusy(false);
58
+ }
59
+ }
60
+ async function confirm() {
61
+ if (!enroll || !/^\d{6}$/.test(code)) {
62
+ setError('Enter the 6-digit code from your app.');
63
+ return;
64
+ }
65
+ setBusy(true);
66
+ setError(null);
67
+ try {
68
+ const res = await doFetch('/api/mfa/totp/confirm', {
69
+ method: 'POST',
70
+ headers: { 'Content-Type': 'application/json' },
71
+ body: JSON.stringify({ enrollment_id: enroll.enrollment_id, code }),
72
+ });
73
+ if (res.ok) {
74
+ setEnroll(null);
75
+ setCode('');
76
+ setStage('done');
77
+ onConfirmed?.();
78
+ return;
79
+ }
80
+ if (res.status === 401) {
81
+ setStage('signed-out');
82
+ return;
83
+ }
84
+ const json = await readJson(res);
85
+ if ((0, two_factor_verify_failure_1.stepUpRefusal)(res.status, json)) {
86
+ setError(exports.STEP_UP_SENTENCE);
87
+ return;
88
+ }
89
+ setCode('');
90
+ setError(res.status === 400 || res.status === 422
91
+ ? 'That code did not match. Check the time on your phone and try the next code.'
92
+ : 'Setup was not completed. Try again.');
93
+ }
94
+ finally {
95
+ setBusy(false);
96
+ }
97
+ }
98
+ if (stage === 'signed-out') {
99
+ return ((0, jsx_runtime_1.jsxs)("div", { "data-testid": "totp-signed-out", children: [(0, jsx_runtime_1.jsx)("a", { href: "/account-auth/login", children: "Sign in" }), " to set up an authenticator app."] }));
100
+ }
101
+ if (stage === 'done') {
102
+ return ((0, jsx_runtime_1.jsxs)("div", { "data-testid": "totp-done", role: "status", children: [(0, jsx_runtime_1.jsx)("p", { children: "Authenticator app added." }), (0, jsx_runtime_1.jsx)("p", { children: "The next time you sign in on this app, you will be asked for the 6-digit code from it." })] }));
103
+ }
104
+ if (stage === 'enrolling' && enroll) {
105
+ return ((0, jsx_runtime_1.jsxs)("div", { "data-testid": "totp-enrolling", style: { display: 'flex', flexDirection: 'column', gap: 12, alignItems: 'flex-start' }, children: [(0, jsx_runtime_1.jsx)("p", { children: "Open your authenticator app and scan this code. A phone camera works too." }), (0, jsx_runtime_1.jsx)(QrCode_1.QrCode, { value: enroll.otpauth_uri }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("div", { children: "Can't scan? Enter this code manually:" }), (0, jsx_runtime_1.jsx)("code", { "data-testid": "totp-secret", style: { wordBreak: 'break-all' }, children: enroll.secret })] }), (0, jsx_runtime_1.jsxs)("form", { onSubmit: (e) => {
106
+ e.preventDefault();
107
+ void confirm();
108
+ }, children: [(0, jsx_runtime_1.jsx)("label", { htmlFor: "totp-code", children: "Enter the 6-digit code from your app:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("input", { id: "totp-code", "data-testid": "totp-code", inputMode: "numeric", autoComplete: "one-time-code", maxLength: 6, value: code, disabled: busy, onChange: (e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6)) }), ' ', (0, jsx_runtime_1.jsx)("button", { type: "submit", "data-testid": "totp-confirm", disabled: busy || code.length !== 6, children: "Confirm" })] }), error ? ((0, jsx_runtime_1.jsx)("p", { role: "alert", "data-testid": "totp-error", children: error })) : null, (0, jsx_runtime_1.jsx)("button", { type: "button", "data-testid": "totp-restart", disabled: busy, onClick: () => {
109
+ setEnroll(null);
110
+ setCode('');
111
+ setError(null);
112
+ setStage('idle');
113
+ }, children: "Start again" })] }));
114
+ }
115
+ return ((0, jsx_runtime_1.jsxs)("div", { "data-testid": "totp-idle", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", "data-testid": "totp-begin", disabled: busy, onClick: () => void begin(), children: busy ? 'Starting…' : 'Add authenticator app' }), error ? ((0, jsx_runtime_1.jsx)("p", { role: "alert", "data-testid": "totp-error", children: error })) : null] }));
116
+ }
117
+ exports.default = TotpEnrollPanel;
@@ -7,7 +7,7 @@
7
7
  export { makeAuthDecision } from '../auth/auth-decision';
8
8
  export { isUnauthenticatedRoute, configurePublicRoutes, getRouteConfig, configure2FABypassRoutes, should2FABypass, get2FABypassConfig } from '../auth/route-config';
9
9
  export type { UnauthenticatedRouteConfig, ProtectedRouteConfig } from '../auth/route-config';
10
- export { createMvpMiddleware, isPrefetchRequest } from '../middleware/create-middleware';
10
+ export { createMvpMiddleware, isPrefetchRequest, isKnownNonNavigationRequest } from '../middleware/create-middleware';
11
11
  export type { MvpMiddlewareOptions, CircuitBreakerProvider, MiddlewareLogger, AuthContext, AuthAction, SessionPointer, SessionStatus } from '../middleware/create-middleware';
12
12
  export { TwoFactorPresets, AMRValues, ACRLevels, validateAMR, validateACR, checkTwoFactorRequirements } from '../middleware/twofa-presets';
13
13
  export type { TwoFactorRequirements, RouteConfig } from '../middleware/twofa-presets';
@@ -6,7 +6,7 @@
6
6
  * Client-side utilities that depend on browser APIs or server-only code are excluded.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.getInternalApiUrl = exports.checkTwoFactorRequirements = exports.validateACR = exports.validateAMR = exports.ACRLevels = exports.AMRValues = exports.TwoFactorPresets = exports.isPrefetchRequest = exports.createMvpMiddleware = exports.get2FABypassConfig = exports.should2FABypass = exports.configure2FABypassRoutes = exports.getRouteConfig = exports.configurePublicRoutes = exports.isUnauthenticatedRoute = exports.makeAuthDecision = void 0;
9
+ exports.getInternalApiUrl = exports.checkTwoFactorRequirements = exports.validateACR = exports.validateAMR = exports.ACRLevels = exports.AMRValues = exports.TwoFactorPresets = exports.isKnownNonNavigationRequest = exports.isPrefetchRequest = exports.createMvpMiddleware = exports.get2FABypassConfig = exports.should2FABypass = exports.configure2FABypassRoutes = exports.getRouteConfig = exports.configurePublicRoutes = exports.isUnauthenticatedRoute = exports.makeAuthDecision = void 0;
10
10
  // Authentication middleware and configuration (Edge Runtime compatible)
11
11
  var auth_decision_1 = require("../auth/auth-decision");
12
12
  Object.defineProperty(exports, "makeAuthDecision", { enumerable: true, get: function () { return auth_decision_1.makeAuthDecision; } });
@@ -20,6 +20,7 @@ Object.defineProperty(exports, "get2FABypassConfig", { enumerable: true, get: fu
20
20
  var create_middleware_1 = require("../middleware/create-middleware");
21
21
  Object.defineProperty(exports, "createMvpMiddleware", { enumerable: true, get: function () { return create_middleware_1.createMvpMiddleware; } });
22
22
  Object.defineProperty(exports, "isPrefetchRequest", { enumerable: true, get: function () { return create_middleware_1.isPrefetchRequest; } });
23
+ Object.defineProperty(exports, "isKnownNonNavigationRequest", { enumerable: true, get: function () { return create_middleware_1.isKnownNonNavigationRequest; } });
23
24
  // Two-Factor Authentication Presets (Edge Runtime compatible)
24
25
  var twofa_presets_1 = require("../middleware/twofa-presets");
25
26
  Object.defineProperty(exports, "TwoFactorPresets", { enumerable: true, get: function () { return twofa_presets_1.TwoFactorPresets; } });
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export type { AuthConfig } from './types/auth';
6
6
  export { makeAuthDecision } from './auth/auth-decision';
7
7
  export { isUnauthenticatedRoute, configurePublicRoutes, getRouteConfig } from './auth/route-config';
8
8
  export { safeCallbackPath, isSafeCallbackPath, resolveCallbackDestination, DEFAULT_CALLBACK } from './auth/safe-callback';
9
- export { createMvpMiddleware, isPrefetchRequest } from './middleware/create-middleware';
9
+ export { createMvpMiddleware, isPrefetchRequest, isKnownNonNavigationRequest } from './middleware/create-middleware';
10
10
  export { UserAvatarMenu, MobileNavDrawer } from './components/account';
11
11
  export type { UserAvatarMenuProps, MobileNavDrawerProps, NavItem, NavSection } from './components/account';
12
12
  export { ErrorMetricsCard, HealthMetricsCard, AuditLogViewer, AdminAnalyticsLayout, useErrorMetrics, useHealthMetrics, useAuditLog, useAdminAnalytics, getErrorMetrics, getHealthMetrics, writeAuditLog, queryAuditLog, } from './logging';
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // - Better Auth: import { createBetterAuthInstance } from '@payez/next-mvp/auth/better-auth'
9
9
  // - Server auth: import { getSession } from '@payez/next-mvp/server/auth'
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
- exports.queryAuditLog = exports.writeAuditLog = exports.getHealthMetrics = exports.getErrorMetrics = exports.useAdminAnalytics = exports.useAuditLog = exports.useHealthMetrics = exports.useErrorMetrics = exports.AdminAnalyticsLayout = exports.AuditLogViewer = exports.HealthMetricsCard = exports.ErrorMetricsCard = exports.MobileNavDrawer = exports.UserAvatarMenu = exports.isPrefetchRequest = exports.createMvpMiddleware = exports.DEFAULT_CALLBACK = exports.resolveCallbackDestination = exports.isSafeCallbackPath = exports.safeCallbackPath = exports.getRouteConfig = exports.configurePublicRoutes = exports.isUnauthenticatedRoute = exports.makeAuthDecision = exports.useTraditionalAuthEnabled = exports.useFederatedAuthEnabled = exports.useFederatedProviders = exports.useAuthMode = exports.useAuthConfig = exports.AuthProvider = exports.useAnonSession = exports.fetchWithAuth = void 0;
11
+ exports.queryAuditLog = exports.writeAuditLog = exports.getHealthMetrics = exports.getErrorMetrics = exports.useAdminAnalytics = exports.useAuditLog = exports.useHealthMetrics = exports.useErrorMetrics = exports.AdminAnalyticsLayout = exports.AuditLogViewer = exports.HealthMetricsCard = exports.ErrorMetricsCard = exports.MobileNavDrawer = exports.UserAvatarMenu = exports.isKnownNonNavigationRequest = exports.isPrefetchRequest = exports.createMvpMiddleware = exports.DEFAULT_CALLBACK = exports.resolveCallbackDestination = exports.isSafeCallbackPath = exports.safeCallbackPath = exports.getRouteConfig = exports.configurePublicRoutes = exports.isUnauthenticatedRoute = exports.makeAuthDecision = exports.useTraditionalAuthEnabled = exports.useFederatedAuthEnabled = exports.useFederatedProviders = exports.useAuthMode = exports.useAuthConfig = exports.AuthProvider = exports.useAnonSession = exports.fetchWithAuth = void 0;
12
12
  // Client-safe exports only
13
13
  // Client-side utilities
14
14
  var fetch_with_auth_1 = require("./client/fetch-with-auth");
@@ -41,6 +41,7 @@ Object.defineProperty(exports, "DEFAULT_CALLBACK", { enumerable: true, get: func
41
41
  var create_middleware_1 = require("./middleware/create-middleware");
42
42
  Object.defineProperty(exports, "createMvpMiddleware", { enumerable: true, get: function () { return create_middleware_1.createMvpMiddleware; } });
43
43
  Object.defineProperty(exports, "isPrefetchRequest", { enumerable: true, get: function () { return create_middleware_1.isPrefetchRequest; } });
44
+ Object.defineProperty(exports, "isKnownNonNavigationRequest", { enumerable: true, get: function () { return create_middleware_1.isKnownNonNavigationRequest; } });
44
45
  // Account Components
45
46
  var account_1 = require("./components/account");
46
47
  Object.defineProperty(exports, "UserAvatarMenu", { enumerable: true, get: function () { return account_1.UserAvatarMenu; } });
@@ -14,6 +14,7 @@
14
14
  * literally true (PAY-2284).
15
15
  */
16
16
  import { NO_CODE_REQUEST, type CodeRequest } from './code-step-copy';
17
+ import { SIGN_IN_FAILURE_COPY } from './sign-in-failure';
17
18
  export declare const CREDENTIAL_LOGIN_URL = "/api/auth/admin-login";
18
19
  export declare const CREDENTIAL_EMAIL_CODE_URL = "/api/auth/admin-login/email-code";
19
20
  export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
@@ -23,15 +24,24 @@ export type CredentialSignInResult = {
23
24
  kind: 'two_factor';
24
25
  codeRequest: CodeRequest;
25
26
  devCode: string | null;
27
+ nextStep: NextStep;
26
28
  } | {
27
29
  kind: 'rate_limited';
28
30
  message: string;
31
+ code: ErrorCode;
29
32
  } | {
30
33
  kind: 'error';
31
34
  message: string;
35
+ code: ErrorCode;
32
36
  offerEmailCode: boolean;
33
37
  };
34
- export declare const MSG_RATE_LIMITED = "Too many attempts. Please try again later.";
38
+ /**
39
+ * The stable machine code a rendered error carries (data-error-code). The three from the closed table in lib/sign-in-failure,
40
+ * plus 'policy_message' (an allowlisted SsoRequired / InvalidRequest refusal, whose detail the server may send) and
41
+ * 'sign_in_failed' (anything this client could not map: a 400/5xx, an unknown code, a thrown fetch).
42
+ */
43
+ export type ErrorCode = keyof typeof SIGN_IN_FAILURE_COPY | 'policy_message' | 'sign_in_failed';
44
+ export declare const MSG_RATE_LIMITED: string;
35
45
  export declare const MSG_CANNOT_SIGN_IN = "Could not sign in. Check your details and try again.";
36
46
  export declare const MSG_UNEXPECTED = "Something went wrong signing you in. Please try again.";
37
47
  export declare function isEmailShaped(v: string): boolean;
@@ -40,6 +50,21 @@ export declare function validateCredentials(email: string, password: string): {
40
50
  email?: string;
41
51
  password?: string;
42
52
  } | null;
53
+ /**
54
+ * PAY-2630: what the IdP says is outstanding for this user ON THIS CLIENT. The door (api-handlers/auth/credential-login)
55
+ * already relays the IdP's next_step as `nextStep`; this form used to read ZERO of it. 'enroll_mfa' = the user has no usable
56
+ * second factor on this client, so a code prompt alone is a trap; 'mfa_verify' = a factor exists, the prompt is right.
57
+ * Anything else (absent, null, an unknown string, wrong case) is null = "not told", never a guess.
58
+ */
59
+ export type NextStep = 'enroll_mfa' | 'mfa_verify' | null;
60
+ export declare function readNextStep(body: any): NextStep;
61
+ /**
62
+ * Where the browser goes after the code was accepted. A user the IdP told to enrol, who kept the offer ticked, goes to the
63
+ * enrolment page; everyone else goes where they were headed. The destination is never decided from anything but the IdP's
64
+ * answer and the user's own tick. With NO enrolHref (the host mounts no enrolment page) nobody is rerouted: there is no default, because
65
+ * a default that a host does not serve is a 404 after a successful sign-in (QAPert, PR 1687 read).
66
+ */
67
+ export declare function postSignInDestination(destination: string, nextStep: NextStep, enrolAfter: boolean, enrolHref?: string): string;
43
68
  /** One call to the credential door. `code` is the 2FA code on the second turn; email and password are resubmitted with it. */
44
69
  export declare function credentialSignIn(args: {
45
70
  email: string;
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.NO_CODE_REQUEST = exports.MSG_EMAIL_CODE_FAILED = exports.MSG_UNEXPECTED = exports.MSG_CANNOT_SIGN_IN = exports.MSG_RATE_LIMITED = exports.CREDENTIAL_EMAIL_CODE_URL = exports.CREDENTIAL_LOGIN_URL = void 0;
4
4
  exports.isEmailShaped = isEmailShaped;
5
5
  exports.validateCredentials = validateCredentials;
6
+ exports.readNextStep = readNextStep;
7
+ exports.postSignInDestination = postSignInDestination;
6
8
  exports.credentialSignIn = credentialSignIn;
7
9
  exports.requestEmailCode = requestEmailCode;
8
10
  /**
@@ -22,9 +24,10 @@ exports.requestEmailCode = requestEmailCode;
22
24
  */
23
25
  const code_step_copy_1 = require("./code-step-copy");
24
26
  Object.defineProperty(exports, "NO_CODE_REQUEST", { enumerable: true, get: function () { return code_step_copy_1.NO_CODE_REQUEST; } });
27
+ const sign_in_failure_1 = require("./sign-in-failure");
25
28
  exports.CREDENTIAL_LOGIN_URL = '/api/auth/admin-login';
26
29
  exports.CREDENTIAL_EMAIL_CODE_URL = '/api/auth/admin-login/email-code';
27
- exports.MSG_RATE_LIMITED = 'Too many attempts. Please try again later.';
30
+ exports.MSG_RATE_LIMITED = sign_in_failure_1.SIGN_IN_FAILURE_COPY.too_many_attempts;
28
31
  exports.MSG_CANNOT_SIGN_IN = 'Could not sign in. Check your details and try again.';
29
32
  exports.MSG_UNEXPECTED = 'Something went wrong signing you in. Please try again.';
30
33
  const EMAIL_SHAPE = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
@@ -43,6 +46,19 @@ function validateCredentials(email, password) {
43
46
  errors.password = 'Enter your password.';
44
47
  return errors.email || errors.password ? errors : null;
45
48
  }
49
+ function readNextStep(body) {
50
+ const v = body?.nextStep;
51
+ return v === 'enroll_mfa' || v === 'mfa_verify' ? v : null;
52
+ }
53
+ /**
54
+ * Where the browser goes after the code was accepted. A user the IdP told to enrol, who kept the offer ticked, goes to the
55
+ * enrolment page; everyone else goes where they were headed. The destination is never decided from anything but the IdP's
56
+ * answer and the user's own tick. With NO enrolHref (the host mounts no enrolment page) nobody is rerouted: there is no default, because
57
+ * a default that a host does not serve is a 404 after a successful sign-in (QAPert, PR 1687 read).
58
+ */
59
+ function postSignInDestination(destination, nextStep, enrolAfter, enrolHref) {
60
+ return enrolHref && nextStep === 'enroll_mfa' && enrolAfter ? enrolHref : destination;
61
+ }
46
62
  /** The dev echo: the door passes the IdP's DevVerificationCode through as `devVerificationCode`. Nothing else is ever a dev code. */
47
63
  function readDevCode(body) {
48
64
  return typeof body?.devVerificationCode === 'string' && body.devVerificationCode ? body.devVerificationCode : null;
@@ -59,17 +75,27 @@ async function credentialSignIn(args, fetchImpl = (input, init) => fetch(input,
59
75
  body: JSON.stringify({ email: args.email, password: args.password, code: args.code }),
60
76
  });
61
77
  const body = await res.json().catch(() => null);
62
- if (res.status === 429)
63
- return { kind: 'rate_limited', message: asString(body?.error) ?? exports.MSG_RATE_LIMITED };
78
+ // The sentence comes from the closed table, never from the body (BAPert 85415 rule 4).
79
+ if (res.status === 429) {
80
+ const code = body?.code === 'second_factor_rate_limited' ? 'second_factor_rate_limited' : 'too_many_attempts';
81
+ return { kind: 'rate_limited', message: (0, sign_in_failure_1.tooManyAttemptsMessage)(body?.retryAfter, code), code };
82
+ }
64
83
  if (body?.requiresTwoFactor)
65
- return { kind: 'two_factor', codeRequest: (0, code_step_copy_1.readCodeRequest)(body), devCode: readDevCode(body) };
84
+ return { kind: 'two_factor', codeRequest: (0, code_step_copy_1.readCodeRequest)(body), devCode: readDevCode(body), nextStep: readNextStep(body) };
66
85
  if (!res.ok || body?.success !== true) {
67
- return { kind: 'error', message: asString(body?.error) ?? exports.MSG_CANNOT_SIGN_IN, offerEmailCode: body?.offerEmailCode === true };
86
+ const offerEmailCode = body?.offerEmailCode === true;
87
+ if ((0, sign_in_failure_1.isSignInFailureCode)(body?.code))
88
+ return { kind: 'error', message: (0, sign_in_failure_1.signInFailureMessage)(body.code), code: body.code, offerEmailCode };
89
+ // The one passthrough: an allowlisted policy refusal (SsoRequired / InvalidRequest), flagged by the server with this exact code.
90
+ const policy = body?.code === 'policy_message' ? (0, sign_in_failure_1.capPolicyDetail)(body?.error) : null;
91
+ if (policy)
92
+ return { kind: 'error', message: policy, code: 'policy_message', offerEmailCode };
93
+ return { kind: 'error', message: exports.MSG_CANNOT_SIGN_IN, code: 'sign_in_failed', offerEmailCode };
68
94
  }
69
95
  return { kind: 'success' };
70
96
  }
71
97
  catch {
72
- return { kind: 'error', message: exports.MSG_UNEXPECTED, offerEmailCode: false };
98
+ return { kind: 'error', message: exports.MSG_UNEXPECTED, code: 'sign_in_failed', offerEmailCode: false };
73
99
  }
74
100
  }
75
101
  exports.MSG_EMAIL_CODE_FAILED = 'We could not ask for an email code just now. Try again shortly.';
@@ -257,17 +257,18 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
257
257
  }
258
258
  catch { /* fall through */ }
259
259
  }
260
- const isOAuthSession = !!session.oauthProvider;
261
- if (authMethods.length === 0 && isOAuthSession) {
262
- authMethods = ['pwd', 'mfa'];
263
- }
260
+ // PAY-2425 (BAPert 77400, NightHawk 77401): THE CLIENT DOES NOT INFER AUTHENTICATION STRENGTH FROM SILENCE. This block used to answer an OAuth
261
+ // (Google) session whose stored methods were EMPTY with amr ['pwd','mfa'], a two-factor method of 'oauth' and an acr raised from '1' to '2' ("OAuth IS
262
+ // a form of multi-factor auth"). That is false: OAuth is federated sign-in, one factor typed at the provider. It fired exactly when the session
263
+ // could tell us nothing and answered with the strongest claim available; on prod (4.10.0) a Google user on a require2FA client was then counted
264
+ // 2FA-complete with no code (RigPert 77398). What the session does not attest, the refresh body does not assert: empty stays empty, the level stays
265
+ // the session's own, and no method is named unless one is recorded. The IdP computes the real amr/acr for the minted token (and mfaVerified is
266
+ // re-derived from THAT token below); a session that really attests 'mfa' still sends it. History: the fix existed on nextpert/375639-amr-fabrication
267
+ // (e6e3b6c3, e3b3bb12) and was never merged.
264
268
  const twoFactorMethod = authMethods.find(m => ['sms', 'totp', 'email'].includes(m)) ||
265
269
  session.mfaMethod ||
266
- (isOAuthSession ? 'oauth' : null);
267
- let acrValue = String(session.authenticationLevel ?? '1');
268
- if (isOAuthSession && session.mfaVerified && acrValue === '1') {
269
- acrValue = '2';
270
- }
270
+ null;
271
+ const acrValue = String(session.authenticationLevel ?? '1');
271
272
  // WO-2: send the POST-lock current refresh token, not the pre-lock snapshot.
272
273
  // `session` was read before we acquired the lock; a concurrent refresh that
273
274
  // completed while we waited rotates the token, so the pre-lock value is stale
@@ -300,7 +301,11 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
300
301
  amr: authMethods,
301
302
  acr: acrValue,
302
303
  };
303
- if (session.mfaVerified)
304
+ // PAY-2425 (NightHawk 77414): a STORED mfaVerified flag is only echoed when the session's own recorded methods back it. Sessions created while the
305
+ // bug was live carry mfaVerified:true that the bug itself wrote, with no 'mfa' (or sms/totp/email) in their methods; echoing that flag would carry the
306
+ // poison across the upgrade. A genuine second factor leaves 'mfa' and its method in the recorded methods, so it is still sent.
307
+ const methodsAttestSecondFactor = authMethods.some(m => m === 'mfa' || ['sms', 'totp', 'email'].includes(m));
308
+ if (session.mfaVerified && methodsAttestSecondFactor)
304
309
  body.two_factor_verified = true;
305
310
  if (twoFactorMethod)
306
311
  body.two_factor_method = twoFactorMethod;
@@ -82,6 +82,7 @@ exports.getEnabledProviders = getEnabledProviders;
82
82
  require("server-only");
83
83
  const crypto_1 = require("crypto");
84
84
  const never_in_response_1 = require("./never-in-response");
85
+ const payez_client_secret_1 = require("./payez-client-secret");
85
86
  const log_safe_1 = require("./log-safe");
86
87
  // REDIS IS IMPORTED LAZILY, AND THAT IS LOAD-BEARING.
87
88
  // A module-scope `import redis from './redis'` drags ioredis into EVERY bundle that
@@ -691,6 +692,7 @@ async function fetchConfigFromIDP(idpUrl, clientIdStr) {
691
692
  subject: clientIdStr,
692
693
  audience: 'urn:payez:externalauth:clientconfig',
693
694
  expires_in: 60,
695
+ ...(0, payez_client_secret_1.clientSecretBody)(),
694
696
  };
695
697
  const signingResp = await fetch(signingUrl, {
696
698
  method: 'POST',
@@ -731,7 +733,7 @@ async function fetchConfigFromIDP(idpUrl, clientIdStr) {
731
733
  'X-Client-Id': clientIdStr,
732
734
  'X-Correlation-Id': (0, crypto_1.randomUUID)().replace(/-/g, ''),
733
735
  },
734
- body: JSON.stringify({ client_assertion }),
736
+ body: JSON.stringify({ client_assertion, ...(0, payez_client_secret_1.clientSecretBody)() }),
735
737
  cache: 'no-store'
736
738
  });
737
739
  if (!configResp.ok) {
@@ -839,6 +841,7 @@ async function fetchConfigFromIDP(idpUrl, clientIdStr) {
839
841
  // silently substitutes a public constant for an empty secret.
840
842
  throw new Error('[IDP_CONFIG] FATAL: authSecret is empty after parsing and neither BETTER_AUTH_SECRET nor NEXTAUTH_SECRET is set');
841
843
  }
844
+ (0, payez_client_secret_1.warnIfProviderSecretRedacted)(configResp.headers?.get?.('X-PayEz-Secrets-Redacted'), config.oauthProviders ?? [], providerSecretEnvVar);
842
845
  // Success - reset failure tracking
843
846
  consecutiveFailures = 0;
844
847
  return config;
@@ -8,6 +8,7 @@
8
8
  * text on the login page (content spoofing / phishing). Known codes get their sentence; anything else gets one generic
9
9
  * sentence; nothing from the URL is ever shown verbatim.
10
10
  */
11
+ import { type SignInFailureCode } from './sign-in-failure';
11
12
  export declare const GENERIC_LOGIN_ERROR = "We could not sign you in. Please try again.";
12
13
  /**
13
14
  * The sentence for `?error=<code>`, or null when nothing should be shown.
@@ -16,3 +17,5 @@ export declare const GENERIC_LOGIN_ERROR = "We could not sign you in. Please try
16
17
  export declare function friendlyLoginError(code: string | null | undefined): string | null;
17
18
  /** `?oauth_error=<provider>`: only a plain provider-like slug is echoed (capitalised); anything else is the generic sentence. */
18
19
  export declare function oauthFailureMessage(provider: string | null | undefined): string | null;
20
+ /** The machine code (data-error-code) for a `?error=` code, or null when it has none in the closed table. Nothing from the URL is echoed. */
21
+ export declare function loginErrorCode(code: string | null | undefined): SignInFailureCode | null;
@@ -13,12 +13,19 @@ Object.defineProperty(exports, "__esModule", { value: true });
13
13
  exports.GENERIC_LOGIN_ERROR = void 0;
14
14
  exports.friendlyLoginError = friendlyLoginError;
15
15
  exports.oauthFailureMessage = oauthFailureMessage;
16
+ exports.loginErrorCode = loginErrorCode;
17
+ const sign_in_failure_1 = require("./sign-in-failure");
16
18
  exports.GENERIC_LOGIN_ERROR = 'We could not sign you in. Please try again.';
17
19
  const KNOWN = {
18
20
  TwoFactorTimeout: 'Your verification step timed out before the code was confirmed. Please sign in again.',
19
21
  AUTHORIZE_RESUME_REFUSED: 'That sign-in request could not be continued. Please sign in again.',
22
+ // PAY-2279 / PAY-2005(b): the one IdP refusal code the abort path forwards (IDP_ERROR_CODES_TO_FORWARD). Wording is the card's proposal.
23
+ // (OAUTH_SIGN_IN_FAILED has no sentence in THIS change: PR 1606 (PAY-2554) owns that code's redirect and its sentence; once both land the map carries both.)
24
+ REGISTRATION_NOT_ALLOWED: 'Registration is not open for this site. Ask your administrator for an invitation.',
20
25
  ServiceUnavailable: 'Authentication service is temporarily unavailable. Please try again in a few minutes.',
21
- CredentialsSignin: 'Invalid email or password. Please try again.',
26
+ CredentialsSignin: sign_in_failure_1.SIGN_IN_FAILURE_COPY.invalid_credentials,
27
+ // PAY-2554: the neutral code the OAuth callback failure / state-error / aborted-exchange redirects carry (better-auth.ts OAUTH_SIGN_IN_FAILED_ERROR).
28
+ OAUTH_SIGN_IN_FAILED: "Sign-in didn't complete. Please try again, or use email.",
22
29
  };
23
30
  /**
24
31
  * The sentence for `?error=<code>`, or null when nothing should be shown.
@@ -45,3 +52,7 @@ function oauthFailureMessage(provider) {
45
52
  return exports.GENERIC_LOGIN_ERROR;
46
53
  return `${provider.charAt(0).toUpperCase()}${provider.slice(1)} sign-in did not complete. Please try again.`;
47
54
  }
55
+ /** The machine code (data-error-code) for a `?error=` code, or null when it has none in the closed table. Nothing from the URL is echoed. */
56
+ function loginErrorCode(code) {
57
+ return code === 'CredentialsSignin' ? 'invalid_credentials' : null;
58
+ }
@@ -0,0 +1,12 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ /**
3
+ * PAY-2631: the TOTP enrol/confirm handlers forward the browser body verbatim, so a cross-site form post
4
+ * (Content-Type text/plain, application/x-www-form-urlencoded) could reach them. A browser cannot send
5
+ * application/json cross-site without a CORS preflight, so requiring it closes that path.
6
+ * The responses carry the shared secret and the otpauth URI, so they are never cached.
7
+ */
8
+ export declare const NO_STORE: {
9
+ readonly 'Cache-Control': "no-store";
10
+ };
11
+ /** A 415 response when the request is not application/json, otherwise null. */
12
+ export declare function refuseNonJson(req: NextRequest): NextResponse | null;
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.NO_STORE = void 0;
4
+ exports.refuseNonJson = refuseNonJson;
5
+ const server_1 = require("next/server");
6
+ /**
7
+ * PAY-2631: the TOTP enrol/confirm handlers forward the browser body verbatim, so a cross-site form post
8
+ * (Content-Type text/plain, application/x-www-form-urlencoded) could reach them. A browser cannot send
9
+ * application/json cross-site without a CORS preflight, so requiring it closes that path.
10
+ * The responses carry the shared secret and the otpauth URI, so they are never cached.
11
+ */
12
+ exports.NO_STORE = { 'Cache-Control': 'no-store' };
13
+ /** A 415 response when the request is not application/json, otherwise null. */
14
+ function refuseNonJson(req) {
15
+ const ct = (req.headers.get('content-type') || '').toLowerCase();
16
+ if (ct.split(';')[0].trim() === 'application/json')
17
+ return null;
18
+ return server_1.NextResponse.json({ success: false, message: 'Content-Type must be application/json', error: { code: 'UNSUPPORTED_MEDIA_TYPE' } }, { status: 415, headers: exports.NO_STORE });
19
+ }
@@ -45,6 +45,7 @@ exports.resolveNextAuthSecret = resolveNextAuthSecret;
45
45
  require("server-only");
46
46
  const node_crypto_1 = require("node:crypto");
47
47
  const secret_validation_1 = require("./secret-validation");
48
+ const payez_client_secret_1 = require("./payez-client-secret");
48
49
  let cachedSecret = null;
49
50
  let lastFetchedAt = 0;
50
51
  const CACHE_TTL_MS = 5 * 60 * 1000;
@@ -88,6 +89,7 @@ async function resolveNextAuthSecret() {
88
89
  subject: clientIdStr,
89
90
  audience: 'urn:payez:externalauth:nextauthsecret',
90
91
  expires_in: 60,
92
+ ...(0, payez_client_secret_1.clientSecretBody)(),
91
93
  }),
92
94
  cache: 'no-store',
93
95
  });
@@ -115,7 +117,7 @@ async function resolveNextAuthSecret() {
115
117
  'X-Client-Id': clientIdStr,
116
118
  'X-Correlation-Id': (0, node_crypto_1.randomUUID)().replace(/-/g, ''),
117
119
  },
118
- body: JSON.stringify({ client_assertion: clientAssertion }),
120
+ body: JSON.stringify({ client_assertion: clientAssertion, ...(0, payez_client_secret_1.clientSecretBody)() }),
119
121
  cache: 'no-store',
120
122
  });
121
123
  if (!proxyResp.ok) {
@@ -0,0 +1,14 @@
1
+ export declare const CLIENT_SECRET_ENV = "PAYEZ_CLIENT_SECRET";
2
+ /** `{ client_secret }` when the env var is set to a non-blank value, else `{}`. Spread it into a JSON body. */
3
+ export declare function clientSecretBody(): {
4
+ client_secret?: string;
5
+ };
6
+ /**
7
+ * The IdP marks a view whose secrets it withheld with the response header X-PayEz-Secrets-Redacted: true. An app with no
8
+ * <PROVIDER>_CLIENT_SECRET in env then boots and the provider fails at the code exchange. Say so, once per provider per
9
+ * process, naming the provider only.
10
+ */
11
+ export declare function warnIfProviderSecretRedacted(redactedHeader: string | null | undefined, providers: ReadonlyArray<{
12
+ provider: string;
13
+ enabled?: boolean;
14
+ }>, envVarFor: (provider: string) => string): void;
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CLIENT_SECRET_ENV = void 0;
4
+ exports.clientSecretBody = clientSecretBody;
5
+ exports.warnIfProviderSecretRedacted = warnIfProviderSecretRedacted;
6
+ /**
7
+ * PAY-1460 (BAPert 86061): the app's own client secret, so the IdP can tell this app from a caller that merely knows its
8
+ * client id. One env var per app, PAYEZ_CLIENT_SECRET, read at CALL time (not a module-load snapshot: the deployment may
9
+ * inject it late). Unset or blank: NOTHING is added to any request body, so an app that has no secret yet sends exactly
10
+ * what it sent before. The value is never logged and never leaves in a response (markNeverInResponse).
11
+ */
12
+ const never_in_response_1 = require("./never-in-response");
13
+ exports.CLIENT_SECRET_ENV = 'PAYEZ_CLIENT_SECRET';
14
+ /** `{ client_secret }` when the env var is set to a non-blank value, else `{}`. Spread it into a JSON body. */
15
+ function clientSecretBody() {
16
+ const v = process.env[exports.CLIENT_SECRET_ENV];
17
+ if (typeof v !== 'string' || v.trim() === '')
18
+ return {};
19
+ (0, never_in_response_1.markNeverInResponse)(v);
20
+ return { client_secret: v };
21
+ }
22
+ const warnedProviders = new Set();
23
+ /**
24
+ * The IdP marks a view whose secrets it withheld with the response header X-PayEz-Secrets-Redacted: true. An app with no
25
+ * <PROVIDER>_CLIENT_SECRET in env then boots and the provider fails at the code exchange. Say so, once per provider per
26
+ * process, naming the provider only.
27
+ */
28
+ function warnIfProviderSecretRedacted(redactedHeader, providers, envVarFor) {
29
+ if (String(redactedHeader ?? '').trim().toLowerCase() !== 'true')
30
+ return;
31
+ for (const p of providers) {
32
+ if (!p.enabled || !p.provider)
33
+ continue;
34
+ if (process.env[envVarFor(p.provider)])
35
+ continue;
36
+ if (warnedProviders.has(p.provider))
37
+ continue;
38
+ warnedProviders.add(p.provider);
39
+ console.warn(`[IDP_CONFIG] PAYEZ_PROVIDER_SECRET_MISSING_REDACTED provider=${p.provider}`);
40
+ }
41
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * qrcode.ts — minimal QR Code generator (byte mode, ECC level M), a
3
+ * TypeScript port of Nayuki's qrcodegen (MIT) trimmed to what TOTP
4
+ * otpauth:// URIs need. Renders to an SVG path string (no library, no
5
+ * canvas) — D3 MFA page generates the QR client-side per contract
6
+ * (fe-be-contract §D3.1: "no BE QR image needed").
7
+ *
8
+ * Cross-checked bit-exact against python `qrcode` (see D3 day-end report).
9
+ * PAY-2631: copied verbatim from VibeID-Customer-Portal src/lib/qrcode.ts so the generated app draws the QR itself:
10
+ * the TOTP secret is never sent to a third-party QR service.
11
+ */
12
+ export interface QrMatrix {
13
+ size: number;
14
+ modules: boolean[][];
15
+ }
16
+ export declare function debugFinalBytes(text: string): {
17
+ version: number;
18
+ bytes: number[];
19
+ };
20
+ export declare function encodeQr(text: string, maskOverride?: number): QrMatrix;
21
+ /** Render to an SVG path (dark modules as 1x1 squares). */
22
+ export declare function qrToSvgPath(qr: QrMatrix): string;