@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
@@ -1,6 +1,8 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
2
  import { idpFetchJSON } from '../../../lib/idp-fetch';
3
3
  import { ENV_CONFIG } from '../../../config/env';
4
+ import { getIDPClientConfig } from '../../../lib/idp-client-config';
5
+ import { resolveClientKey } from '../../../lib/client-key';
4
6
 
5
7
  /**
6
8
  * Account recovery — verify-code
@@ -36,6 +38,36 @@ import { ENV_CONFIG } from '../../../config/env';
36
38
  * @package @payez/next-mvp
37
39
  */
38
40
 
41
+ /**
42
+ * RECOVERY CLIENT KEY — cards 268012 (S12) and 268410.
43
+ *
44
+ * These four proxies populated NONE of PayEzClientIdResolver's six slots, so
45
+ * RequirePayEzUnAuthorized 404'd the request BEFORE the action ran and no
46
+ * recovery token was ever minted. That is why S12 failed at step 1.
47
+ *
48
+ * NOT `ENV_CONFIG.CLIENT_ID`, which is what card 268012 originally prescribed.
49
+ * That variable is the ENVIRONMENT-SPECIFIC NUMERIC id, and the IDP accepts a
50
+ * numeric with NO existence check (RequirePayEzUnAuthorizedAttribute.cs:31-33)
51
+ * while it VALIDATES a slug (:41-47). Hardcoding the numeric here would have
52
+ * added four more sites to the 268410 family and made a stale id answer for
53
+ * the WRONG TENANT on a password-reset path, silently. Slug-preferring, with
54
+ * the numeric fallback logged, is the same resolver the other nine sites use.
55
+ *
56
+ * SERVER VALUE WINS: client_id is applied AFTER the caller's payload spread.
57
+ * These are unauthenticated recovery endpoints, so a browser-supplied
58
+ * client_id would be a tenant-targeting lever. The caller does not get to
59
+ * choose which tenant their password reset is addressed to.
60
+ */
61
+ async function recoveryClientKey(): Promise<string | null> {
62
+ try {
63
+ const config = await getIDPClientConfig();
64
+ return resolveClientKey(config, 'account/recovery/verify-code').key;
65
+ } catch (e) {
66
+ console.error('[account/recovery/verify-code] could not resolve IDP client config for client_id:', e);
67
+ return null;
68
+ }
69
+ }
70
+
39
71
  export async function POST(req: NextRequest) {
40
72
  let payload: unknown = {};
41
73
  try {
@@ -47,13 +79,15 @@ export async function POST(req: NextRequest) {
47
79
  );
48
80
  }
49
81
 
82
+ const clientKey = await recoveryClientKey();
83
+
50
84
  const result = await idpFetchJSON(
51
85
  req,
52
86
  `${ENV_CONFIG.IDP_URL}/api/Account/recovery/verify-code`,
53
87
  {
54
88
  method: 'POST',
55
- headers: { 'Content-Type': 'application/json' },
56
- body: JSON.stringify(payload),
89
+ headers: { 'Content-Type': 'application/json', ...(clientKey ? { 'X-Client-Id': clientKey } : {}) },
90
+ body: JSON.stringify({ ...(payload as Record<string, unknown>), ...(clientKey ? { client_id: clientKey } : {}) }),
57
91
  }
58
92
  );
59
93
 
@@ -1,4 +1,6 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
+ import { getIDPClientConfig } from '../../lib/idp-client-config';
3
+ import { resolveClientKey } from '../../lib/client-key';
2
4
 
3
5
  function getIdpUrl(): string {
4
6
  const url = process.env.IDP_URL;
@@ -6,14 +8,45 @@ function getIdpUrl(): string {
6
8
  return url;
7
9
  }
8
10
 
11
+ /**
12
+ * RECOVERY CLIENT KEY — cards 268012 (S12) and 268410.
13
+ *
14
+ * These four proxies populated NONE of PayEzClientIdResolver's six slots, so
15
+ * RequirePayEzUnAuthorized 404'd the request BEFORE the action ran and no
16
+ * recovery token was ever minted. That is why S12 failed at step 1.
17
+ *
18
+ * NOT `ENV_CONFIG.CLIENT_ID`, which is what card 268012 originally prescribed.
19
+ * That variable is the ENVIRONMENT-SPECIFIC NUMERIC id, and the IDP accepts a
20
+ * numeric with NO existence check (RequirePayEzUnAuthorizedAttribute.cs:31-33)
21
+ * while it VALIDATES a slug (:41-47). Hardcoding the numeric here would have
22
+ * added four more sites to the 268410 family and made a stale id answer for
23
+ * the WRONG TENANT on a password-reset path, silently. Slug-preferring, with
24
+ * the numeric fallback logged, is the same resolver the other nine sites use.
25
+ *
26
+ * SERVER VALUE WINS: client_id is applied AFTER the caller's payload spread.
27
+ * These are unauthenticated recovery endpoints, so a browser-supplied
28
+ * client_id would be a tenant-targeting lever. The caller does not get to
29
+ * choose which tenant their password reset is addressed to.
30
+ */
31
+ async function recoveryClientKey(): Promise<string | null> {
32
+ try {
33
+ const config = await getIDPClientConfig();
34
+ return resolveClientKey(config, 'account/reset-password').key;
35
+ } catch (e) {
36
+ console.error('[account/reset-password] could not resolve IDP client config for client_id:', e);
37
+ return null;
38
+ }
39
+ }
40
+
9
41
  export async function POST(req: NextRequest) {
10
42
  const IDP_URL = getIdpUrl();
11
43
  try {
12
44
  const payload = await req.json();
45
+ const clientKey = await recoveryClientKey();
13
46
  const upstream = await fetch(`${IDP_URL}/api/Account/reset-password`, {
14
47
  method: 'POST',
15
- headers: { 'Content-Type': 'application/json' },
16
- body: JSON.stringify(payload),
48
+ headers: { 'Content-Type': 'application/json', ...(clientKey ? { 'X-Client-Id': clientKey } : {}) },
49
+ body: JSON.stringify({ ...payload, ...(clientKey ? { client_id: clientKey } : {}) }),
17
50
  });
18
51
  const data = await upstream.json().catch(() => ({}));
19
52
  return NextResponse.json(data, { status: upstream.status });
@@ -43,7 +43,7 @@ export async function POST(req: NextRequest) {
43
43
  // Get IDP base URL and client ID from environment
44
44
  const idpBaseUrl = process.env.IDP_URL;
45
45
 
46
- const clientId = process.env.CLIENT_ID || process.env.NEXT_PUBLIC_CLIENT_ID;
46
+ const clientId = process.env.CLIENT_ID;
47
47
 
48
48
  if (!idpBaseUrl) {
49
49
  console.error('[VALIDATE_PASSWORD] IDP_URL not configured');
@@ -96,8 +96,27 @@ export async function POST(req: NextRequest) {
96
96
  );
97
97
  }
98
98
 
99
- // Return the IDP response with proper structure
100
- return NextResponse.json(responseData as ValidatePasswordResponse, {
99
+ /**
100
+ * 283211 - UNWRAP THE IDP ENVELOPE. Approved by BAPert 2026-08-30.
101
+ *
102
+ * The IDP answers this endpoint with an ENVELOPE - { success, data: { is_valid, score,
103
+ * failed_requirements, policy } } - and this line used to hand that straight back. The
104
+ * `as ValidatePasswordResponse` is a COMPILE-TIME ASSERTION AND PERFORMS NO RUNTIME
105
+ * TRANSFORMATION, so nothing ever reshaped it and the type told us it was fine.
106
+ *
107
+ * The consumer is usePasswordValidation, which reads `data.is_valid` at the top level and
108
+ * calls setIsValid with it. Against an envelope that is `undefined`, so isValid never
109
+ * became true; and EVERY error path in this file returns a FLAT { is_valid: false }. So the
110
+ * success shape was unreadable and the failure shape was readable-and-false: setIsValid(true)
111
+ * was UNREACHABLE, and the Change Password button's `canSubmit` could never enable.
112
+ *
113
+ * `responseData?.data ?? responseData` is defensive in both directions BY DESIGN: it unwraps
114
+ * the envelope we get today and passes a flat body through unchanged, so it does not break
115
+ * if the IDP is later made consistent with this file's own declared response type.
116
+ */
117
+ const unwrapped = (responseData as any)?.data ?? responseData;
118
+
119
+ return NextResponse.json(unwrapped as ValidatePasswordResponse, {
101
120
  status: 200,
102
121
  headers: { 'Cache-Control': 'no-store' },
103
122
  });
@@ -11,6 +11,7 @@
11
11
  import { NextRequest, NextResponse } from 'next/server';
12
12
  import { getSession } from '../../server/auth';
13
13
  import { getStartupIDPConfig } from '../../lib/startup-init';
14
+ import { resolveClientKey } from '../../lib/client-key';
14
15
  import { ADMIN_ROLES, hasAnyRole } from '../../lib/roles';
15
16
 
16
17
  interface VibeRequestOptions {
@@ -66,7 +67,7 @@ async function vibeServiceRequest<T = unknown>(
66
67
 
67
68
  // Get the client slug from startup config for multi-client admin support
68
69
  const idpConfig = getStartupIDPConfig();
69
- const idpClientId = idpConfig?.clientSlug || idpConfig?.clientId;
70
+ const idpClientId = resolveClientKey(idpConfig, 'admin/analytics').key;
70
71
 
71
72
  try {
72
73
  const res = await fetch(proxyUrl, {
@@ -10,6 +10,7 @@
10
10
  import { NextRequest, NextResponse } from 'next/server';
11
11
  import { getSession } from '../../server/auth';
12
12
  import { getStartupIDPConfig } from '../../lib/startup-init';
13
+ import { resolveClientKey } from '../../lib/client-key';
13
14
  import { ADMIN_ROLES, hasAnyRole } from '../../lib/roles';
14
15
 
15
16
  interface VibeRequestOptions {
@@ -65,7 +66,7 @@ async function vibeServiceRequest<T = unknown>(
65
66
 
66
67
  // Get the client slug from startup config for multi-client admin support
67
68
  const idpConfig = getStartupIDPConfig();
68
- const idpClientId = idpConfig?.clientSlug || idpConfig?.clientId;
69
+ const idpClientId = resolveClientKey(idpConfig, 'admin/audit').key;
69
70
 
70
71
  try {
71
72
  const res = await fetch(proxyUrl, {
@@ -11,6 +11,7 @@
11
11
  import { NextRequest, NextResponse } from 'next/server';
12
12
  import { getSession } from '../../server/auth';
13
13
  import { getStartupIDPConfig } from '../../lib/startup-init';
14
+ import { resolveClientKey } from '../../lib/client-key';
14
15
  import { ADMIN_ROLES, hasAnyRole } from '../../lib/roles';
15
16
 
16
17
  interface VibeRequestOptions {
@@ -78,7 +79,7 @@ async function vibeServiceRequest<T = unknown>(
78
79
 
79
80
  // Get the client slug from startup config for multi-client admin support
80
81
  const idpConfig = getStartupIDPConfig();
81
- const idpClientId = idpConfig?.clientSlug || idpConfig?.clientId;
82
+ const idpClientId = resolveClientKey(idpConfig, 'admin/sessions').key;
82
83
 
83
84
  try {
84
85
  const res = await fetch(proxyUrl, {
@@ -11,6 +11,7 @@
11
11
  import { NextRequest, NextResponse } from 'next/server';
12
12
  import { getSession } from '../../server/auth';
13
13
  import { getStartupIDPConfig } from '../../lib/startup-init';
14
+ import { resolveClientKey } from '../../lib/client-key';
14
15
  import { getRedis } from '../../lib/redis';
15
16
  import { ADMIN_ROLES } from '../../lib/roles';
16
17
 
@@ -66,7 +67,7 @@ async function vibeServiceRequest<T = unknown>(
66
67
  const proxyUrl = `${idpUrl}/api/vibe/proxy`;
67
68
 
68
69
  const idpConfig = getStartupIDPConfig();
69
- const idpClientId = idpConfig?.clientSlug || idpConfig?.clientId;
70
+ const idpClientId = resolveClientKey(idpConfig, 'admin/stats').key;
70
71
 
71
72
  try {
72
73
  const res = await fetch(proxyUrl, {
@@ -10,6 +10,7 @@
10
10
  import { NextRequest, NextResponse } from 'next/server';
11
11
  import { getSession } from '../../server/auth';
12
12
  import { getStartupIDPConfig } from '../../lib/startup-init';
13
+ import { resolveClientKey } from '../../lib/client-key';
13
14
  import { ADMIN_ROLES, hasAnyRole } from '../../lib/roles';
14
15
 
15
16
  interface VibeRequestOptions {
@@ -65,7 +66,7 @@ async function vibeServiceRequest<T = unknown>(
65
66
 
66
67
  // Get the client slug from startup config for multi-client admin support
67
68
  const idpConfig = getStartupIDPConfig();
68
- const idpClientId = idpConfig?.clientSlug || idpConfig?.clientId;
69
+ const idpClientId = resolveClientKey(idpConfig, 'admin/users').key;
69
70
 
70
71
  try {
71
72
  const res = await fetch(proxyUrl, {
@@ -11,6 +11,7 @@
11
11
  import { NextRequest, NextResponse } from 'next/server';
12
12
  import { getSession } from '../../server/auth';
13
13
  import { getStartupIDPConfig } from '../../lib/startup-init';
14
+ import { resolveClientKey } from '../../lib/client-key';
14
15
  import { ADMIN_ROLES, hasAnyRole } from '../../lib/roles';
15
16
 
16
17
  interface VibeRequestOptions {
@@ -80,7 +81,7 @@ async function vibeServiceRequest<T = unknown>(
80
81
 
81
82
  // Get the client slug from startup config for multi-client admin support
82
83
  const idpConfig = getStartupIDPConfig();
83
- const idpClientId = idpConfig?.clientSlug || idpConfig?.clientId;
84
+ const idpClientId = resolveClientKey(idpConfig, 'admin/vibe-data').key;
84
85
 
85
86
  try {
86
87
  const res = await fetch(proxyUrl, {
@@ -1,10 +1,13 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
2
  import { ENV_CONFIG, API_ENDPOINTS } from '../../config/env';
3
3
  import { logger } from '../../config/logger';
4
+ import { assertAbsoluteIdpUrl } from '../../lib/idp-url-guard';
4
5
 
5
6
  export async function GET(_req: NextRequest) {
6
7
  try {
7
8
  const url = `${ENV_CONFIG.IDP_URL}${API_ENDPOINTS.externalAuth.jwks}`;
9
+ // Direct fetch - the idpFetchJSON guard is not on this path.
10
+ assertAbsoluteIdpUrl(url);
8
11
  const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
9
12
  const bodyText = await res.text();
10
13
  try {
@@ -15,6 +18,13 @@ export async function GET(_req: NextRequest) {
15
18
  return NextResponse.json({ error: 'Invalid JWKS response' }, { status: 502 });
16
19
  }
17
20
  } catch (error) {
21
+ // This catch DISCARDED `error` entirely and answered a bare 502, so a config
22
+ // fault and a dead upstream were indistinguishable - and the guard's named
23
+ // message above would have been swallowed with them. The 502 body is left
24
+ // unchanged (it is a public response shape); the cause now reaches the log.
25
+ logger.error('[AUTH_JWKS] Failed to fetch JWKS', {
26
+ message: error instanceof Error ? error.message : String(error),
27
+ });
18
28
  return NextResponse.json({ error: 'Failed to fetch JWKS' }, { status: 502 });
19
29
  }
20
30
  }
@@ -127,10 +127,13 @@ export function createLoginHandler(config: LoginConfig) {
127
127
  // Extract client headers for forwarding to IDP
128
128
  const clientHeaders: Record<string, string> = {};
129
129
 
130
- // Forward client IP if available
130
+ // Forward client IP if available.
131
+ // 269334 / R21: forward the FULL chain, do NOT collapse to [0] - see the same
132
+ // change in lib/idp-fetch.ts. [0] is the forgeable client-supplied entry and
133
+ // collapsing to it destroys the chain the IDP's trusted-hop logic reads.
131
134
  const clientIp = req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip');
132
135
  if (clientIp) {
133
- clientHeaders['X-Forwarded-For'] = clientIp.split(',')[0].trim();
136
+ clientHeaders['X-Forwarded-For'] = clientIp;
134
137
  }
135
138
 
136
139
  // Forward user agent if available
@@ -1,98 +1,112 @@
1
- import { NextRequest, NextResponse } from 'next/server';
2
- import { ENV_CONFIG } from '../../config/env';
3
- import { getIDPClientConfig } from '../../lib/idp-client-config';
4
-
5
- /**
6
- * Passkey SIGN-IN ceremony (ANONYMOUS — no session exists yet):
7
- * optionsPOST -> POST /api/ExternalAuth/passwordless/passkey/options
8
- * verifyPOST -> POST /api/ExternalAuth/passwordless/passkey/verify
9
- *
10
- * Anon idiom copied from auth/better-auth.ts (exchangeOAuthForIdpTokens):
11
- * direct fetch to the IDP, client identified by client_id from the resolved
12
- * IDP client config (slug preferred, numeric id fallback) — sent BOTH as the
13
- * X-Client-Id header (the probe-verified routing key) and merged into the
14
- * body when the caller did not supply one.
15
- *
16
- * The verify response is passed through WITHOUT unwrapping: this is a
17
- * sign-in, its payload carries the grant the caller's session layer consumes.
18
- */
19
-
20
- async function clientKey(): Promise<string> {
21
- const config = await getIDPClientConfig();
22
- return config.clientSlug || String(config.clientId);
23
- }
24
-
25
- async function anonCall(req: NextRequest, path: string) {
26
- let parsed: Record<string, unknown> = {};
27
- try {
28
- const raw = await req.text();
29
- if (raw && raw.trim().length > 0) parsed = JSON.parse(raw);
30
- } catch {}
31
-
32
- const key = await clientKey();
33
- const res = await fetch(`${ENV_CONFIG.IDP_URL}${path}`, {
34
- method: 'POST',
35
- headers: { 'Content-Type': 'application/json', 'X-Client-Id': key },
36
- body: JSON.stringify({ client_id: key, ...parsed }),
37
- cache: 'no-store',
38
- });
39
- const json = await res.json().catch(() => null);
40
- return { res, json };
41
- }
42
-
43
- export async function optionsPOST(req: NextRequest) {
44
- const { res, json } = await anonCall(req, '/api/ExternalAuth/passwordless/passkey/options');
45
- return NextResponse.json(json ?? { success: false, message: 'Empty upstream response' }, { status: res.status });
46
- }
47
-
48
- /**
49
- * VERIFY DOES NOT USE anonCall — IT MUST RESHAPE, NOT SPREAD.
50
- *
51
- * The IDP contract is three fields (PasskeyModels.cs:36 PasskeyAuthVerifyRequest):
52
- * assertion_id : string
53
- * response : the WHOLE AuthenticatorAssertionRawResponse from
54
- * navigator.credentials.get()
55
- * client_id : string
56
- *
57
- * anonCall SPREADS the caller's body (`{ client_id, ...parsed }`). For options
58
- * that is harmless. For verify it is fatal: spreading an assertion hoists
59
- * id/rawId/type to the top level and leaves `response` holding only the INNER
60
- * {clientDataJSON, authenticatorData, signature, userHandle}. The IDP's guard
61
- * then answers "response is required" in 4-12ms WITHOUT REACHING THE CRYPTO —
62
- * a timing signature DotNetPert used to prove the caller was still spreading
63
- * long after the graft-side plugin had been fixed.
64
- *
65
- * This route and the Better Auth plugin endpoint are TWO PATHS to the same IDP
66
- * endpoint. Fixing only the plugin left this one live, spreading, and reachable
67
- * on a URL one segment away from the correct one — which is what kept the
68
- * sign-in leg red across several runs.
69
- *
70
- * Accepts the assertion either already nested (`{ assertion_id, response }`) or
71
- * flat (`{ assertion_id, id, rawId, type, response }`) and normalises both, so a
72
- * caller written against either shape works.
73
- */
74
- export async function verifyPOST(req: NextRequest) {
75
- let parsed: Record<string, unknown> = {};
76
- try {
77
- const raw = await req.text();
78
- if (raw && raw.trim().length > 0) parsed = JSON.parse(raw);
79
- } catch { /* empty body -> the IDP's own validation answers */ }
80
-
81
- const { assertion_id: assertionId, response, ...rest } = parsed;
82
-
83
- // Already-nested callers send `response` holding a full assertion (it has an
84
- // `id`). Flat callers send the assertion's own fields alongside assertion_id,
85
- // and their `response` is the inner attestation half — not a whole assertion.
86
- const nested = response && typeof response === 'object' && 'id' in (response as object);
87
- const assertion = nested ? response : { ...rest, response };
88
-
89
- const key = await clientKey();
90
- const res = await fetch(`${ENV_CONFIG.IDP_URL}/api/ExternalAuth/passwordless/passkey/verify`, {
91
- method: 'POST',
92
- headers: { 'Content-Type': 'application/json', 'X-Client-Id': key },
93
- body: JSON.stringify({ client_id: key, assertion_id: assertionId, response: assertion }),
94
- cache: 'no-store',
95
- });
96
- const json = await res.json().catch(() => null);
97
- return NextResponse.json(json ?? { success: false, message: 'Empty upstream response' }, { status: res.status });
98
- }
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { ENV_CONFIG } from '../../config/env';
3
+ import { getIDPClientConfig } from '../../lib/idp-client-config';
4
+ import { resolveClientKey } from '../../lib/client-key';
5
+ import { assertAbsoluteIdpUrl } from '../../lib/idp-url-guard';
6
+
7
+ /**
8
+ * Passkey SIGN-IN ceremony (ANONYMOUS — no session exists yet):
9
+ * optionsPOST -> POST /api/ExternalAuth/passwordless/passkey/options
10
+ * verifyPOST -> POST /api/ExternalAuth/passwordless/passkey/verify
11
+ *
12
+ * Anon idiom copied from auth/better-auth.ts (exchangeOAuthForIdpTokens):
13
+ * direct fetch to the IDP, client identified by client_id from the resolved
14
+ * IDP client config (slug preferred, numeric id fallback) — sent BOTH as the
15
+ * X-Client-Id header (the probe-verified routing key) and merged into the
16
+ * body when the caller did not supply one.
17
+ *
18
+ * The verify response is passed through WITHOUT unwrapping: this is a
19
+ * sign-in, its payload carries the grant the caller's session layer consumes.
20
+ */
21
+
22
+ async function clientKey(): Promise<string> {
23
+ const config = await getIDPClientConfig();
24
+ const { key } = resolveClientKey(config, 'auth/passkey-signin');
25
+ // Refuse rather than send an empty client_id. Card 268410: the graft's own
26
+ // copy of this path learned this the hard way — `|| ''` made an unset config
27
+ // look like a valid value and the IDP answered "client_id is required" in
28
+ // 7-9ms, which read as a request-shape bug for hours.
29
+ if (!key) throw new Error('[passkey-signin] no IDP client slug or id resolved - refusing to send an empty client_id');
30
+ return key;
31
+ }
32
+
33
+ async function anonCall(req: NextRequest, path: string) {
34
+ let parsed: Record<string, unknown> = {};
35
+ try {
36
+ const raw = await req.text();
37
+ if (raw && raw.trim().length > 0) parsed = JSON.parse(raw);
38
+ } catch {}
39
+
40
+ const key = await clientKey();
41
+ // This route calls fetch directly, so it does NOT reach the guard inside
42
+ // idpFetchJSON. Same reasoning as the clientKey() throw above: name the missing
43
+ // config rather than sending a request built from it.
44
+ assertAbsoluteIdpUrl(`${ENV_CONFIG.IDP_URL}${path}`);
45
+ const res = await fetch(`${ENV_CONFIG.IDP_URL}${path}`, {
46
+ method: 'POST',
47
+ headers: { 'Content-Type': 'application/json', 'X-Client-Id': key },
48
+ body: JSON.stringify({ client_id: key, ...parsed }),
49
+ cache: 'no-store',
50
+ });
51
+ const json = await res.json().catch(() => null);
52
+ return { res, json };
53
+ }
54
+
55
+ export async function optionsPOST(req: NextRequest) {
56
+ const { res, json } = await anonCall(req, '/api/ExternalAuth/passwordless/passkey/options');
57
+ return NextResponse.json(json ?? { success: false, message: 'Empty upstream response' }, { status: res.status });
58
+ }
59
+
60
+ /**
61
+ * VERIFY DOES NOT USE anonCall — IT MUST RESHAPE, NOT SPREAD.
62
+ *
63
+ * The IDP contract is three fields (PasskeyModels.cs:36 PasskeyAuthVerifyRequest):
64
+ * assertion_id : string
65
+ * response : the WHOLE AuthenticatorAssertionRawResponse from
66
+ * navigator.credentials.get()
67
+ * client_id : string
68
+ *
69
+ * anonCall SPREADS the caller's body (`{ client_id, ...parsed }`). For options
70
+ * that is harmless. For verify it is fatal: spreading an assertion hoists
71
+ * id/rawId/type to the top level and leaves `response` holding only the INNER
72
+ * {clientDataJSON, authenticatorData, signature, userHandle}. The IDP's guard
73
+ * then answers "response is required" in 4-12ms WITHOUT REACHING THE CRYPTO —
74
+ * a timing signature DotNetPert used to prove the caller was still spreading
75
+ * long after the graft-side plugin had been fixed.
76
+ *
77
+ * This route and the Better Auth plugin endpoint are TWO PATHS to the same IDP
78
+ * endpoint. Fixing only the plugin left this one live, spreading, and reachable
79
+ * on a URL one segment away from the correct one — which is what kept the
80
+ * sign-in leg red across several runs.
81
+ *
82
+ * Accepts the assertion either already nested (`{ assertion_id, response }`) or
83
+ * flat (`{ assertion_id, id, rawId, type, response }`) and normalises both, so a
84
+ * caller written against either shape works.
85
+ */
86
+ export async function verifyPOST(req: NextRequest) {
87
+ let parsed: Record<string, unknown> = {};
88
+ try {
89
+ const raw = await req.text();
90
+ if (raw && raw.trim().length > 0) parsed = JSON.parse(raw);
91
+ } catch { /* empty body -> the IDP's own validation answers */ }
92
+
93
+ const { assertion_id: assertionId, response, ...rest } = parsed;
94
+
95
+ // Already-nested callers send `response` holding a full assertion (it has an
96
+ // `id`). Flat callers send the assertion's own fields alongside assertion_id,
97
+ // and their `response` is the inner attestation half — not a whole assertion.
98
+ const nested = response && typeof response === 'object' && 'id' in (response as object);
99
+ const assertion = nested ? response : { ...rest, response };
100
+
101
+ const key = await clientKey();
102
+ // Direct fetch - see the note in anonCall; the idpFetchJSON guard is not on this path.
103
+ assertAbsoluteIdpUrl(`${ENV_CONFIG.IDP_URL}/api/ExternalAuth/passwordless/passkey/verify`);
104
+ const res = await fetch(`${ENV_CONFIG.IDP_URL}/api/ExternalAuth/passwordless/passkey/verify`, {
105
+ method: 'POST',
106
+ headers: { 'Content-Type': 'application/json', 'X-Client-Id': key },
107
+ body: JSON.stringify({ client_id: key, assertion_id: assertionId, response: assertion }),
108
+ cache: 'no-store',
109
+ });
110
+ const json = await res.json().catch(() => null);
111
+ return NextResponse.json(json ?? { success: false, message: 'Empty upstream response' }, { status: res.status });
112
+ }