@payez/next-mvp 4.1.21 → 4.4.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 (178) hide show
  1. package/POST-INSTALL.md +408 -0
  2. package/README.md +7 -0
  3. package/dist/api-handlers/account/recovery/initiate.js +35 -2
  4. package/dist/api-handlers/account/recovery/send-code.d.ts +0 -33
  5. package/dist/api-handlers/account/recovery/send-code.js +35 -2
  6. package/dist/api-handlers/account/recovery/verify-code.d.ts +0 -33
  7. package/dist/api-handlers/account/recovery/verify-code.js +35 -2
  8. package/dist/api-handlers/account/reset-password.js +35 -2
  9. package/dist/api-handlers/account/validate-password.js +21 -3
  10. package/dist/api-handlers/admin/analytics.js +2 -1
  11. package/dist/api-handlers/admin/audit.js +2 -1
  12. package/dist/api-handlers/admin/sessions.js +2 -1
  13. package/dist/api-handlers/admin/stats.js +2 -1
  14. package/dist/api-handlers/admin/users.js +2 -1
  15. package/dist/api-handlers/admin/vibe-data.js +2 -1
  16. package/dist/api-handlers/auth/jwks.js +10 -0
  17. package/dist/api-handlers/auth/login.js +5 -2
  18. package/dist/api-handlers/auth/passkey-signin.js +16 -1
  19. package/dist/api-handlers/auth/refresh.d.ts +23 -3
  20. package/dist/api-handlers/auth/refresh.js +107 -16
  21. package/dist/api-handlers/auth/signout.js +3 -2
  22. package/dist/api-handlers/auth/verify-code.js +5 -2
  23. package/dist/api-handlers/session/viability.js +27 -1
  24. package/dist/api-handlers/test/force-expire.d.ts +29 -15
  25. package/dist/api-handlers/test/force-expire.js +109 -14
  26. package/dist/auth/better-auth.d.ts +263 -6
  27. package/dist/auth/better-auth.js +437 -59
  28. package/dist/auth/utils/idp-client.js +1 -1
  29. package/dist/client/AuthContext.js +49 -17
  30. package/dist/client/better-auth-client.d.ts +62 -62
  31. package/dist/components/account/UserAvatarMenu.js +12 -0
  32. package/dist/components/auth/ModeAwareLoginPage.js +10 -8
  33. package/dist/components/auth/ModeAwareSignupPage.js +5 -2
  34. package/dist/components/auth/TraditionalAuthSection.js +5 -1
  35. package/dist/hooks/useAuthSettings.js +14 -2
  36. package/dist/hooks/usePublicAuthSettings.d.ts +2 -2
  37. package/dist/hooks/usePublicAuthSettings.js +28 -14
  38. package/dist/lib/client-key.d.ts +61 -0
  39. package/dist/lib/client-key.js +72 -0
  40. package/dist/lib/demo-mode.js +2 -1
  41. package/dist/lib/ensure-fresh-access-token.js +5 -1
  42. package/dist/lib/idp-client-config.d.ts +25 -2
  43. package/dist/lib/idp-client-config.js +381 -36
  44. package/dist/lib/idp-fetch.d.ts +2 -0
  45. package/dist/lib/idp-fetch.js +31 -1
  46. package/dist/lib/idp-url-guard.d.ts +29 -0
  47. package/dist/lib/idp-url-guard.js +38 -0
  48. package/dist/lib/redis.js +77 -5
  49. package/dist/lib/session-cookie-shape.d.ts +69 -0
  50. package/dist/lib/session-cookie-shape.js +126 -0
  51. package/dist/lib/session-store.d.ts +35 -2
  52. package/dist/lib/session-store.js +135 -4
  53. package/dist/lib/single-flight.d.ts +35 -0
  54. package/dist/lib/single-flight.js +30 -0
  55. package/dist/lib/token-lifecycle.js +12 -0
  56. package/dist/middleware/create-middleware.js +12 -0
  57. package/dist/models/SessionModel.d.ts +41 -0
  58. package/dist/models/SessionModel.js +9 -0
  59. package/dist/pages/admin-login/page.d.ts +1 -1
  60. package/dist/pages/admin-login/page.js +5 -41
  61. package/dist/pages/security/EnhancedSecurityPage.d.ts +35 -1
  62. package/dist/pages/security/EnhancedSecurityPage.js +42 -2
  63. package/dist/pages/security/index.d.ts +1 -0
  64. package/dist/pages/security/index.js +3 -0
  65. package/dist/pages/test-env/EmergencyLogoutPage.d.ts +17 -5
  66. package/dist/pages/test-env/EmergencyLogoutPage.js +17 -5
  67. package/dist/pages/test-env/JwtInspectPage.d.ts +30 -5
  68. package/dist/pages/test-env/JwtInspectPage.js +166 -7
  69. package/dist/pages/test-env/RefreshTokenPage.d.ts +22 -6
  70. package/dist/pages/test-env/RefreshTokenPage.js +22 -6
  71. package/dist/pages/test-env/TestEnvPage.d.ts +16 -4
  72. package/dist/pages/test-env/TestEnvPage.js +16 -4
  73. package/dist/pages/test-env/guarded.d.ts +4 -0
  74. package/dist/pages/test-env/guarded.js +86 -0
  75. package/dist/pages/test-env/index.d.ts +50 -7
  76. package/dist/pages/test-env/index.js +58 -12
  77. package/dist/routes/auth/logout.js +3 -2
  78. package/dist/routes/auth/refresh.js +27 -1
  79. package/dist/routes/auth/session.d.ts +12 -3
  80. package/dist/routes/auth/session.js +92 -9
  81. package/dist/routes/auth/settings.d.ts +34 -4
  82. package/dist/routes/auth/settings.js +87 -33
  83. package/dist/routes/auth/viability.d.ts +1 -1
  84. package/dist/routes/auth/viability.js +122 -11
  85. package/dist/server/auth-guard.js +22 -15
  86. package/dist/server/auth.d.ts +177 -20
  87. package/dist/server/auth.js +34 -75
  88. package/dist/server/decode-session.js +84 -10
  89. package/dist/types/auth.d.ts +18 -0
  90. package/package.json +969 -958
  91. package/scripts/check-internal-url-usage.sh +73 -73
  92. package/scripts/check-publish-health.mjs +120 -0
  93. package/scripts/dev-broker.ps1 +71 -6
  94. package/scripts/print-client-config.mjs +162 -0
  95. package/scripts/server-only-stub.cjs +4 -0
  96. package/src/api-handlers/account/recovery/initiate.ts +35 -2
  97. package/src/api-handlers/account/recovery/send-code.ts +36 -2
  98. package/src/api-handlers/account/recovery/verify-code.ts +36 -2
  99. package/src/api-handlers/account/reset-password.ts +35 -2
  100. package/src/api-handlers/account/validate-password.ts +22 -3
  101. package/src/api-handlers/admin/analytics.ts +2 -1
  102. package/src/api-handlers/admin/audit.ts +2 -1
  103. package/src/api-handlers/admin/sessions.ts +2 -1
  104. package/src/api-handlers/admin/stats.ts +2 -1
  105. package/src/api-handlers/admin/users.ts +2 -1
  106. package/src/api-handlers/admin/vibe-data.ts +2 -1
  107. package/src/api-handlers/auth/jwks.ts +10 -0
  108. package/src/api-handlers/auth/login.ts +5 -2
  109. package/src/api-handlers/auth/passkey-signin.ts +112 -98
  110. package/src/api-handlers/auth/refresh.ts +112 -15
  111. package/src/api-handlers/auth/signout.ts +4 -3
  112. package/src/api-handlers/auth/verify-code.ts +5 -2
  113. package/src/api-handlers/mfa/backup-code-verify.ts +175 -175
  114. package/src/api-handlers/mfa/backup-codes.ts +41 -41
  115. package/src/api-handlers/mfa/methods.ts +46 -46
  116. package/src/api-handlers/mfa/totp-confirm.ts +42 -42
  117. package/src/api-handlers/mfa/totp-enroll.ts +42 -42
  118. package/src/api-handlers/passkeys/list.ts +31 -31
  119. package/src/api-handlers/passkeys/manage.ts +54 -54
  120. package/src/api-handlers/passkeys/register-options.ts +42 -42
  121. package/src/api-handlers/passkeys/register-verify.ts +41 -41
  122. package/src/api-handlers/session/viability.ts +29 -1
  123. package/src/api-handlers/test/force-expire.ts +118 -13
  124. package/src/auth/better-auth.ts +486 -51
  125. package/src/auth/utils/idp-client.ts +1 -1
  126. package/src/client/AuthContext.tsx +55 -19
  127. package/src/components/account/UserAvatarMenu.tsx +11 -0
  128. package/src/components/auth/ModeAwareLoginPage.tsx +40 -49
  129. package/src/components/auth/ModeAwareSignupPage.tsx +5 -2
  130. package/src/components/auth/TraditionalAuthSection.tsx +5 -1
  131. package/src/hooks/useAuthSettings.ts +14 -2
  132. package/src/hooks/usePublicAuthSettings.ts +38 -16
  133. package/src/lib/client-key.ts +88 -0
  134. package/src/lib/demo-mode.ts +2 -1
  135. package/src/lib/ensure-fresh-access-token.ts +5 -1
  136. package/src/lib/idp-client-config.ts +399 -36
  137. package/src/lib/idp-fetch.ts +36 -1
  138. package/src/lib/idp-url-guard.ts +37 -0
  139. package/src/lib/redis.ts +84 -6
  140. package/src/lib/session-cookie-shape.ts +184 -0
  141. package/src/lib/session-store.ts +152 -6
  142. package/src/lib/single-flight.ts +64 -0
  143. package/src/lib/token-lifecycle.ts +12 -0
  144. package/src/middleware/create-middleware.ts +11 -0
  145. package/src/models/SessionModel.ts +49 -0
  146. package/src/pages/admin-login/page.tsx +17 -127
  147. package/src/pages/security/EnhancedSecurityPage.tsx +95 -9
  148. package/src/pages/security/index.ts +3 -0
  149. package/src/pages/test-env/EmergencyLogoutPage.tsx +17 -5
  150. package/src/pages/test-env/JwtInspectPage.tsx +235 -20
  151. package/src/pages/test-env/RefreshTokenPage.tsx +22 -6
  152. package/src/pages/test-env/TestEnvPage.tsx +16 -4
  153. package/src/pages/test-env/guarded.tsx +81 -0
  154. package/src/pages/test-env/index.ts +59 -7
  155. package/src/routes/auth/logout.ts +4 -3
  156. package/src/routes/auth/passkey-signin.ts +13 -13
  157. package/src/routes/auth/refresh.ts +30 -1
  158. package/src/routes/auth/session.ts +98 -9
  159. package/src/routes/auth/settings.ts +144 -42
  160. package/src/routes/auth/viability.ts +129 -11
  161. package/src/routes/mfa/backup-code-verify.ts +1 -1
  162. package/src/routes/mfa/backup-codes.ts +10 -10
  163. package/src/routes/mfa/methods.ts +22 -22
  164. package/src/routes/mfa/totp-confirm.ts +10 -10
  165. package/src/routes/mfa/totp-enroll.ts +10 -10
  166. package/src/routes/mfa/totp-verify.ts +14 -14
  167. package/src/routes/passkeys/list.ts +10 -10
  168. package/src/routes/passkeys/manage.ts +23 -23
  169. package/src/routes/passkeys/register-options.ts +10 -10
  170. package/src/routes/passkeys/register-verify.ts +10 -10
  171. package/src/server/auth-guard.ts +24 -17
  172. package/src/server/auth.ts +37 -73
  173. package/src/server/decode-session.ts +95 -11
  174. package/src/templates/instrumentation.ts +21 -2
  175. package/src/types/auth.ts +20 -0
  176. package/dist/lib/auth-secret.d.ts +0 -17
  177. package/dist/lib/auth-secret.js +0 -116
  178. package/src/lib/auth-secret.ts +0 -137
@@ -2,20 +2,53 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.POST = POST;
4
4
  const server_1 = require("next/server");
5
+ const idp_client_config_1 = require("../../lib/idp-client-config");
6
+ const client_key_1 = require("../../lib/client-key");
5
7
  function getIdpUrl() {
6
8
  const url = process.env.IDP_URL;
7
9
  if (!url)
8
10
  throw new Error('[IDP_URL] FATAL: IDP_URL environment variable is REQUIRED.');
9
11
  return url;
10
12
  }
13
+ /**
14
+ * RECOVERY CLIENT KEY — cards 268012 (S12) and 268410.
15
+ *
16
+ * These four proxies populated NONE of PayEzClientIdResolver's six slots, so
17
+ * RequirePayEzUnAuthorized 404'd the request BEFORE the action ran and no
18
+ * recovery token was ever minted. That is why S12 failed at step 1.
19
+ *
20
+ * NOT `ENV_CONFIG.CLIENT_ID`, which is what card 268012 originally prescribed.
21
+ * That variable is the ENVIRONMENT-SPECIFIC NUMERIC id, and the IDP accepts a
22
+ * numeric with NO existence check (RequirePayEzUnAuthorizedAttribute.cs:31-33)
23
+ * while it VALIDATES a slug (:41-47). Hardcoding the numeric here would have
24
+ * added four more sites to the 268410 family and made a stale id answer for
25
+ * the WRONG TENANT on a password-reset path, silently. Slug-preferring, with
26
+ * the numeric fallback logged, is the same resolver the other nine sites use.
27
+ *
28
+ * SERVER VALUE WINS: client_id is applied AFTER the caller's payload spread.
29
+ * These are unauthenticated recovery endpoints, so a browser-supplied
30
+ * client_id would be a tenant-targeting lever. The caller does not get to
31
+ * choose which tenant their password reset is addressed to.
32
+ */
33
+ async function recoveryClientKey() {
34
+ try {
35
+ const config = await (0, idp_client_config_1.getIDPClientConfig)();
36
+ return (0, client_key_1.resolveClientKey)(config, 'account/reset-password').key;
37
+ }
38
+ catch (e) {
39
+ console.error('[account/reset-password] could not resolve IDP client config for client_id:', e);
40
+ return null;
41
+ }
42
+ }
11
43
  async function POST(req) {
12
44
  const IDP_URL = getIdpUrl();
13
45
  try {
14
46
  const payload = await req.json();
47
+ const clientKey = await recoveryClientKey();
15
48
  const upstream = await fetch(`${IDP_URL}/api/Account/reset-password`, {
16
49
  method: 'POST',
17
- headers: { 'Content-Type': 'application/json' },
18
- body: JSON.stringify(payload),
50
+ headers: { 'Content-Type': 'application/json', ...(clientKey ? { 'X-Client-Id': clientKey } : {}) },
51
+ body: JSON.stringify({ ...payload, ...(clientKey ? { client_id: clientKey } : {}) }),
19
52
  });
20
53
  const data = await upstream.json().catch(() => ({}));
21
54
  return server_1.NextResponse.json(data, { status: upstream.status });
@@ -20,7 +20,7 @@ async function POST(req) {
20
20
  }
21
21
  // Get IDP base URL and client ID from environment
22
22
  const idpBaseUrl = process.env.IDP_URL;
23
- const clientId = process.env.CLIENT_ID || process.env.NEXT_PUBLIC_CLIENT_ID;
23
+ const clientId = process.env.CLIENT_ID;
24
24
  if (!idpBaseUrl) {
25
25
  console.error('[VALIDATE_PASSWORD] IDP_URL not configured');
26
26
  return server_1.NextResponse.json({
@@ -61,8 +61,26 @@ async function POST(req) {
61
61
  headers: { 'Cache-Control': 'no-store' },
62
62
  });
63
63
  }
64
- // Return the IDP response with proper structure
65
- return server_1.NextResponse.json(responseData, {
64
+ /**
65
+ * 283211 - UNWRAP THE IDP ENVELOPE. Approved by BAPert 2026-08-30.
66
+ *
67
+ * The IDP answers this endpoint with an ENVELOPE - { success, data: { is_valid, score,
68
+ * failed_requirements, policy } } - and this line used to hand that straight back. The
69
+ * `as ValidatePasswordResponse` is a COMPILE-TIME ASSERTION AND PERFORMS NO RUNTIME
70
+ * TRANSFORMATION, so nothing ever reshaped it and the type told us it was fine.
71
+ *
72
+ * The consumer is usePasswordValidation, which reads `data.is_valid` at the top level and
73
+ * calls setIsValid with it. Against an envelope that is `undefined`, so isValid never
74
+ * became true; and EVERY error path in this file returns a FLAT { is_valid: false }. So the
75
+ * success shape was unreadable and the failure shape was readable-and-false: setIsValid(true)
76
+ * was UNREACHABLE, and the Change Password button's `canSubmit` could never enable.
77
+ *
78
+ * `responseData?.data ?? responseData` is defensive in both directions BY DESIGN: it unwraps
79
+ * the envelope we get today and passes a flat body through unchanged, so it does not break
80
+ * if the IDP is later made consistent with this file's own declared response type.
81
+ */
82
+ const unwrapped = responseData?.data ?? responseData;
83
+ return server_1.NextResponse.json(unwrapped, {
66
84
  status: 200,
67
85
  headers: { 'Cache-Control': 'no-store' },
68
86
  });
@@ -46,6 +46,7 @@ exports.createAnalyticsHandler = createAnalyticsHandler;
46
46
  const server_1 = require("next/server");
47
47
  const auth_1 = require("../../server/auth");
48
48
  const startup_init_1 = require("../../lib/startup-init");
49
+ const client_key_1 = require("../../lib/client-key");
49
50
  const roles_1 = require("../../lib/roles");
50
51
  async function checkAdminRole(request) {
51
52
  const session = await (0, auth_1.getSession)(request);
@@ -82,7 +83,7 @@ async function vibeServiceRequest(endpoint, options) {
82
83
  const proxyUrl = `${idpUrl}/api/vibe/proxy`;
83
84
  // Get the client slug from startup config for multi-client admin support
84
85
  const idpConfig = (0, startup_init_1.getStartupIDPConfig)();
85
- const idpClientId = idpConfig?.clientSlug || idpConfig?.clientId;
86
+ const idpClientId = (0, client_key_1.resolveClientKey)(idpConfig, 'admin/analytics').key;
86
87
  try {
87
88
  const res = await fetch(proxyUrl, {
88
89
  method: 'POST',
@@ -45,6 +45,7 @@ exports.createAuditHandler = createAuditHandler;
45
45
  const server_1 = require("next/server");
46
46
  const auth_1 = require("../../server/auth");
47
47
  const startup_init_1 = require("../../lib/startup-init");
48
+ const client_key_1 = require("../../lib/client-key");
48
49
  const roles_1 = require("../../lib/roles");
49
50
  async function checkAdminRole(request) {
50
51
  const session = await (0, auth_1.getSession)(request);
@@ -81,7 +82,7 @@ async function vibeServiceRequest(endpoint, options) {
81
82
  const proxyUrl = `${idpUrl}/api/vibe/proxy`;
82
83
  // Get the client slug from startup config for multi-client admin support
83
84
  const idpConfig = (0, startup_init_1.getStartupIDPConfig)();
84
- const idpClientId = idpConfig?.clientSlug || idpConfig?.clientId;
85
+ const idpClientId = (0, client_key_1.resolveClientKey)(idpConfig, 'admin/audit').key;
85
86
  try {
86
87
  const res = await fetch(proxyUrl, {
87
88
  method: 'POST',
@@ -46,6 +46,7 @@ exports.createSessionsHandler = createSessionsHandler;
46
46
  const server_1 = require("next/server");
47
47
  const auth_1 = require("../../server/auth");
48
48
  const startup_init_1 = require("../../lib/startup-init");
49
+ const client_key_1 = require("../../lib/client-key");
49
50
  const roles_1 = require("../../lib/roles");
50
51
  /**
51
52
  * Check if the current user has admin role
@@ -88,7 +89,7 @@ async function vibeServiceRequest(endpoint, options) {
88
89
  const proxyUrl = `${idpUrl}/api/vibe/proxy`;
89
90
  // Get the client slug from startup config for multi-client admin support
90
91
  const idpConfig = (0, startup_init_1.getStartupIDPConfig)();
91
- const idpClientId = idpConfig?.clientSlug || idpConfig?.clientId;
92
+ const idpClientId = (0, client_key_1.resolveClientKey)(idpConfig, 'admin/sessions').key;
92
93
  try {
93
94
  const res = await fetch(proxyUrl, {
94
95
  method: 'POST',
@@ -46,6 +46,7 @@ exports.createStatsHandler = createStatsHandler;
46
46
  const server_1 = require("next/server");
47
47
  const auth_1 = require("../../server/auth");
48
48
  const startup_init_1 = require("../../lib/startup-init");
49
+ const client_key_1 = require("../../lib/client-key");
49
50
  const redis_1 = require("../../lib/redis");
50
51
  const roles_1 = require("../../lib/roles");
51
52
  async function checkAdminRole(request) {
@@ -82,7 +83,7 @@ async function vibeServiceRequest(endpoint, options) {
82
83
  .digest('base64');
83
84
  const proxyUrl = `${idpUrl}/api/vibe/proxy`;
84
85
  const idpConfig = (0, startup_init_1.getStartupIDPConfig)();
85
- const idpClientId = idpConfig?.clientSlug || idpConfig?.clientId;
86
+ const idpClientId = (0, client_key_1.resolveClientKey)(idpConfig, 'admin/stats').key;
86
87
  try {
87
88
  const res = await fetch(proxyUrl, {
88
89
  method: 'POST',
@@ -45,6 +45,7 @@ exports.createUsersHandler = createUsersHandler;
45
45
  const server_1 = require("next/server");
46
46
  const auth_1 = require("../../server/auth");
47
47
  const startup_init_1 = require("../../lib/startup-init");
48
+ const client_key_1 = require("../../lib/client-key");
48
49
  const roles_1 = require("../../lib/roles");
49
50
  async function checkAdminRole(request) {
50
51
  const session = await (0, auth_1.getSession)(request);
@@ -81,7 +82,7 @@ async function vibeServiceRequest(endpoint, options) {
81
82
  const proxyUrl = `${idpUrl}/api/vibe/proxy`;
82
83
  // Get the client slug from startup config for multi-client admin support
83
84
  const idpConfig = (0, startup_init_1.getStartupIDPConfig)();
84
- const idpClientId = idpConfig?.clientSlug || idpConfig?.clientId;
85
+ const idpClientId = (0, client_key_1.resolveClientKey)(idpConfig, 'admin/users').key;
85
86
  try {
86
87
  const res = await fetch(proxyUrl, {
87
88
  method: 'POST',
@@ -52,6 +52,7 @@ exports.createQueryHandler = createQueryHandler;
52
52
  const server_1 = require("next/server");
53
53
  const auth_1 = require("../../server/auth");
54
54
  const startup_init_1 = require("../../lib/startup-init");
55
+ const client_key_1 = require("../../lib/client-key");
55
56
  const roles_1 = require("../../lib/roles");
56
57
  /**
57
58
  * Check if the current user has admin role
@@ -96,7 +97,7 @@ async function vibeServiceRequest(endpoint, options) {
96
97
  const proxyUrl = `${idpUrl}/api/vibe/proxy`;
97
98
  // Get the client slug from startup config for multi-client admin support
98
99
  const idpConfig = (0, startup_init_1.getStartupIDPConfig)();
99
- const idpClientId = idpConfig?.clientSlug || idpConfig?.clientId;
100
+ const idpClientId = (0, client_key_1.resolveClientKey)(idpConfig, 'admin/vibe-data').key;
100
101
  try {
101
102
  const res = await fetch(proxyUrl, {
102
103
  method: 'POST',
@@ -4,9 +4,12 @@ exports.GET = GET;
4
4
  const server_1 = require("next/server");
5
5
  const env_1 = require("../../config/env");
6
6
  const logger_1 = require("../../config/logger");
7
+ const idp_url_guard_1 = require("../../lib/idp-url-guard");
7
8
  async function GET(_req) {
8
9
  try {
9
10
  const url = `${env_1.ENV_CONFIG.IDP_URL}${env_1.API_ENDPOINTS.externalAuth.jwks}`;
11
+ // Direct fetch - the idpFetchJSON guard is not on this path.
12
+ (0, idp_url_guard_1.assertAbsoluteIdpUrl)(url);
10
13
  const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
11
14
  const bodyText = await res.text();
12
15
  try {
@@ -19,6 +22,13 @@ async function GET(_req) {
19
22
  }
20
23
  }
21
24
  catch (error) {
25
+ // This catch DISCARDED `error` entirely and answered a bare 502, so a config
26
+ // fault and a dead upstream were indistinguishable - and the guard's named
27
+ // message above would have been swallowed with them. The 502 body is left
28
+ // unchanged (it is a public response shape); the cause now reaches the log.
29
+ logger_1.logger.error('[AUTH_JWKS] Failed to fetch JWKS', {
30
+ message: error instanceof Error ? error.message : String(error),
31
+ });
22
32
  return server_1.NextResponse.json({ error: 'Failed to fetch JWKS' }, { status: 502 });
23
33
  }
24
34
  }
@@ -87,10 +87,13 @@ function createLoginHandler(config) {
87
87
  }
88
88
  // Extract client headers for forwarding to IDP
89
89
  const clientHeaders = {};
90
- // Forward client IP if available
90
+ // Forward client IP if available.
91
+ // 269334 / R21: forward the FULL chain, do NOT collapse to [0] - see the same
92
+ // change in lib/idp-fetch.ts. [0] is the forgeable client-supplied entry and
93
+ // collapsing to it destroys the chain the IDP's trusted-hop logic reads.
91
94
  const clientIp = req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip');
92
95
  if (clientIp) {
93
- clientHeaders['X-Forwarded-For'] = clientIp.split(',')[0].trim();
96
+ clientHeaders['X-Forwarded-For'] = clientIp;
94
97
  }
95
98
  // Forward user agent if available
96
99
  const userAgent = req.headers.get('user-agent');
@@ -5,6 +5,8 @@ exports.verifyPOST = verifyPOST;
5
5
  const server_1 = require("next/server");
6
6
  const env_1 = require("../../config/env");
7
7
  const idp_client_config_1 = require("../../lib/idp-client-config");
8
+ const client_key_1 = require("../../lib/client-key");
9
+ const idp_url_guard_1 = require("../../lib/idp-url-guard");
8
10
  /**
9
11
  * Passkey SIGN-IN ceremony (ANONYMOUS — no session exists yet):
10
12
  * optionsPOST -> POST /api/ExternalAuth/passwordless/passkey/options
@@ -21,7 +23,14 @@ const idp_client_config_1 = require("../../lib/idp-client-config");
21
23
  */
22
24
  async function clientKey() {
23
25
  const config = await (0, idp_client_config_1.getIDPClientConfig)();
24
- return config.clientSlug || String(config.clientId);
26
+ const { key } = (0, client_key_1.resolveClientKey)(config, 'auth/passkey-signin');
27
+ // Refuse rather than send an empty client_id. Card 268410: the graft's own
28
+ // copy of this path learned this the hard way — `|| ''` made an unset config
29
+ // look like a valid value and the IDP answered "client_id is required" in
30
+ // 7-9ms, which read as a request-shape bug for hours.
31
+ if (!key)
32
+ throw new Error('[passkey-signin] no IDP client slug or id resolved - refusing to send an empty client_id');
33
+ return key;
25
34
  }
26
35
  async function anonCall(req, path) {
27
36
  let parsed = {};
@@ -32,6 +41,10 @@ async function anonCall(req, path) {
32
41
  }
33
42
  catch { }
34
43
  const key = await clientKey();
44
+ // This route calls fetch directly, so it does NOT reach the guard inside
45
+ // idpFetchJSON. Same reasoning as the clientKey() throw above: name the missing
46
+ // config rather than sending a request built from it.
47
+ (0, idp_url_guard_1.assertAbsoluteIdpUrl)(`${env_1.ENV_CONFIG.IDP_URL}${path}`);
35
48
  const res = await fetch(`${env_1.ENV_CONFIG.IDP_URL}${path}`, {
36
49
  method: 'POST',
37
50
  headers: { 'Content-Type': 'application/json', 'X-Client-Id': key },
@@ -86,6 +99,8 @@ async function verifyPOST(req) {
86
99
  const nested = response && typeof response === 'object' && 'id' in response;
87
100
  const assertion = nested ? response : { ...rest, response };
88
101
  const key = await clientKey();
102
+ // Direct fetch - see the note in anonCall; the idpFetchJSON guard is not on this path.
103
+ (0, idp_url_guard_1.assertAbsoluteIdpUrl)(`${env_1.ENV_CONFIG.IDP_URL}/api/ExternalAuth/passwordless/passkey/verify`);
89
104
  const res = await fetch(`${env_1.ENV_CONFIG.IDP_URL}/api/ExternalAuth/passwordless/passkey/verify`, {
90
105
  method: 'POST',
91
106
  headers: { 'Content-Type': 'application/json', 'X-Client-Id': key },
@@ -4,14 +4,34 @@
4
4
  * ASK BEFORE EDITING - TESTED AND WORKING SYSTEM
5
5
  *
6
6
  * This handler manages the server-side refresh token cycle with:
7
- * - Better Auth session extraction
8
- * - Session token fallback for internal calls
7
+ * - Better Auth session extraction (the ONLY way a session is established here)
9
8
  * - PayEz IDP refresh token exchange
10
9
  * - Session state updates with new tokens
11
10
  * - Proper error handling and logging
12
11
  * - Single-use semantics enforcement
13
12
  *
14
- * @version 2.0
13
+ * ── 246007: THE `x-session-token` FALLBACK IS GONE. IT NOW HARD-DENIES. ────
14
+ * This list previously advertised an x-session-token fallback for internal
15
+ * callers as a current capability. That behaviour has been REMOVED: a request presenting `x-session-token` with
16
+ * no cookie-resolved session of its own is rejected 401 UNAUTHORIZED, because
17
+ * there is no session to establish ownership of the token it names. Accepting
18
+ * it let a caller refresh a session they did not own.
19
+ *
20
+ * The line is corrected here rather than merely deleted, and it is called out
21
+ * rather than quietly dropped, because THIS DOCSTRING SHIPS. It is compiled
22
+ * into dist and into the .d.ts, so it reaches every consumer's editor as hover
23
+ * text. QAPert-NightHawk caught that the fixed build was still describing the
24
+ * removed capability as a current feature (34973) — her verifier tests code
25
+ * properties and reads no comments, so a correct build could have published
26
+ * documentation asserting the opposite of what the code does.
27
+ *
28
+ * That matters twice over: an internal caller relying on the fallback gets a
29
+ * silent behavioural break while the docs promise it still works, and a
30
+ * published package that misdescribes its own auth behaviour is the same
31
+ * amplifier that put other defects onto three surfaces — shipped code carrying
32
+ * instructions for using it wrongly.
33
+ *
34
+ * @version 2.1
15
35
  */
16
36
  import { NextRequest, NextResponse } from 'next/server';
17
37
  interface RefreshConfig {
@@ -5,19 +5,40 @@
5
5
  * ASK BEFORE EDITING - TESTED AND WORKING SYSTEM
6
6
  *
7
7
  * This handler manages the server-side refresh token cycle with:
8
- * - Better Auth session extraction
9
- * - Session token fallback for internal calls
8
+ * - Better Auth session extraction (the ONLY way a session is established here)
10
9
  * - PayEz IDP refresh token exchange
11
10
  * - Session state updates with new tokens
12
11
  * - Proper error handling and logging
13
12
  * - Single-use semantics enforcement
14
13
  *
15
- * @version 2.0
14
+ * ── 246007: THE `x-session-token` FALLBACK IS GONE. IT NOW HARD-DENIES. ────
15
+ * This list previously advertised an x-session-token fallback for internal
16
+ * callers as a current capability. That behaviour has been REMOVED: a request presenting `x-session-token` with
17
+ * no cookie-resolved session of its own is rejected 401 UNAUTHORIZED, because
18
+ * there is no session to establish ownership of the token it names. Accepting
19
+ * it let a caller refresh a session they did not own.
20
+ *
21
+ * The line is corrected here rather than merely deleted, and it is called out
22
+ * rather than quietly dropped, because THIS DOCSTRING SHIPS. It is compiled
23
+ * into dist and into the .d.ts, so it reaches every consumer's editor as hover
24
+ * text. QAPert-NightHawk caught that the fixed build was still describing the
25
+ * removed capability as a current feature (34973) — her verifier tests code
26
+ * properties and reads no comments, so a correct build could have published
27
+ * documentation asserting the opposite of what the code does.
28
+ *
29
+ * That matters twice over: an internal caller relying on the fallback gets a
30
+ * silent behavioural break while the docs promise it still works, and a
31
+ * published package that misdescribes its own auth behaviour is the same
32
+ * amplifier that put other defects onto three surfaces — shipped code carrying
33
+ * instructions for using it wrongly.
34
+ *
35
+ * @version 2.1
16
36
  */
17
37
  Object.defineProperty(exports, "__esModule", { value: true });
18
38
  exports.POST = void 0;
19
39
  exports.createRefreshHandler = createRefreshHandler;
20
40
  const server_1 = require("next/server");
41
+ const crypto_1 = require("crypto");
21
42
  const auth_1 = require("../../server/auth");
22
43
  const session_store_1 = require("../../lib/session-store");
23
44
  const token_expiry_1 = require("../../lib/token-expiry");
@@ -49,12 +70,50 @@ function createRefreshHandler(config) {
49
70
  let sessionToken = (betterAuthSession?.session?.token);
50
71
  let userId = betterAuthSession?.user?.id;
51
72
  if (!sessionToken) {
52
- // Fallback: check for session token in header (for internal server-to-server calls)
73
+ // OWNERSHIP CHECK on the x-session-token fallback (246007). BAPert ruled
74
+ // 34708: ship it, accept the one-time re-login cost, do not gate on 240654.
75
+ //
76
+ // WHAT THIS CLOSED: the fallback took a session token from a CALLER-SUPPLIED
77
+ // header and acted on that session with no check that it belonged to the
78
+ // caller. /api/auth/* is in publicRoutes on the consuming surfaces, so an
79
+ // UNAUTHENTICATED caller who knew any session token could cause that session
80
+ // to be refreshed -- an identity assumption, and the precondition for the
81
+ // forced-logout chain via IDP reuse detection.
82
+ //
83
+ // WHY THE CHECK IS A HARD DENY RATHER THAN A COMPARISON, which is the part
84
+ // worth understanding before anyone "improves" it: this branch is reached
85
+ // ONLY when getBetterAuthSession() failed to resolve a session from the
86
+ // request's own cookie. The natural ownership check -- "the header may only
87
+ // name the session the caller's cookie already resolves to" -- therefore has
88
+ // nothing to compare against by construction. Any correct ownership test at
89
+ // this point evaluates to reject. So the check and outright removal coincide
90
+ // here; a more permissive version is not possible without moving the check
91
+ // somewhere it can actually see an authenticated identity.
92
+ //
93
+ // WHO THIS AFFECTS, and it is a deliberate accepted cost, not an oversight:
94
+ // internal callers (lib/internal-api internalRefresh, from api-handler and
95
+ // idp-fetch) forward the end user's cookie. Where that cookie resolves they
96
+ // never enter this branch and are unaffected. Where it does not -- sessions
97
+ // minted before the graft dual-write (8a5ac21) whose cookie carries only the
98
+ // __Secure- name, which the pre-240654 resolver could not read -- the refresh
99
+ // now fails and the user re-authenticates ONCE. Their next login writes both
100
+ // cookie names and they are permanently fine. That population cannot grow, does
101
+ // not age out (session TTL slides on every write), and does not self-heal on
102
+ // use (this handler sets no cookie) -- so the only ways out were to accept the
103
+ // re-login or leave the defect live. BAPert chose to accept it.
104
+ //
105
+ // If server-to-server refresh must work WITHOUT an end-user cookie, that needs
106
+ // real service authentication -- a shared secret, mTLS, or a network policy --
107
+ // never a header any client can set.
53
108
  const headerSessionToken = req.headers.get('x-session-token');
54
109
  if (headerSessionToken) {
55
- sessionToken = headerSessionToken;
56
- const currentSession = await (0, session_store_1.getSession)(sessionToken);
57
- userId = currentSession?.userId || currentSession?.email;
110
+ console.warn('[AUTH_REFRESH] REJECTED x-session-token: no cookie-resolved session to own it. ' +
111
+ 'A header-named session is not proof of ownership; the caller must present a ' +
112
+ 'session cookie this server can resolve.');
113
+ return server_1.NextResponse.json({
114
+ error: 'Session ownership could not be established',
115
+ code: 'UNAUTHORIZED',
116
+ }, { status: 401 });
58
117
  }
59
118
  }
60
119
  if (!sessionToken || !userId) {
@@ -102,19 +161,41 @@ function createRefreshHandler(config) {
102
161
  console.log(`║ 2FA Complete: ${String(!!currentSession.mfaVerified).padEnd(10)} ║`);
103
162
  console.log(`║ Auth Level (ACR): ${String(currentSession.authenticationLevel || '1').padEnd(10)} ║`);
104
163
  console.log('╚══════════════════════════════════════════════════════════════════════════════╝');
105
- // Try to acquire refresh lock to prevent concurrent refresh attempts
106
- const requestId = req.headers.get('x-request-id') ?? `refresh_${Date.now()}`;
164
+ // LOCK IDENTITY IS MINTED SERVER-SIDE. It must never come from a header.
165
+ //
166
+ // This was `req.headers.get('x-request-id') ?? \`refresh_${Date.now()}\``, and the
167
+ // value was used as the lock's `acquiredBy`. Below it, a branch let a caller
168
+ // PROCEED WITHOUT THE LOCK when the existing lock's acquiredBy equalled the id
169
+ // they had supplied. Since the caller chose that id, the branch was a lock
170
+ // bypass requiring no guesswork: send N concurrent requests all carrying the
171
+ // same x-request-id, and every one after the first skips the wait.
172
+ //
173
+ // That completed an attacker-controlled forced logout, because the serialisation
174
+ // is what prevents refresh-token REUSE, and the IDP treats reuse as compromise:
175
+ // IdentityRefreshTokenService:166 -> RevokeDescendantTokens ->
176
+ // IdentityRefreshTokenRepository:142 revokes every ACTIVE token for that USER
177
+ // issued after the reused one -- across all devices and all client apps. So a
178
+ // caller who knew one session token could log a user out everywhere by sending
179
+ // a few requests with a header value of their own choosing.
180
+ //
181
+ // Minting server-side removes the bypass by construction: no caller can produce
182
+ // a value that matches an in-flight lock, so the equality branch can never be
183
+ // true and is deleted rather than left inert -- an unreachable branch that
184
+ // trusts a header is a trap for whoever edits this next.
185
+ //
186
+ // The anti-deadlock intent that branch served is preserved by the wait path
187
+ // below, which is the correct handling anyway: a genuine duplicate waits for the
188
+ // in-flight refresh and returns its result instead of starting a second one.
189
+ // The header is still read, for TRACING ONLY, and never influences control flow.
190
+ const traceId = req.headers.get('x-request-id') ?? undefined;
191
+ const requestId = `refresh_${(0, crypto_1.randomUUID)()}`;
107
192
  const lockAcquired = await (0, session_store_1.acquireRefreshLock)(sessionToken, requestId, 5000);
108
193
  let weAcquiredLock = false;
109
194
  let releaseLockVersion;
110
195
  if (!lockAcquired.acquired) {
111
- const existingLock = await (0, session_store_1.checkRefreshLock)(sessionToken);
112
- if (existingLock && existingLock.acquiredBy === requestId) {
113
- console.info('[AUTH_REFRESH] Proceeding under existing caller-held lock', { requestId });
114
- }
115
- else {
196
+ {
116
197
  // Wait for the lock to release, then check if token is now fresh
117
- console.info('[AUTH_REFRESH] Refresh in progress by another request, waiting for completion...', { requestId });
198
+ console.info('[AUTH_REFRESH] Refresh in progress by another request, waiting for completion...', { requestId, traceId });
118
199
  const maxWaitMs = 5000;
119
200
  const checkIntervalMs = 200;
120
201
  const startWait = Date.now();
@@ -595,7 +676,17 @@ function createRefreshHandler(config) {
595
676
  bearerKeyId: newBearerKeyId || currentSession.bearerKeyId,
596
677
  authenticationMethods: amrClaims,
597
678
  authenticationLevel: acrLevel,
598
- mfaVerified: amrClaims.includes('mfa') || currentSession.mfaVerified,
679
+ // 170007 rule 2 (BAPert ruling on 121350): '||' is the wrong operator
680
+ // for a fact with an expiry. mfaVerified is derived from amrClaims,
681
+ // which is FRESH every exchange (decoded from the token just
682
+ // returned, falling back to the prior session's amr only on a
683
+ // genuine decode failure a few lines up — not on "this token
684
+ // doesn't currently carry mfa"). ORing in currentSession.mfaVerified
685
+ // made the flag MONOTONIC against a claim that DECAYS (mfa_time,
686
+ // 10 minutes): once true, it stayed true forever regardless of
687
+ // what the current token actually says, silently re-opening the
688
+ // 2FA bypass with no bug in the bridge. Re-derive only.
689
+ mfaVerified: amrClaims.includes('mfa'),
599
690
  mfaCompletedAt: mfaTime,
600
691
  mfaExpiresAt: mfaExpires,
601
692
  mfaValidityHours: mfaValidityHours
@@ -129,10 +129,11 @@ function createSignoutHandler() {
129
129
  console.warn('[SIGNOUT] Manual JWT decode failed to extract session token');
130
130
  }
131
131
  }
132
- // Delete Redis session if UUID was extracted
132
+ // Delete the session from EVERY store (181922): canonical + BA secondary.
133
+ // Canonical-only deletion let the ba: record resurrect the session.
133
134
  if (redisSessionToken) {
134
135
  try {
135
- await (0, session_store_1.deleteSession)(redisSessionToken);
136
+ await (0, session_store_1.deleteSessionEverywhere)(redisSessionToken);
136
137
  console.info('[SIGNOUT] Redis session cleanup successful', {
137
138
  sessionToken: redisSessionToken.substring(0, 8) + '...'
138
139
  });
@@ -57,8 +57,11 @@ function createVerifyCodeHandler() {
57
57
  });
58
58
  // Update tokens in Redis
59
59
  await (0, session_store_1.updateTokens)(sessionToken, accessToken, refreshToken, accessTokenExpires, refreshTokenExpires);
60
- // Mark 2FA as complete
61
- await (0, session_store_1.mark2FAComplete)(sessionToken);
60
+ // Mark 2FA as complete.
61
+ // 339563: pass the stepped-up access token so the session gets an
62
+ // mfaExpiresAt alongside mfaVerified. Without it the gate's `> 0` guard
63
+ // treats the missing expiry as "never expires".
64
+ await (0, session_store_1.mark2FAComplete)(sessionToken, accessToken);
62
65
  console.info('[VERIFY-CODE] 2FA completion successful', {
63
66
  sessionToken: sessionToken.substring(0, 8) + '...',
64
67
  userId: betterAuthSession?.user?.id
@@ -71,7 +71,33 @@ async function GET(req) {
71
71
  // CRITICAL: OAuth sessions without refresh token always need 2FA completion
72
72
  // to obtain a refresh token from the IDP. The OAuth callback sets mfaVerified=false
73
73
  // when no refresh_token is present. This must override the client config.
74
- if (sessionData.mfaVerified === false && !sessionData.idpRefreshToken) {
74
+ //
75
+ // *** R807 (BAPert, card 362550) — SECOND OF THE TWO SITES THE SIGNAL IS FOR. ***
76
+ // The `=== false` below reads as strict but had stopped behaving strictly: every
77
+ // construction site collapses an ABSENT mfaVerified to `false` (`?? false`), so by
78
+ // the time it is read here "the callback said false" and "we never knew" are the
79
+ // same value. This override therefore fired on EVERY session, not on OAuth sessions
80
+ // that genuinely lack a refresh token.
81
+ //
82
+ // The comment at line ~122 of this file already warned that fixing only ONE of the
83
+ // two viability implementations leaves an app mounting the other one blind. That
84
+ // applies here verbatim: routes/auth/viability.ts carries the same guard, and both
85
+ // are publicly exported entry points, so both must gate on the signal or the defect
86
+ // survives one export over.
87
+ //
88
+ // mfaSignalPresent === true -> the producer really emitted a value, so the `false`
89
+ // is a MEASUREMENT. Override.
90
+ // absent / false -> LEGACY session (minted before the signal existed) or
91
+ // a path with no IDP tokens. NOT a signal. Do not
92
+ // force a re-challenge.
93
+ //
94
+ // *** CONFINED, BY RULING. *** This signal is permissive by construction — it can
95
+ // only ever SUPPRESS this override, never grant access. Anything that GRANTS must
96
+ // keep gating on `mfaVerified`, which stays fail-closed at all five sites. R807 is
97
+ // void if this signal reaches an auth decision.
98
+ if (sessionData.mfaSignalPresent === true &&
99
+ sessionData.mfaVerified === false &&
100
+ !sessionData.idpRefreshToken) {
75
101
  requires2FA = true;
76
102
  }
77
103
  // CRITICAL: Check if MFA has expired (2FA TTL enforcement)
@@ -1,23 +1,37 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
2
  /**
3
- * Force-expire access token for testing refresh flow.
3
+ * ⚠️ DESTRUCTIVE TEST-ONLY HANDLER — DO NOT MOUNT IN PRODUCTION. ⚠️
4
4
  *
5
- * Sets the access token expiry to 2 minutes in the past,
6
- * which will trigger a refresh on the next API call.
5
+ * Force-expire access token for testing refresh flow. Sets the access token
6
+ * expiry to 2 minutes in the past, which will trigger a refresh on the next
7
+ * API call.
8
+ *
9
+ * THIS MUTATES SESSION STATE. It is a test affordance, not a product feature.
10
+ *
11
+ * ── WHY THIS WARNING EXISTS (QAPert 34983) ────────────────────────────────
12
+ * The usage block below is not decoration: THIS DOCSTRING IS THE SPREAD
13
+ * MECHANISM. It ships compiled into dist and into the .d.ts, so it reaches
14
+ * every consumer's editor as hover text, and it is a one-line copy-paste that
15
+ * mounts a destructive endpoint. Every one of the three surfaces this route
16
+ * ended up on followed exactly these instructions.
17
+ *
18
+ * The handler itself was fixed (244877: default-deny behind ENABLE_TEST_ROUTES,
19
+ * plus the caller-supplied-token IDOR removed). But a fixed handler shipped
20
+ * with unqualified mounting instructions still spreads: the guidance was never
21
+ * corrected alongside the code, and a package that documents a dangerous route
22
+ * as a plain convenience is instructing the weaker configuration.
23
+ *
24
+ * ── IF YOU MOUNT IT, MOUNT IT LIKE THIS ───────────────────────────────────
25
+ * Required: set ENABLE_TEST_ROUTES=true IN THAT ENVIRONMENT ONLY. Absent, empty
26
+ * or any other value default-DENIES (404). Never set it in production.
7
27
  *
8
- * Usage in consuming app:
9
28
  * ```typescript
10
- * // app/api/test/force-expire/route.ts
29
+ * // app/api/test/force-expire/route.ts — TEST/DEV ENVIRONMENTS ONLY
30
+ * // Requires ENABLE_TEST_ROUTES=true in that environment; 404s otherwise.
11
31
  * export { POST } from '@payez/next-mvp/api-handlers/test/force-expire';
12
32
  * ```
33
+ *
34
+ * If you do not need to exercise the refresh flow, DO NOT MOUNT THIS AT ALL.
35
+ * The safest configuration is the absence of the route.
13
36
  */
14
- export declare const POST: (req: NextRequest) => Promise<NextResponse<{
15
- success: boolean;
16
- error: string;
17
- }> | NextResponse<{
18
- success: boolean;
19
- previous: number | null;
20
- previousIso: string | null;
21
- newExpiry: number;
22
- newExpiryIso: string;
23
- }>>;
37
+ export declare const POST: (req: NextRequest) => Promise<NextResponse<unknown>>;