@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,3 +1,25 @@
1
+ /* ============================================================================
2
+ * ONE VALUE, ONE VARIABLE NAME. CLIENT_ID.
3
+ *
4
+ * DO NOT INTRODUCE A SECOND ENV VAR CARRYING A VALUE THAT ALREADY HAS ONE.
5
+ * Not with a prefix. Not "just for the client side". Not "temporarily".
6
+ *
7
+ * This value had THREE names before 2026-08-26:
8
+ * CLIENT_ID read by 8 server files - the real one
9
+ * NEXT_PUBLIC_CLIENT_ID read as a fallback in 6 places, ALL server-side
10
+ * NEXT_CLIENT_ID read by lib/demo-mode.ts, and set by dev-broker.ps1,
11
+ * which meant BROKER MODE NEVER PASSED THE CLIENT ID
12
+ * TO THE CONFIG AT ALL. The bug was the extra name.
13
+ *
14
+ * The NEXT_PUBLIC_ prefix exists so Next inlines a value into the BROWSER bundle.
15
+ * Nothing in the browser ever read it. So it bought nothing and merely LOOKED
16
+ * justified - which is worse than an obviously arbitrary name, because it stops
17
+ * the next person questioning it.
18
+ *
19
+ * IF YOU NEED THIS VALUE SOMEWHERE NEW, READ CLIENT_ID. If you genuinely need it
20
+ * in browser code, that is a real Next constraint - raise it, do not quietly add
21
+ * a second spelling and leave the collision for someone else to measure.
22
+ * ============================================================================ */
1
23
  /**
2
24
  * IDP Client Configuration
3
25
  *
@@ -19,8 +41,22 @@
19
41
  */
20
42
 
21
43
  import 'server-only';
22
- import { randomUUID } from 'crypto';
23
- import redis from './redis';
44
+ import { createHash, randomUUID } from 'crypto';
45
+ // REDIS IS IMPORTED LAZILY, AND THAT IS LOAD-BEARING.
46
+ // A module-scope `import redis from './redis'` drags ioredis into EVERY bundle that
47
+ // touches this file - including instrumentation.ts. Next compiles the instrumentation
48
+ // hook for the EDGE runtime as well as node, and ioredis internally does
49
+ // require('stream'), which does not exist on edge. The result was a BUILD ERROR in the
50
+ // browser overlay for a code path the edge build never executes.
51
+ //
52
+ // The NEXT_RUNTIME check in instrumentation.ts is a RUNTIME guard; the bundler still
53
+ // traces the import statically. Deferring the import to first use is what actually
54
+ // keeps ioredis out of the graph. All four call sites below are already async.
55
+ let _redisMod: typeof import('./redis').default | null = null;
56
+ async function getRedis() {
57
+ if (!_redisMod) _redisMod = (await import('./redis')).default;
58
+ return _redisMod;
59
+ }
24
60
 
25
61
  // ============================================================================
26
62
  // Types
@@ -36,8 +72,14 @@ export interface OAuthProviderConfig {
36
72
  }
37
73
 
38
74
  export interface AuthSettings {
39
- require2FA: boolean;
40
- allowed2FAMethods: string[];
75
+ // 257255 / BAPert v34 DATA sites: these two are POLICY. They are OPTIONAL
76
+ // because a config document that does not carry a policy must produce a
77
+ // config with NO policy - not an invented one. Consumers must handle
78
+ // undefined; routes/auth/settings.ts refuses to serve when they are absent.
79
+ // The remaining fields below are operational tuning, not authorization, so
80
+ // their defaults are not in this class.
81
+ require2FA?: boolean;
82
+ allowed2FAMethods?: string[];
41
83
  mfaGracePeriodHours: number;
42
84
  mfaRememberDeviceDays: number;
43
85
  sessionTimeoutMinutes: number;
@@ -46,6 +88,30 @@ export interface AuthSettings {
46
88
  rememberMeDays: number;
47
89
  lockoutThreshold: number;
48
90
  lockoutDurationMinutes: number;
91
+
92
+ /**
93
+ * 240447: tenant policy the IDP has always sent and this loader never read.
94
+ *
95
+ * The IDP emits these inside `auth_settings` on the internal client-config
96
+ * response (PayEz.Identity.Api InternalClientConfigController, the
97
+ * `auth_settings` object: allow_public_registration, allow_social_login,
98
+ * enable_password_reset — backed by IDPClientSecurityPolicy).
99
+ * routes/auth/settings was answering with hardcoded `true` literals
100
+ * carrying the comment "Could come from config.authSettings in future",
101
+ * so the portal advertised self-serve registration for a tenant whose IDP
102
+ * forbids it (measured same-client same-minute: IDP
103
+ * allow_public_registration=FALSE, portal allowPublicRegistration=TRUE).
104
+ *
105
+ * OPTIONAL ON PURPOSE — `undefined` must stay distinguishable from
106
+ * `false`. undefined = the IDP did not tell us (older IDP build, or
107
+ * auth_settings absent entirely); false = the IDP affirmatively said no.
108
+ * Collapsing the first into a boolean default is exactly how the previous
109
+ * hardcoded `true` became a lie that read like a measurement. Consumers
110
+ * must decide explicitly what an unknown means for them.
111
+ */
112
+ allowPublicRegistration?: boolean;
113
+ allowSocialLogin?: boolean;
114
+ enablePasswordReset?: boolean;
49
115
  }
50
116
 
51
117
  export interface BrandingConfig {
@@ -74,6 +140,50 @@ export interface IDPClientConfig {
74
140
  // Cache & Fetch Deduplication
75
141
  // ============================================================================
76
142
 
143
+ /**
144
+ * 261588 - THE OPERATOR'S SECRET, CAPTURED BEFORE THIS MODULE CAN CLOBBER IT.
145
+ *
146
+ * startup-init.ts:129 defers to an operator-set BETTER_AUTH_SECRET, and its sibling
147
+ * comment says that override is INTENDED ('allows deployment override for beta/staging').
148
+ * The config-refresh paths in this file then overwrote it minutes later, so a deliberate
149
+ * deployment override survived startup and was silently swapped on the first refresh -
150
+ * sessions signed before and after the swap carry DIFFERENT secrets. A real guard
151
+ * neutralised by a later path that did not get the memo.
152
+ *
153
+ * *** WHY THIS IS A MODULE-LOAD SNAPSHOT AND NOT `if (!process.env.BETTER_AUTH_SECRET)`. ***
154
+ * That naive guard is what the cache path uses (249808). It fixes the override, but it
155
+ * also means that once ANY refresh has populated the variable, NO later refresh can ever
156
+ * update it - so an IDP-side secret ROTATION silently stops propagating and the app keeps
157
+ * signing with a retired key. That trades one silent failure for another.
158
+ *
159
+ * Snapshotting at module load separates the two cases properly:
160
+ * operator set it -> never overwritten, for the process lifetime (the override is real)
161
+ * operator did not -> every refresh may publish, so rotation still works
162
+ *
163
+ * This is read ONCE, at import, before any function here has had a chance to write.
164
+ */
165
+ const OPERATOR_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
166
+ const OPERATOR_NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET;
167
+ let operatorOverrideLogged = false;
168
+
169
+ /** Publish the resolved auth secret WITHOUT ever overwriting an operator-set value. */
170
+ function publishAuthSecret(value: string, source: string): void {
171
+ if (OPERATOR_AUTH_SECRET) {
172
+ if (!operatorOverrideLogged) {
173
+ operatorOverrideLogged = true;
174
+ console.log(
175
+ `[IDP_CONFIG] BETTER_AUTH_SECRET was set by the deployment; keeping it and ignoring the ${source} value (261588). ` +
176
+ 'Unset it if you want the IDP to supply the signing secret.'
177
+ );
178
+ }
179
+ } else {
180
+ process.env.BETTER_AUTH_SECRET = value;
181
+ }
182
+ if (!OPERATOR_NEXTAUTH_SECRET) {
183
+ process.env.NEXTAUTH_SECRET = value;
184
+ }
185
+ }
186
+
77
187
  let cachedConfig: IDPClientConfig | null = null;
78
188
  let cacheExpiry: number = 0;
79
189
  let pendingFetch: Promise<IDPClientConfig> | null = null; // Prevents parallel fetches
@@ -84,9 +194,74 @@ let pendingFetch: Promise<IDPClientConfig> | null = null; // Prevents parallel f
84
194
 
85
195
  const REDIS_CONFIG_KEY_PREFIX = 'idp_config:';
86
196
 
197
+ /**
198
+ * 263437 - THE CACHE KEY MUST NAME THE BACKEND THE VALUE CAME FROM.
199
+ *
200
+ * The key was `idp_config:{CLIENT_ID}` - client slug ONLY. The cached value is fully
201
+ * determined by (backend origin, clientId): Layer 3 below fetches from
202
+ * INTERNAL_IDP_URL || IDP_URL for CLIENT_ID. Two instances pointed at DIFFERENT IDP
203
+ * backends but carrying the same slug are therefore indistinguishable to this cache,
204
+ * and whichever writes last serves BOTH - with ITS backend's client scope.
205
+ *
206
+ * WHY NOT REDIS_INSTANCE_ID (the originally proposed discriminator):
207
+ * it names the STORE, not the backend. Two legs on separate stores cannot collide in
208
+ * the first place; put them on ONE store - the shape this fix exists to survive - and
209
+ * they report the SAME instance id, so the keys recollide and the bug returns with a
210
+ * fix sitting in front of it. A discriminator that is only distinct in the
211
+ * configuration where the collision is already impossible discriminates nothing.
212
+ *
213
+ * SCOPE NOTE, so this is not over-read the way 273385 was: an undiscriminated key is a
214
+ * LATENT hazard, not the explanation for any wrong-tenant symptom measured on the walk
215
+ * rigs. Measured 2026-08-28 from three seats - the 93 and AKS legs were never on one
216
+ * store, and each backend independently returns the same blob for this slug. If you are
217
+ * chasing a wrong clientId, the value came from the IDP record, not from this cache.
218
+ */
219
+ function getConfigBackendTag(): string {
220
+ // Must mirror Layer 3's selection EXACTLY (INTERNAL_IDP_URL wins), or the key would
221
+ // name a backend the value did not come from.
222
+ const rawBackend = process.env.INTERNAL_IDP_URL || process.env.IDP_URL;
223
+ if (!rawBackend) {
224
+ throw new Error(
225
+ '[IDP_CONFIG] FATAL: INTERNAL_IDP_URL or IDP_URL must be set - the config cache key names the backend the cached value came from'
226
+ );
227
+ }
228
+
229
+ // ORIGIN, not the full URL: a path or trailing-slash difference is not a different
230
+ // backend, and letting it fork the key would silently halve the cache hit rate - a
231
+ // quieter bug in the same family. An unparseable value is used VERBATIM rather than
232
+ // collapsed to a constant, which would reintroduce the collision it is meant to stop.
233
+ let backendIdentity: string;
234
+ try {
235
+ backendIdentity = new URL(rawBackend).origin.toLowerCase();
236
+ } catch {
237
+ backendIdentity = rawBackend.trim().toLowerCase();
238
+ }
239
+
240
+ // A short digest, not the raw origin: keeps keys bounded and does not publish the
241
+ // internal topology to anyone holding KEYS on a shared store.
242
+ return createHash('sha256').update(backendIdentity).digest('hex').slice(0, 12);
243
+ }
244
+
87
245
  function getRedisConfigKey(): string {
88
- const clientId = process.env.CLIENT_ID || process.env.NEXT_PUBLIC_CLIENT_ID || 'default';
89
- return `${REDIS_CONFIG_KEY_PREFIX}${clientId}`;
246
+ // ONE ENV VAR: CLIENT_ID. Collapsed 2026-08-26.
247
+ // There were three names for this value - CLIENT_ID, NEXT_PUBLIC_CLIENT_ID and
248
+ // NEXT_CLIENT_ID. The NEXT_PUBLIC_ prefix exists so Next inlines a value into the
249
+ // BROWSER bundle; nothing in the browser ever read it, so it bought nothing and
250
+ // merely looked justified. Verified before removal: all 7 consuming sites set
251
+ // CLIENT_ID in their env; none relied on the fallback.
252
+ //
253
+ // AND IT MUST THROW, NOT DEFAULT. This clientId is the REDIS CACHE KEY. The old
254
+ // `|| 'default'` meant an app that failed to set the id did not fail - it read and
255
+ // wrote config under `idp_config:default`, silently sharing one cache slot with
256
+ // every other misconfigured app, while Layer 3 THREW for that same missing value.
257
+ // Two behaviours for one missing variable, and the silent one owned the cache.
258
+ const clientId = process.env.CLIENT_ID;
259
+ if (!clientId) {
260
+ throw new Error(
261
+ '[IDP_CONFIG] FATAL: CLIENT_ID must be set - it is the config cache key, and defaulting it shares one cache entry across misconfigured apps'
262
+ );
263
+ }
264
+ return `${REDIS_CONFIG_KEY_PREFIX}${clientId}:${getConfigBackendTag()}`;
90
265
  }
91
266
 
92
267
  interface RedisCachedConfig {
@@ -97,13 +272,13 @@ interface RedisCachedConfig {
97
272
  async function getConfigFromRedis(): Promise<IDPClientConfig | null> {
98
273
  try {
99
274
  const key = getRedisConfigKey();
100
- const cached = await redis.get(key);
275
+ const cached = await (await getRedis()).get(key);
101
276
  if (!cached) return null;
102
277
 
103
278
  const parsed: RedisCachedConfig = JSON.parse(cached);
104
279
  if (Date.now() >= parsed.expiresAt) {
105
280
  // Expired, delete it
106
- await redis.del(key);
281
+ await (await getRedis()).del(key);
107
282
  return null;
108
283
  }
109
284
 
@@ -123,7 +298,7 @@ async function setConfigInRedis(config: IDPClientConfig): Promise<void> {
123
298
  expiresAt: Date.now() + (ttlSeconds * 1000)
124
299
  };
125
300
  // Store with TTL slightly longer than the logical expiry to allow for clock skew
126
- await redis.set(key, JSON.stringify(data), 'EX', ttlSeconds + 10);
301
+ await (await getRedis()).set(key, JSON.stringify(data), 'EX', ttlSeconds + 10);
127
302
  } catch (error) {
128
303
  console.warn('[IDP_CONFIG] Failed to write to Redis cache:', error);
129
304
  }
@@ -157,7 +332,19 @@ export async function getIDPClientConfig(forceRefresh: boolean = false): Promise
157
332
  const now = Date.now();
158
333
 
159
334
  // Layer 1: Return in-memory cached if still valid (skip if forceRefresh)
160
- if (!forceRefresh && cachedConfig && now < cacheExpiry) {
335
+ //
336
+ // The `cachedConfig.authSecret` term is DEFENCE IN DEPTH, not redundancy.
337
+ // Both writers are validated (the fetch path checks before caching; the
338
+ // Redis path requires a non-empty secret to count as a hit), so in principle
339
+ // an empty config can no longer be cached. This makes that property TRUE AT
340
+ // THE POINT OF USE instead of only provable by reading the whole file —
341
+ // DotNetPert 21184 and DotNetPert-Scout 21178 both argued for guarding the
342
+ // return, and they are right that it is the shape that cannot drift: a
343
+ // future third writer, or a cache restored from an older build's Redis
344
+ // entry, is caught here regardless of what the writers do.
345
+ // The cost is one boolean; the failure it prevents is every session signed
346
+ // with a secret published on npm.
347
+ if (!forceRefresh && cachedConfig && cachedConfig.authSecret && now < cacheExpiry) {
161
348
  return cachedConfig;
162
349
  }
163
350
 
@@ -169,17 +356,99 @@ export async function getIDPClientConfig(forceRefresh: boolean = false): Promise
169
356
  // Layer 2: Check Redis cache (skip if forceRefresh - startup should always get fresh data)
170
357
  if (!forceRefresh) {
171
358
  const redisConfig = await getConfigFromRedis();
172
- if (redisConfig) {
359
+ // A cached entry is only USABLE if it carries a secret — see the note
360
+ // below. `redisConfig && !authSecret` must behave like a cache MISS, so
361
+ // the emptiness test belongs here, in the condition that gates the
362
+ // `return`, and not merely around the assignment inside it.
363
+ // ── 249808 FOLLOW-THROUGH: THE CACHE MAY NEVER *ESTABLISH* THE SECRET. ──
364
+ //
365
+ // The rule stated just above - "a cached entry is only USABLE if it
366
+ // carries a secret" - was right and did not go far enough. The cache
367
+ // must also never be the FIRST source of the signing secret, because
368
+ // this blob is unauthenticated (bare JSON.parse, no MAC, and its own
369
+ // `expiresAt` sits inside the attacker-writable value).
370
+ //
371
+ // Guarding the ASSIGNMENT (the earlier 4.1.39 fix) protects a secret
372
+ // that already exists. It does nothing when none exists yet - and that
373
+ // window is real: the graft CATCHES the FATAL that startup-init throws
374
+ // when the IDP is unreachable at boot (graft instrumentation.ts:65-69,
375
+ // "BETTER_AUTH_SECRET may be unavailable until then"), so the app can
376
+ // serve requests with no secret established. The first request would
377
+ // then take the signing key from the cache - which is the whole
378
+ // forgery vector, arriving through the back door of a fix that had
379
+ // closed the front one.
380
+ //
381
+ // So: with no trusted secret in hand, THIS CACHE ENTRY IS A MISS. Fall
382
+ // through to Layer 3, the IDP fetch, which is authoritative and already
383
+ // guarded to throw on an empty secret. Escalation to truth, exactly as
384
+ // the empty-secret case above does.
385
+ //
386
+ // Accepts either name, matching startup-init.ts:147 which verifies
387
+ // `BETTER_AUTH_SECRET || NEXTAUTH_SECRET` during the rename transition.
388
+ //
389
+ // COST, bounded: only while no secret is established does this force an
390
+ // IDP round trip. startup-init calls getIDPClientConfig(TRUE) and so
391
+ // skips Layer 2 regardless, meaning normal boot is unaffected; once the
392
+ // secret exists the cache serves every non-forced caller as before. The
393
+ // only behaviour that changes is in the window where the alternative was
394
+ // trusting an unauthenticated store for a signing key.
395
+ const haveTrustedSecret = !!(process.env.BETTER_AUTH_SECRET || process.env.NEXTAUTH_SECRET);
396
+ if (redisConfig && redisConfig.authSecret && haveTrustedSecret) {
173
397
  // Restore to in-memory cache
174
398
  cachedConfig = redisConfig;
175
399
  cacheExpiry = Date.now() + ((redisConfig.configCacheTtlSeconds || 300) * 1000);
176
400
 
177
401
  // Set BETTER_AUTH_SECRET from cached config (also set legacy
178
402
  // NEXTAUTH_SECRET during the rename transition).
179
- if (redisConfig.authSecret) {
180
- process.env.BETTER_AUTH_SECRET = redisConfig.authSecret;
181
- process.env.NEXTAUTH_SECRET = redisConfig.authSecret;
182
- }
403
+ //
404
+ // ⚠ THE EMPTY CASE IS NOT A NO-OP — IT WAS, AND THAT WAS THE HOLE.
405
+ // The three FETCH paths (:225 external, :353 internal, post-parse)
406
+ // throw on an empty authSecret, because an empty secret reaching
407
+ // betterAuth() is silently replaced by the PUBLIC DEFAULT_SECRET
408
+ // (create-context.mjs:79) rather than rejected. This path used to
409
+ // just skip the assignment and `return redisConfig` below — so a
410
+ // cached config with an empty secret bypassed ALL THREE guards, and
411
+ // it is checked FIRST, before any of them. Redis persistence made it
412
+ // durable across restarts. Found by QAPert (21175) after I claimed
413
+ // the guards covered every path; they covered every FETCH path.
414
+ //
415
+ // Resolution is deliberately NOT a throw: a corrupt cache entry
416
+ // should not take the app down when the authoritative source is one
417
+ // call away. Treat it as a MISS and fall through to the fetch paths,
418
+ // which are guarded and will throw if the IDP is genuinely empty.
419
+ // That is escalation to truth, not a fallback that weakens the check.
420
+ // ── 249808: AN EXPLICIT BETTER_AUTH_SECRET NOW WINS OVER THE CACHE. ──
421
+ //
422
+ // These two assignments were UNCONDITIONAL. That made the Redis
423
+ // cache authoritative for the SESSION SIGNING KEY, and the cache is
424
+ // not a trusted input: getConfigFromRedis() does a bare JSON.parse
425
+ // with no signature or MAC, and its only freshness check is an
426
+ // `expiresAt` field that lives INSIDE the same attacker-writable
427
+ // blob. So anyone who could write that key chose the signing secret,
428
+ // and knowing the secret means minting a validly-signed session for
429
+ // any user — including one who has never logged in. Forgery, not
430
+ // theft: it survives flushing the store and revoking refresh tokens,
431
+ // because it needs neither.
432
+ //
433
+ // THE FILE ALREADY KNEW THE RIGHT PATTERN. Five lines below, a mere
434
+ // URL defers to an explicitly-configured value, with a comment
435
+ // blessing deployment override. startup-init.ts:129 defers on THIS
436
+ // VERY SECRET (`config.authSecret && !process.env.BETTER_AUTH_SECRET`).
437
+ // The signing key was the one value in the package taking an
438
+ // untrusted cache's word unconditionally — protected less carefully
439
+ // than a base URL.
440
+ //
441
+ // Deferring restores that consistency and makes an explicitly-set
442
+ // BETTER_AUTH_SECRET a REAL secret: a value the deployment owns and
443
+ // the cache cannot override.
444
+ //
445
+ // BOUND, stated so nobody over-reads this: it closes the vector only
446
+ // where BETTER_AUTH_SECRET IS ACTUALLY SET. Where it is unset the
447
+ // cache still supplies it and the exposure stands — so this is
448
+ // necessary, cheap and strictly-better, NOT sufficient on its own.
449
+ // Locking the store and setting the secret explicitly in every
450
+ // deployment remain required.
451
+ publishAuthSecret(redisConfig.authSecret, 'Redis-cached IDP config');
183
452
 
184
453
  // Set IDENTITY_CLIENT_BASE_EXTERNAL_URL from cached config.
185
454
  // Only set if not already defined (allows deployment override for beta/staging).
@@ -194,10 +463,11 @@ export async function getIDPClientConfig(forceRefresh: boolean = false): Promise
194
463
  // Layer 3: Fetch from IDP
195
464
  const internalIdpUrl = process.env.INTERNAL_IDP_URL;
196
465
  const idpUrl = process.env.IDP_URL;
197
- const clientIdStr = process.env.CLIENT_ID || process.env.NEXT_PUBLIC_CLIENT_ID;
466
+ // CLIENT_ID only - see the note at the cache-key site above.
467
+ const clientIdStr = process.env.CLIENT_ID;
198
468
 
199
469
  if (!clientIdStr) {
200
- throw new Error('[IDP_CONFIG] FATAL: CLIENT_ID or NEXT_PUBLIC_CLIENT_ID must be set');
470
+ throw new Error('[IDP_CONFIG] FATAL: CLIENT_ID must be set');
201
471
  }
202
472
  if (!internalIdpUrl && !idpUrl) {
203
473
  throw new Error('[IDP_CONFIG] FATAL: INTERNAL_IDP_URL or IDP_URL must be set');
@@ -209,22 +479,47 @@ export async function getIDPClientConfig(forceRefresh: boolean = false): Promise
209
479
  : fetchConfigFromIDP(idpUrl!, clientIdStr);
210
480
  pendingFetch = fetcher
211
481
  .then(async config => {
482
+ // ── VALIDATE BEFORE CACHING. ORDER IS THE WHOLE FIX. ────────────
483
+ // This block used to assign cachedConfig and await setConfigInRedis
484
+ // FIRST and throw afterwards — so a response with an empty secret
485
+ // poisoned BOTH caches on its way to failing. The throw only stopped
486
+ // THAT call: the next one hit the in-memory layer (`return
487
+ // cachedConfig`), which has no guard because it is a cache read, and
488
+ // handed the empty config to betterAuth() — where empty silently
489
+ // becomes the public DEFAULT_SECRET. Redis made it survive restarts.
490
+ //
491
+ // Guarding every cached RETURN would also work, and DotNetPert-Scout
492
+ // (21178) is right that it is the more robust shape in general. But
493
+ // there are four such returns and one writer, and the invariant we
494
+ // actually want is "an unvalidated config is never cached at all".
495
+ // Enforce it where it is cheapest to keep true.
496
+ if (!config.authSecret) {
497
+ // ⚠ LOAD-BEARING THROW — DO NOT SOFTEN TO A DEFAULT OR A WARNING.
498
+ // better-auth does NOT treat an empty secret as missing: at
499
+ // create-context.mjs:79 it substitutes DEFAULT_SECRET
500
+ // ("better-auth-secret-12345678901234567890"), which ships in the
501
+ // PUBLIC npm package, and its !secret guard can then never fire.
502
+ // The only remaining protection there is an isProduction gate — so
503
+ // outside production an empty secret means every session is signed
504
+ // with a string anyone can read off npm and forge.
505
+ // This throw is what stops that. It is also what turned the
506
+ // 2026-08-05 nextauth_secret spelling bug into a loud FATAL instead
507
+ // of a silently forgeable app.
508
+ throw new Error('[IDP_CONFIG] FATAL: IDP did not return authSecret');
509
+ }
510
+
511
+ // Validated above — only now is it safe to cache or publish.
512
+ publishAuthSecret(config.authSecret, 'freshly-fetched IDP config');
513
+
212
514
  // Cache with TTL from response (default 5 minutes)
213
515
  cachedConfig = config;
214
516
  cacheExpiry = Date.now() + ((config.configCacheTtlSeconds || 300) * 1000);
215
517
 
216
- // Store in Redis for persistence across module reloads
518
+ // Store in Redis for persistence across module reloads. This is the
519
+ // write that made a bad config outlive the process, so it must stay
520
+ // BELOW the guard, never above it.
217
521
  await setConfigInRedis(config);
218
522
 
219
- // Set BETTER_AUTH_SECRET from config (also set legacy
220
- // NEXTAUTH_SECRET during the rename transition).
221
- if (config.authSecret) {
222
- process.env.BETTER_AUTH_SECRET = config.authSecret;
223
- process.env.NEXTAUTH_SECRET = config.authSecret;
224
- } else {
225
- throw new Error('[IDP_CONFIG] FATAL: IDP did not return authSecret');
226
- }
227
-
228
523
  // Set IDENTITY_CLIENT_BASE_EXTERNAL_URL from config.
229
524
  // Only set if not already defined (allows deployment override for beta/staging).
230
525
  if (config.baseClientUrl && !process.env.IDENTITY_CLIENT_BASE_EXTERNAL_URL) {
@@ -255,7 +550,7 @@ export function clearConfigCache(): void {
255
550
  export async function clearConfigRedisCache(): Promise<void> {
256
551
  try {
257
552
  const key = getRedisConfigKey();
258
- await redis.del(key);
553
+ await (await getRedis()).del(key);
259
554
  console.log('[IDP_CONFIG] Redis cache cleared:', key);
260
555
  } catch (error) {
261
556
  console.warn('[IDP_CONFIG] Failed to clear Redis cache:', error);
@@ -329,8 +624,13 @@ async function fetchConfigFromInternalIDP(internalIdpUrl: string, clientIdStr: s
329
624
  additionalParams: p.additionalParams ?? p.additional_params
330
625
  })),
331
626
  authSettings: {
332
- require2FA: configData.authSettings?.require2FA ?? configData.auth_settings?.require_2fa ?? true,
333
- allowed2FAMethods: configData.authSettings?.allowed2FAMethods ?? configData.auth_settings?.allowed_2fa_methods ?? ['email', 'sms'],
627
+ // 257255: terminal `?? true` / `?? ['email','sms']` REMOVED. They
628
+ // made a document with NO auth policy indistinguishable from one
629
+ // carrying exactly those values, one layer below the route that
630
+ // reports policy - so even a corrected settings.ts would have
631
+ // validated a fabrication. Undefined now means "not carried".
632
+ require2FA: configData.authSettings?.require2FA ?? configData.auth_settings?.require_2fa,
633
+ allowed2FAMethods: configData.authSettings?.allowed2FAMethods ?? configData.auth_settings?.allowed_2fa_methods,
334
634
  mfaGracePeriodHours: configData.authSettings?.mfaGracePeriodHours ?? configData.auth_settings?.mfa_grace_period_hours ?? 24,
335
635
  mfaRememberDeviceDays: configData.authSettings?.mfaRememberDeviceDays ?? configData.auth_settings?.mfa_remember_device_days ?? 30,
336
636
  sessionTimeoutMinutes: configData.authSettings?.sessionTimeoutMinutes ?? configData.auth_settings?.session_timeout_minutes ?? 60,
@@ -338,7 +638,29 @@ async function fetchConfigFromInternalIDP(internalIdpUrl: string, clientIdStr: s
338
638
  allowRememberMe: configData.authSettings?.allowRememberMe ?? configData.auth_settings?.allow_remember_me ?? true,
339
639
  rememberMeDays: configData.authSettings?.rememberMeDays ?? configData.auth_settings?.remember_me_days ?? 30,
340
640
  lockoutThreshold: configData.authSettings?.lockoutThreshold ?? configData.auth_settings?.lockout_threshold ?? 5,
341
- lockoutDurationMinutes: configData.authSettings?.lockoutDurationMinutes ?? configData.auth_settings?.lockout_duration_minutes ?? 15
641
+ lockoutDurationMinutes: configData.authSettings?.lockoutDurationMinutes ?? configData.auth_settings?.lockout_duration_minutes ?? 15,
642
+
643
+ // 240447: read the tenant policy the IDP already sends. Applied to
644
+ // BOTH parse paths (fetchConfigFromInternalIDP and
645
+ // fetchConfigFromIDP) — fixing one would leave the other silently
646
+ // omitting these, which is the partial-fix trap.
647
+ //
648
+ // Same dual-casing idiom as every field above, but with NO
649
+ // `?? default` tail, deliberately. The others default because a
650
+ // wrong timeout is a nuisance; a wrong registration flag is a LIE
651
+ // about what the tenant permits, and the hardcoded `true` in
652
+ // routes/auth/settings was exactly that lie. `undefined` has to
653
+ // survive to the consumer so it can decide, instead of being
654
+ // handed a guess wearing the costume of a measurement.
655
+ allowPublicRegistration:
656
+ configData.authSettings?.allowPublicRegistration ??
657
+ configData.auth_settings?.allow_public_registration,
658
+ allowSocialLogin:
659
+ configData.authSettings?.allowSocialLogin ??
660
+ configData.auth_settings?.allow_social_login,
661
+ enablePasswordReset:
662
+ configData.authSettings?.enablePasswordReset ??
663
+ configData.auth_settings?.enable_password_reset
342
664
  },
343
665
  branding: {
344
666
  theme: configData.branding?.theme,
@@ -350,6 +672,9 @@ async function fetchConfigFromInternalIDP(internalIdpUrl: string, clientIdStr: s
350
672
  };
351
673
 
352
674
  if (!config.authSecret) {
675
+ // LOAD-BEARING — see the note on the external path's equivalent throw.
676
+ // An empty secret reaching betterAuth() becomes the PUBLIC default, not an
677
+ // error. Never replace this with a fallback.
353
678
  throw new Error('[IDP_CONFIG] FATAL: Internal IDP did not return authSecret');
354
679
  }
355
680
 
@@ -366,7 +691,12 @@ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<
366
691
  const timeSinceFailure = Date.now() - lastFailureTime;
367
692
  if (timeSinceFailure < CIRCUIT_OPEN_MS) {
368
693
  // Circuit is open - return stale cache if available
369
- if (cachedConfig) {
694
+ // Stale cache is acceptable here; a stale config with NO SECRET is
695
+ // not. These two paths fire exactly when the IDP is unreachable —
696
+ // the worst moment to hand betterAuth() an empty secret, which it
697
+ // silently upgrades to the public DEFAULT_SECRET. Falling through to
698
+ // a hard failure is correct: unavailable beats forgeable.
699
+ if (cachedConfig && cachedConfig.authSecret) {
370
700
  return cachedConfig;
371
701
  }
372
702
  throw new Error(`[IDP_CONFIG] Circuit breaker OPEN - no cached config available. Retry in ${Math.round((CIRCUIT_OPEN_MS - timeSinceFailure) / 1000)}s`);
@@ -384,7 +714,12 @@ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<
384
714
  if (timeSinceFailure < backoffMs) {
385
715
  const remainingMs = backoffMs - timeSinceFailure;
386
716
  // Return stale cache during backoff if available
387
- if (cachedConfig) {
717
+ // Stale cache is acceptable here; a stale config with NO SECRET is
718
+ // not. These two paths fire exactly when the IDP is unreachable —
719
+ // the worst moment to hand betterAuth() an empty secret, which it
720
+ // silently upgrades to the public DEFAULT_SECRET. Falling through to
721
+ // a hard failure is correct: unavailable beats forgeable.
722
+ if (cachedConfig && cachedConfig.authSecret) {
388
723
  return cachedConfig;
389
724
  }
390
725
  throw new Error(`[IDP_CONFIG] In backoff period - retry in ${Math.round(remainingMs)}ms`);
@@ -509,9 +844,13 @@ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<
509
844
  console.warn('[IDP_CONFIG] DEPRECATION: require2FA found at top-level. Should be nested under auth_settings. Update IDP.');
510
845
  return topLevel;
511
846
  }
512
- return true; // Default to true for security
847
+ // 257255: was `return true`. A default here is a FABRICATED
848
+ // POLICY - QAPert measured the same client as require_2fa TRUE
849
+ // on AKS and FALSE on dev-93, so any constant is wrong in one
850
+ // environment by construction. Undefined = "not carried".
851
+ return undefined;
513
852
  })(),
514
- allowed2FAMethods: configData.authSettings?.allowed2FAMethods ?? configData.auth_settings?.allowed_2fa_methods ?? ['email', 'sms'],
853
+ allowed2FAMethods: configData.authSettings?.allowed2FAMethods ?? configData.auth_settings?.allowed_2fa_methods,
515
854
  mfaGracePeriodHours: configData.authSettings?.mfaGracePeriodHours ?? configData.auth_settings?.mfa_grace_period_hours ?? 24,
516
855
  mfaRememberDeviceDays: configData.authSettings?.mfaRememberDeviceDays ?? configData.auth_settings?.mfa_remember_device_days ?? 30,
517
856
  sessionTimeoutMinutes: configData.authSettings?.sessionTimeoutMinutes ?? configData.auth_settings?.session_timeout_minutes ?? 60,
@@ -519,7 +858,29 @@ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<
519
858
  allowRememberMe: configData.authSettings?.allowRememberMe ?? configData.auth_settings?.allow_remember_me ?? true,
520
859
  rememberMeDays: configData.authSettings?.rememberMeDays ?? configData.auth_settings?.remember_me_days ?? 30,
521
860
  lockoutThreshold: configData.authSettings?.lockoutThreshold ?? configData.auth_settings?.lockout_threshold ?? 5,
522
- lockoutDurationMinutes: configData.authSettings?.lockoutDurationMinutes ?? configData.auth_settings?.lockout_duration_minutes ?? 15
861
+ lockoutDurationMinutes: configData.authSettings?.lockoutDurationMinutes ?? configData.auth_settings?.lockout_duration_minutes ?? 15,
862
+
863
+ // 240447: read the tenant policy the IDP already sends. Applied to
864
+ // BOTH parse paths (fetchConfigFromInternalIDP and
865
+ // fetchConfigFromIDP) — fixing one would leave the other silently
866
+ // omitting these, which is the partial-fix trap.
867
+ //
868
+ // Same dual-casing idiom as every field above, but with NO
869
+ // `?? default` tail, deliberately. The others default because a
870
+ // wrong timeout is a nuisance; a wrong registration flag is a LIE
871
+ // about what the tenant permits, and the hardcoded `true` in
872
+ // routes/auth/settings was exactly that lie. `undefined` has to
873
+ // survive to the consumer so it can decide, instead of being
874
+ // handed a guess wearing the costume of a measurement.
875
+ allowPublicRegistration:
876
+ configData.authSettings?.allowPublicRegistration ??
877
+ configData.auth_settings?.allow_public_registration,
878
+ allowSocialLogin:
879
+ configData.authSettings?.allowSocialLogin ??
880
+ configData.auth_settings?.allow_social_login,
881
+ enablePasswordReset:
882
+ configData.authSettings?.enablePasswordReset ??
883
+ configData.auth_settings?.enable_password_reset
523
884
  },
524
885
  branding: {
525
886
  theme: configData.branding?.theme,
@@ -538,6 +899,8 @@ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<
538
899
  throw new Error('[IDP_CONFIG] FATAL: clientId is empty or missing after parsing');
539
900
  }
540
901
  if (!config.authSecret) {
902
+ // LOAD-BEARING — empty must fail here, never downstream. betterAuth()
903
+ // silently substitutes a public constant for an empty secret.
541
904
  throw new Error('[IDP_CONFIG] FATAL: authSecret is empty after parsing');
542
905
  }
543
906
 
@@ -2,6 +2,13 @@ import { NextRequest } from 'next/server';
2
2
  import { getTokenTestAware } from './test-aware-get-token';
3
3
  import { getSession } from './session-store';
4
4
  import { internalRefresh } from './internal-api';
5
+ import { assertAbsoluteIdpUrl } from './idp-url-guard';
6
+
7
+ // Re-exported so `idp-fetch` remains the single obvious import for IDP calling
8
+ // concerns; the implementation lives in a dependency-free leaf so the three
9
+ // direct-fetch callers (auth/jwks.ts, auth/passkey-signin.ts x2) do not have to
10
+ // pull this module's server-only chain in for one regex.
11
+ export { assertAbsoluteIdpUrl };
5
12
 
6
13
  /**
7
14
  * Centralized IDP fetch helper
@@ -22,8 +29,23 @@ function buildHeaders(req: NextRequest, bearer?: string, extra?: Record<string,
22
29
  'Content-Type': 'application/json',
23
30
  ...extra,
24
31
  };
32
+ /**
33
+ * 269334 / R21: forward the FULL X-Forwarded-For chain, do NOT collapse to [0].
34
+ *
35
+ * `.split(',')[0]` took the LEFT-most entry - the value the ORIGINAL client
36
+ * supplied, which is forgeable - and discarded the rest of the chain. That both
37
+ * (a) forwards the attacker-controlled value and (b) destroys the chain the
38
+ * receiving side needs to identify the real client by counting trusted hops from
39
+ * the right. This app cannot itself observe the TCP peer (Next 16.2.12 route
40
+ * handler, no socket), so the correct division of labour is: the IDP's ingress
41
+ * (Envoy, use_remote_address:true) APPENDS the real observed peer, the IDP counts
42
+ * xff_num_trusted_hops from the right, and THIS layer's only job is to not throw
43
+ * the chain away. Forwarding the full value is byte-identical to today for any
44
+ * consumer reading [0], so it cannot regress current behaviour; it only stops
45
+ * discarding the entries the trusted-hop logic will need.
46
+ */
25
47
  const xfwd = req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip');
26
- if (xfwd) headers['X-Forwarded-For'] = xfwd.split(',')[0].trim();
48
+ if (xfwd) headers['X-Forwarded-For'] = xfwd;
27
49
  const ua = req.headers.get('user-agent');
28
50
  if (ua) headers['User-Agent'] = ua;
29
51
  if (bearer) headers['Authorization'] = `Bearer ${bearer}`;
@@ -97,7 +119,20 @@ export async function idpFetchJSON<T = any>(
97
119
  let attemptedRefresh = false;
98
120
  let { accessToken, sessionToken, hasRefreshToken } = await ensureFreshAccessToken(req);
99
121
 
122
+ assertAbsoluteIdpUrl(targetUrl);
123
+
100
124
  const makeCall = async (bearer?: string) => {
125
+ // NOTE (307290): this fetch is UNGUARDED, and so is the first call to it at
126
+ // the "First attempt" below. Only the JSON PARSE (try/catch immediately
127
+ // after) and the RETRY are guarded. A transport-level failure - DNS, refused,
128
+ // TLS, timeout - therefore throws straight out of idpFetchJSON, past the thin
129
+ // route handlers that call it, and Next answers 500. This is long-standing and
130
+ // NOT introduced by 307290; but 307290's middleware carve-out lets expired-token
131
+ // /api/ requests reach these handlers where middleware previously redirected
132
+ // them away, so it routes a NEW POPULATION at this pre-existing risk. If IDP
133
+ // reachability blips after that change, suspect this line before suspecting the
134
+ // carve-out. Correcting b4b8ffae's message, which wrongly said every risky
135
+ // branch here is try/caught.
101
136
  const res = await fetch(targetUrl, {
102
137
  ...init,
103
138
  headers: buildHeaders(req, bearer, init.headers as any),