@payez/next-mvp 4.8.1 → 4.8.3

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 (314) hide show
  1. package/POST-INSTALL.md +408 -408
  2. package/README.md +859 -859
  3. package/dist/api-handlers/admin/audit.js +13 -21
  4. package/dist/api-handlers/admin/sessions.js +59 -45
  5. package/dist/api-handlers/admin/stats.js +1 -1
  6. package/dist/api-handlers/admin/users.js +15 -23
  7. package/dist/api-handlers/admin/vibe-data.d.ts +5 -0
  8. package/dist/api-handlers/admin/vibe-data.js +5 -0
  9. package/dist/components/account/MobileNavDrawer.js +18 -18
  10. package/dist/components/account/UserAvatarMenu.js +1 -1
  11. package/dist/components/admin/AlertSettingsTab.js +40 -40
  12. package/dist/components/admin/LoggingSettingsTab.js +28 -28
  13. package/dist/lib/session-store.js +21 -21
  14. package/dist/lib/startup-init.js +19 -19
  15. package/dist/pages/login/page.js +28 -28
  16. package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
  17. package/dist/vibe/client.d.ts +1 -1
  18. package/dist/vibe/client.js +2 -1
  19. package/dist/vibe/filters.d.ts +36 -0
  20. package/dist/vibe/filters.js +47 -0
  21. package/dist/vibe/generic.js +2 -1
  22. package/dist/vibe/sessions.js +28 -3
  23. package/package.json +2 -2
  24. package/scripts/check-publish-eol.mjs +33 -15
  25. package/src/api/auth-handler.ts +550 -550
  26. package/src/api/index.ts +17 -17
  27. package/src/api-handlers/account/change-password.ts +170 -170
  28. package/src/api-handlers/account/masked-info.ts +46 -46
  29. package/src/api-handlers/account/profile.ts +99 -99
  30. package/src/api-handlers/account/recovery/forward.ts +117 -117
  31. package/src/api-handlers/account/recovery/initiate.ts +59 -59
  32. package/src/api-handlers/account/recovery/send-code.ts +21 -21
  33. package/src/api-handlers/account/recovery/verify-code.ts +20 -20
  34. package/src/api-handlers/account/reset-password.ts +87 -87
  35. package/src/api-handlers/account/send-code.ts +70 -70
  36. package/src/api-handlers/account/update-phone.ts +84 -84
  37. package/src/api-handlers/account/validate-password.ts +139 -139
  38. package/src/api-handlers/account/verify-email.ts +134 -134
  39. package/src/api-handlers/account/verify-sms.ts +134 -134
  40. package/src/api-handlers/admin/analytics.ts +452 -452
  41. package/src/api-handlers/admin/audit.ts +223 -232
  42. package/src/api-handlers/admin/index.ts +63 -63
  43. package/src/api-handlers/admin/redis-sessions.ts +259 -259
  44. package/src/api-handlers/admin/sessions.ts +341 -327
  45. package/src/api-handlers/admin/site-logs.ts +388 -388
  46. package/src/api-handlers/admin/stats.ts +260 -260
  47. package/src/api-handlers/admin/users.ts +240 -251
  48. package/src/api-handlers/admin/vibe-data.ts +338 -333
  49. package/src/api-handlers/anon/preferences.ts +123 -123
  50. package/src/api-handlers/auth/credential-login-email-code.ts +92 -92
  51. package/src/api-handlers/auth/credential-login.ts +948 -948
  52. package/src/api-handlers/auth/jwks.ts +31 -31
  53. package/src/api-handlers/auth/login.ts +252 -252
  54. package/src/api-handlers/auth/passkey-signin.ts +116 -116
  55. package/src/api-handlers/auth/refresh.ts +829 -829
  56. package/src/api-handlers/auth/signout.ts +201 -201
  57. package/src/api-handlers/auth/status.ts +23 -23
  58. package/src/api-handlers/auth/update-session.ts +144 -144
  59. package/src/api-handlers/auth/validate.ts +45 -45
  60. package/src/api-handlers/auth/verify-code.ts +116 -116
  61. package/src/api-handlers/mfa/backup-code-verify.ts +170 -170
  62. package/src/api-handlers/mfa/backup-codes.ts +42 -42
  63. package/src/api-handlers/mfa/methods.ts +47 -47
  64. package/src/api-handlers/mfa/totp-confirm.ts +43 -43
  65. package/src/api-handlers/mfa/totp-enroll.ts +43 -43
  66. package/src/api-handlers/mfa/totp-verify.ts +172 -172
  67. package/src/api-handlers/oauth/resume.ts +182 -182
  68. package/src/api-handlers/onboarding/send-email-code.ts +18 -18
  69. package/src/api-handlers/onboarding/set-password.ts +21 -21
  70. package/src/api-handlers/onboarding/verify-code.ts +20 -20
  71. package/src/api-handlers/onboarding/verify-welcome-email.ts +17 -17
  72. package/src/api-handlers/passkeys/list.ts +32 -32
  73. package/src/api-handlers/passkeys/manage.ts +55 -55
  74. package/src/api-handlers/passkeys/register-options.ts +43 -43
  75. package/src/api-handlers/passkeys/register-verify.ts +42 -42
  76. package/src/api-handlers/session/refresh-viability.ts +56 -56
  77. package/src/api-handlers/session/viability.ts +238 -238
  78. package/src/api-handlers/test/force-expire.ts +167 -167
  79. package/src/auth/auth-decision.ts +292 -292
  80. package/src/auth/better-auth.ts +1688 -1688
  81. package/src/auth/idp-oauth.ts +310 -310
  82. package/src/auth/password-reset-bridge.ts +98 -98
  83. package/src/auth/revocation-fields.ts +16 -16
  84. package/src/auth/route-config.ts +236 -236
  85. package/src/auth/safe-callback.ts +199 -199
  86. package/src/auth/session-cookie-clear.ts +30 -30
  87. package/src/auth/session-reset-link.ts +159 -159
  88. package/src/auth/session-response-sanitizer.ts +151 -151
  89. package/src/auth/types/auth-types.ts +555 -555
  90. package/src/auth/types/index.ts +7 -7
  91. package/src/auth/unauthenticated-routes.ts +2 -2
  92. package/src/auth/utils/idp-client.ts +364 -364
  93. package/src/auth/utils/index.ts +6 -6
  94. package/src/auth/utils/token-utils.ts +243 -243
  95. package/src/client/AuthContext.tsx +192 -192
  96. package/src/client/better-auth-client.ts +70 -70
  97. package/src/client/fetch-with-auth.ts +47 -47
  98. package/src/client/fetchWithSession.ts +21 -21
  99. package/src/client/index.ts +13 -13
  100. package/src/client/useAnonSession.ts +131 -131
  101. package/src/components/SessionSync.tsx +138 -138
  102. package/src/components/SignalRHealthCheck.tsx +131 -131
  103. package/src/components/account/MobileNavDrawer.tsx +305 -305
  104. package/src/components/account/UserAvatarMenu.tsx +277 -277
  105. package/src/components/account/index.ts +13 -13
  106. package/src/components/admin/AlertSettingsTab.tsx +728 -728
  107. package/src/components/admin/AnalyticsTab.tsx +703 -703
  108. package/src/components/admin/DataBrowserTab.tsx +508 -508
  109. package/src/components/admin/LoggingSettingsTab.tsx +665 -665
  110. package/src/components/admin/SessionsTab.tsx +421 -421
  111. package/src/components/admin/StatsTab.tsx +379 -379
  112. package/src/components/admin/VibeAdminContext.tsx +87 -87
  113. package/src/components/admin/VibeAdminLayout.tsx +187 -187
  114. package/src/components/admin/index.ts +59 -59
  115. package/src/components/auth/CredentialSignInForm.tsx +240 -240
  116. package/src/components/auth/FederatedAuthSection.tsx +129 -129
  117. package/src/components/auth/ModeAwareLoginPage.tsx +146 -146
  118. package/src/components/auth/ModeAwareSignupPage.tsx +200 -200
  119. package/src/components/auth/PasswordResetBridge.tsx +30 -30
  120. package/src/components/auth/TraditionalAuthSection.tsx +103 -103
  121. package/src/components/recovery/CompleteStep.tsx +36 -36
  122. package/src/components/recovery/InitiateRecoveryStep.tsx +68 -68
  123. package/src/components/recovery/RequestedStep.tsx +30 -30
  124. package/src/components/recovery/SelectMethodStep.tsx +73 -73
  125. package/src/components/recovery/SetPasswordStep.tsx +118 -118
  126. package/src/components/recovery/VerifyCodeStep.tsx +90 -90
  127. package/src/components/reserved/ReservedRecoveryWarning.tsx +160 -160
  128. package/src/components/reserved/ReservedStatusBox.tsx +118 -118
  129. package/src/components/ui/BetaBadge.tsx +58 -58
  130. package/src/components/ui/Footer.tsx +93 -93
  131. package/src/config/env.ts +55 -55
  132. package/src/config/logger.ts +62 -62
  133. package/src/config/logging-config.ts +82 -82
  134. package/src/config/unauthenticated-routes.ts +19 -19
  135. package/src/config/vibe-log-transport.ts +264 -264
  136. package/src/edge/internal-api-url.ts +65 -65
  137. package/src/edge/middleware.ts +42 -42
  138. package/src/hooks/useAuth.ts +117 -117
  139. package/src/hooks/useAuthSettings.ts +109 -109
  140. package/src/hooks/useAvailableProviders.ts +143 -143
  141. package/src/hooks/usePasswordValidation.ts +127 -127
  142. package/src/hooks/useProfile.ts +82 -82
  143. package/src/hooks/usePublicAuthSettings.ts +171 -171
  144. package/src/hooks/useSessionExpiration.ts +101 -101
  145. package/src/hooks/useViabilitySession.ts +337 -337
  146. package/src/index.ts +64 -64
  147. package/src/lib/anon-session.ts +213 -213
  148. package/src/lib/api-handler.ts +643 -643
  149. package/src/lib/app-slug.ts +228 -228
  150. package/src/lib/authorize-resume.ts +172 -172
  151. package/src/lib/bearer-fetch.ts +31 -31
  152. package/src/lib/client-key.ts +88 -88
  153. package/src/lib/code-step-copy.ts +83 -83
  154. package/src/lib/credential-sign-in-client.ts +101 -101
  155. package/src/lib/demo-mode.ts +18 -18
  156. package/src/lib/device-id.ts +67 -67
  157. package/src/lib/ensure-fresh-access-token.ts +509 -509
  158. package/src/lib/geolocation.ts +265 -265
  159. package/src/lib/idp-client-config.ts +1005 -1005
  160. package/src/lib/idp-fetch.ts +169 -169
  161. package/src/lib/idp-url-guard.ts +37 -37
  162. package/src/lib/internal-api.ts +171 -171
  163. package/src/lib/jwt-decode-client.ts +45 -45
  164. package/src/lib/jwt-decode.ts +82 -82
  165. package/src/lib/lock-window.ts +49 -49
  166. package/src/lib/login-error-message.ts +41 -41
  167. package/src/lib/never-in-response.ts +64 -64
  168. package/src/lib/nextauth-secret.ts +168 -168
  169. package/src/lib/onboarding-proxy.ts +57 -57
  170. package/src/lib/password-rules.ts +50 -50
  171. package/src/lib/rate-limit-service.ts +9 -9
  172. package/src/lib/redis.ts +105 -105
  173. package/src/lib/refresh-token-validator.ts +64 -64
  174. package/src/lib/roles.ts +177 -177
  175. package/src/lib/safe-error-response.ts +149 -149
  176. package/src/lib/secret-validation.ts +8 -8
  177. package/src/lib/session-cookie-shape.ts +185 -185
  178. package/src/lib/session-store.ts +1092 -1092
  179. package/src/lib/session.ts +34 -34
  180. package/src/lib/single-flight.ts +64 -64
  181. package/src/lib/site-logger.ts +245 -245
  182. package/src/lib/standardized-client-api.ts +901 -901
  183. package/src/lib/startup-init.ts +276 -276
  184. package/src/lib/test-aware-get-token.ts +41 -41
  185. package/src/lib/token-expiry.ts +40 -40
  186. package/src/lib/token-lifecycle.ts +546 -546
  187. package/src/lib/token-log-id.ts +69 -69
  188. package/src/lib/twofa-rejection.ts +91 -91
  189. package/src/lib/types/api-responses.ts +336 -336
  190. package/src/lib/user-agent-parser.ts +252 -252
  191. package/src/logging/api/admin-analytics.ts +51 -51
  192. package/src/logging/api/audit-log.ts +53 -53
  193. package/src/logging/components/AdminAnalyticsLayout.tsx +49 -49
  194. package/src/logging/components/AuditLogViewer.tsx +125 -125
  195. package/src/logging/components/ErrorMetricsCard.tsx +98 -98
  196. package/src/logging/components/HealthMetricsCard.tsx +70 -70
  197. package/src/logging/hooks/useAdminAnalytics.ts +22 -22
  198. package/src/logging/hooks/useAuditLog.ts +24 -24
  199. package/src/logging/hooks/useErrorMetrics.ts +40 -40
  200. package/src/logging/hooks/useHealthMetrics.ts +44 -44
  201. package/src/logging/index.ts +18 -18
  202. package/src/logging/types/analytics.ts +81 -81
  203. package/src/logging/types/audit.ts +31 -31
  204. package/src/logging/types/index.ts +3 -3
  205. package/src/middleware/create-middleware.ts +1141 -1141
  206. package/src/middleware/rbac-check.ts +279 -279
  207. package/src/middleware/twofa-presets.ts +224 -224
  208. package/src/models/DecodedAccessToken.ts +16 -16
  209. package/src/models/SessionModel.ts +331 -331
  210. package/src/pages/admin-login/page.tsx +111 -111
  211. package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -527
  212. package/src/pages/admin-page-permissions/index.ts +7 -7
  213. package/src/pages/admin-roles/RolesAdminPage.tsx +533 -533
  214. package/src/pages/admin-roles/index.ts +9 -9
  215. package/src/pages/admin-roles/modals.tsx +469 -469
  216. package/src/pages/client-admin/ClientSiteAdminPage.tsx +382 -382
  217. package/src/pages/client-admin/index.ts +33 -33
  218. package/src/pages/coming-soon/page.tsx +83 -83
  219. package/src/pages/login/page.tsx +338 -338
  220. package/src/pages/onboarding/index.ts +11 -11
  221. package/src/pages/onboarding/page.tsx +212 -212
  222. package/src/pages/profile/EnhancedProfilePage.tsx +514 -514
  223. package/src/pages/profile/index.ts +9 -9
  224. package/src/pages/profile/page.tsx +166 -166
  225. package/src/pages/recovery/page.tsx +267 -267
  226. package/src/pages/roles/MyRolesPage.tsx +211 -211
  227. package/src/pages/roles/components.tsx +294 -294
  228. package/src/pages/roles/index.ts +17 -17
  229. package/src/pages/security/EnhancedSecurityPage.tsx +660 -660
  230. package/src/pages/security/index.ts +12 -12
  231. package/src/pages/security/page.tsx +507 -507
  232. package/src/pages/service-unavailable/page.tsx +63 -63
  233. package/src/pages/settings/EnhancedSettingsPage.tsx +642 -642
  234. package/src/pages/settings/index.ts +9 -9
  235. package/src/pages/settings/page.tsx +47 -47
  236. package/src/pages/showcase/ShowcasePage.tsx +532 -532
  237. package/src/pages/showcase/index.ts +13 -13
  238. package/src/pages/test-env/EmergencyLogoutPage.tsx +192 -192
  239. package/src/pages/test-env/JwtInspectPage.tsx +449 -449
  240. package/src/pages/test-env/RefreshTokenPage.tsx +178 -178
  241. package/src/pages/test-env/TestEnvPage.tsx +130 -130
  242. package/src/pages/test-env/guarded.tsx +81 -81
  243. package/src/pages/test-env/index.ts +77 -77
  244. package/src/pages/test-env/redact-credentials.ts +96 -96
  245. package/src/pages/verify-code/page.tsx +746 -746
  246. package/src/routes/account/index.ts +31 -31
  247. package/src/routes/account/masked-info.ts +36 -36
  248. package/src/routes/account/send-code.ts +39 -39
  249. package/src/routes/account/update-phone.ts +13 -13
  250. package/src/routes/account/verify-email.ts +40 -40
  251. package/src/routes/account/verify-sms.ts +40 -40
  252. package/src/routes/auth/credential-login-email-code.ts +6 -6
  253. package/src/routes/auth/credential-login.ts +6 -6
  254. package/src/routes/auth/index.ts +22 -22
  255. package/src/routes/auth/logout.ts +111 -111
  256. package/src/routes/auth/nextauth.ts +45 -45
  257. package/src/routes/auth/passkey-signin.ts +13 -13
  258. package/src/routes/auth/refresh.ts +77 -77
  259. package/src/routes/auth/session-reset.ts +37 -37
  260. package/src/routes/auth/session.ts +261 -261
  261. package/src/routes/auth/settings.ts +181 -181
  262. package/src/routes/auth/viability.ts +519 -519
  263. package/src/routes/index.ts +17 -17
  264. package/src/routes/mfa/backup-code-verify.ts +1 -1
  265. package/src/routes/mfa/backup-codes.ts +10 -10
  266. package/src/routes/mfa/methods.ts +22 -22
  267. package/src/routes/mfa/totp-confirm.ts +10 -10
  268. package/src/routes/mfa/totp-enroll.ts +10 -10
  269. package/src/routes/mfa/totp-verify.ts +14 -14
  270. package/src/routes/onboarding/index.ts +15 -15
  271. package/src/routes/onboarding/send-email-code.ts +2 -2
  272. package/src/routes/onboarding/set-password.ts +2 -2
  273. package/src/routes/onboarding/verify-code.ts +2 -2
  274. package/src/routes/onboarding/verify-welcome-email.ts +2 -2
  275. package/src/routes/passkeys/list.ts +10 -10
  276. package/src/routes/passkeys/manage.ts +23 -23
  277. package/src/routes/passkeys/register-options.ts +10 -10
  278. package/src/routes/passkeys/register-verify.ts +10 -10
  279. package/src/routes/session/index.ts +7 -7
  280. package/src/routes/session/refresh-viability.ts +17 -17
  281. package/src/server/auth-guard.ts +203 -203
  282. package/src/server/auth.ts +486 -486
  283. package/src/server/decode-session.ts +309 -309
  284. package/src/server/slim-middleware.ts +117 -117
  285. package/src/server/verified-session-token.ts +103 -103
  286. package/src/server/with-auth.ts +93 -93
  287. package/src/services/signalrActivityService.ts +258 -258
  288. package/src/stores/authStore.ts +1900 -1900
  289. package/src/templates/instrumentation.ts +60 -60
  290. package/src/theme/ThemeProvider.tsx +39 -39
  291. package/src/theme/default.ts +33 -33
  292. package/src/theme/index.ts +31 -31
  293. package/src/theme/types.ts +69 -69
  294. package/src/theme/useTheme.ts +57 -57
  295. package/src/theme/utils.ts +40 -40
  296. package/src/types/api.ts +12 -12
  297. package/src/types/auth.d.ts +23 -23
  298. package/src/types/auth.ts +48 -48
  299. package/src/types/logging.ts +11 -11
  300. package/src/types/recovery.ts +54 -54
  301. package/src/utils/api.ts +401 -401
  302. package/src/utils/circuitBreaker.ts +40 -40
  303. package/src/utils/error-message.ts +108 -108
  304. package/src/utils/layout/reservedSpace.ts +124 -124
  305. package/src/utils/logout.ts +30 -30
  306. package/src/vibe/client.ts +878 -877
  307. package/src/vibe/enterprise-auth.ts +208 -208
  308. package/src/vibe/errors.ts +209 -209
  309. package/src/vibe/filters.ts +52 -0
  310. package/src/vibe/generic.ts +340 -339
  311. package/src/vibe/hooks/index.ts +367 -367
  312. package/src/vibe/index.ts +133 -133
  313. package/src/vibe/sessions.ts +925 -899
  314. package/src/vibe/types.ts +577 -577
@@ -1,1005 +1,1005 @@
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
- * ============================================================================ */
23
- /**
24
- * IDP Client Configuration
25
- *
26
- * Fetches full client configuration from IDP including:
27
- * - OAuth provider credentials (from Key Vault)
28
- * - 2FA/MFA settings
29
- * - Session configuration
30
- * - Better Auth signing secret
31
- * - Branding
32
- *
33
- * CACHING STRATEGY:
34
- * 1. In-memory cache (fastest, but lost on module reload in dev)
35
- * 2. Redis cache (survives module reloads, shared across instances)
36
- * 3. IDP fetch (when both caches miss)
37
- *
38
- * NO FALLBACKS. If IDP doesn't respond correctly, we fail loud.
39
- *
40
- * @version 2.0.0 - Added Redis-backed caching
41
- */
42
-
43
- import 'server-only';
44
- import { createHash, randomUUID } from 'crypto';
45
- import { markNeverInResponse } from './never-in-response';
46
- // REDIS IS IMPORTED LAZILY, AND THAT IS LOAD-BEARING.
47
- // A module-scope `import redis from './redis'` drags ioredis into EVERY bundle that
48
- // touches this file - including instrumentation.ts. Next compiles the instrumentation
49
- // hook for the EDGE runtime as well as node, and ioredis internally does
50
- // require('stream'), which does not exist on edge. The result was a BUILD ERROR in the
51
- // browser overlay for a code path the edge build never executes.
52
- //
53
- // The NEXT_RUNTIME check in instrumentation.ts is a RUNTIME guard; the bundler still
54
- // traces the import statically. Deferring the import to first use is what actually
55
- // keeps ioredis out of the graph. All four call sites below are already async.
56
- let _redisMod: typeof import('./redis').default | null = null;
57
- async function getRedis() {
58
- if (!_redisMod) _redisMod = (await import('./redis')).default;
59
- return _redisMod;
60
- }
61
-
62
- // ============================================================================
63
- // Types
64
- // ============================================================================
65
-
66
- export interface OAuthProviderConfig {
67
- provider: string;
68
- enabled: boolean;
69
- clientId: string;
70
- clientSecret: string;
71
- scopes?: string;
72
- additionalParams?: Record<string, any>;
73
- }
74
-
75
- export interface AuthSettings {
76
- // 257255 / BAPert v34 DATA sites: these two are POLICY. They are OPTIONAL
77
- // because a config document that does not carry a policy must produce a
78
- // config with NO policy - not an invented one. Consumers must handle
79
- // undefined; routes/auth/settings.ts refuses to serve when they are absent.
80
- // The remaining fields below are operational tuning, not authorization, so
81
- // their defaults are not in this class.
82
- require2FA?: boolean;
83
- allowed2FAMethods?: string[];
84
- mfaGracePeriodHours: number;
85
- mfaRememberDeviceDays: number;
86
- sessionTimeoutMinutes: number;
87
- idleTimeoutMinutes: number;
88
- allowRememberMe: boolean;
89
- rememberMeDays: number;
90
- lockoutThreshold: number;
91
- lockoutDurationMinutes: number;
92
-
93
- /**
94
- * 240447: tenant policy the IDP has always sent and this loader never read.
95
- *
96
- * The IDP emits these inside `auth_settings` on the internal client-config
97
- * response (PayEz.Identity.Api InternalClientConfigController, the
98
- * `auth_settings` object: allow_public_registration, allow_social_login,
99
- * enable_password_reset — backed by IDPClientSecurityPolicy).
100
- * routes/auth/settings was answering with hardcoded `true` literals
101
- * carrying the comment "Could come from config.authSettings in future",
102
- * so the portal advertised self-serve registration for a tenant whose IDP
103
- * forbids it (measured same-client same-minute: IDP
104
- * allow_public_registration=FALSE, portal allowPublicRegistration=TRUE).
105
- *
106
- * OPTIONAL ON PURPOSE — `undefined` must stay distinguishable from
107
- * `false`. undefined = the IDP did not tell us (older IDP build, or
108
- * auth_settings absent entirely); false = the IDP affirmatively said no.
109
- * Collapsing the first into a boolean default is exactly how the previous
110
- * hardcoded `true` became a lie that read like a measurement. Consumers
111
- * must decide explicitly what an unknown means for them.
112
- */
113
- allowPublicRegistration?: boolean;
114
- allowSocialLogin?: boolean;
115
- enablePasswordReset?: boolean;
116
- }
117
-
118
- export interface BrandingConfig {
119
- theme?: string;
120
- primaryColor?: string;
121
- secondaryColor?: string;
122
- logoUrl?: string;
123
- }
124
-
125
- export interface IDPClientConfig {
126
- clientId: string;
127
- clientSlug: string;
128
- /**
129
- * Cryptographic secret used by Better Auth to sign session JWTs.
130
- * Historically named "nextAuthSecret" — kept under the new name now.
131
- */
132
- authSecret: string;
133
- configCacheTtlSeconds: number;
134
- oauthProviders: OAuthProviderConfig[];
135
- authSettings: AuthSettings;
136
- branding: BrandingConfig;
137
- baseClientUrl?: string; // Public base URL - sets IDENTITY_CLIENT_BASE_EXTERNAL_URL
138
- }
139
-
140
- // ============================================================================
141
- // Cache & Fetch Deduplication
142
- // ============================================================================
143
-
144
- /**
145
- * 261588 - THE OPERATOR'S SECRET, CAPTURED BEFORE THIS MODULE CAN CLOBBER IT.
146
- *
147
- * startup-init.ts:129 defers to an operator-set BETTER_AUTH_SECRET, and its sibling
148
- * comment says that override is INTENDED ('allows deployment override for beta/staging').
149
- * The config-refresh paths in this file then overwrote it minutes later, so a deliberate
150
- * deployment override survived startup and was silently swapped on the first refresh -
151
- * sessions signed before and after the swap carry DIFFERENT secrets. A real guard
152
- * neutralised by a later path that did not get the memo.
153
- *
154
- * *** WHY THIS IS A MODULE-LOAD SNAPSHOT AND NOT `if (!process.env.BETTER_AUTH_SECRET)`. ***
155
- * That naive guard is what the cache path uses (249808). It fixes the override, but it
156
- * also means that once ANY refresh has populated the variable, NO later refresh can ever
157
- * update it - so an IDP-side secret ROTATION silently stops propagating and the app keeps
158
- * signing with a retired key. That trades one silent failure for another.
159
- *
160
- * Snapshotting at module load separates the two cases properly:
161
- * operator set it -> never overwritten, for the process lifetime (the override is real)
162
- * operator did not -> every refresh may publish, so rotation still works
163
- *
164
- * This is read ONCE, at import, before any function here has had a chance to write.
165
- */
166
- const OPERATOR_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
167
- const OPERATOR_NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET;
168
- let operatorOverrideLogged = false;
169
-
170
- // ============================================================================
171
- // PAY-1460 - SECRETS ARE ENV-FIRST
172
- // ============================================================================
173
- /**
174
- * The IdP serves the signing secret and every OAuth provider client secret in the client config, to any
175
- * caller holding a client assertion (PAY-1460). It cannot stop serving them while this package depends on
176
- * them: every earlier version throws FATAL on an empty config authSecret, whatever the environment says.
177
- *
178
- * So both are now resolved ENV FIRST, config second:
179
- * signing secret BETTER_AUTH_SECRET (canonical), NEXTAUTH_SECRET (legacy alias), then config authSecret.
180
- * Uses the module-load snapshot above: this module publishes the config value into
181
- * process.env, so a live read would mistake the IdP's value for the operator's.
182
- * provider secret <PROVIDER>_CLIENT_SECRET, provider name upper-cased with non-alphanumerics as '_'
183
- * (GOOGLE_CLIENT_SECRET, APPLE_CLIENT_SECRET, MICROSOFT_CLIENT_SECRET), then config.
184
- *
185
- * The FATAL on an empty signing secret is unchanged and still load-bearing: it now fires only when BOTH the
186
- * environment and the config are empty.
187
- *
188
- * Values are never logged. The SOURCE of each is logged once per change. An env-sourced value is never
189
- * written to the Redis config cache (stripEnvSecrets); a cache read re-applies the environment.
190
- */
191
- export type SecretSource = string; // 'env:<VAR>' | 'idp-config' | 'none'
192
-
193
- export function providerSecretEnvVar(provider: string): string {
194
- return `${String(provider).trim().toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_CLIENT_SECRET`;
195
- }
196
-
197
- const loggedSecretSources = new Map<string, SecretSource>();
198
- function logSecretSource(what: string, source: SecretSource): void {
199
- if (loggedSecretSources.get(what) === source) return;
200
- loggedSecretSources.set(what, source);
201
- console.log(`[IDP_CONFIG] ${what} source: ${source}`);
202
- }
203
-
204
- function resolveAuthSecret(fromConfig: string | undefined): { value: string; source: SecretSource } {
205
- if (OPERATOR_AUTH_SECRET) return { value: OPERATOR_AUTH_SECRET, source: 'env:BETTER_AUTH_SECRET' };
206
- if (OPERATOR_NEXTAUTH_SECRET) return { value: OPERATOR_NEXTAUTH_SECRET, source: 'env:NEXTAUTH_SECRET' };
207
- if (fromConfig) return { value: fromConfig, source: 'idp-config' };
208
- return { value: '', source: 'none' };
209
- }
210
-
211
- /** A copy of `config` with every secret resolved env-first. Never mutates (the argument may be cached). */
212
- function applyEnvSecrets(config: IDPClientConfig): IDPClientConfig {
213
- const auth = resolveAuthSecret(config.authSecret);
214
- logSecretSource('authSecret', auth.source);
215
- // PAY-1529: every secret this module resolves is registered as never-in-response HERE, the one place
216
- // both fetch paths and the Redis read pass through, so the runtime backstop knows the value whichever
217
- // handler later tries to serialize it.
218
- markNeverInResponse(auth.value);
219
- return {
220
- ...config,
221
- authSecret: auth.value,
222
- oauthProviders: (config.oauthProviders ?? []).map((p) => {
223
- const envVar = providerSecretEnvVar(p.provider);
224
- const fromEnv = process.env[envVar];
225
- const source: SecretSource = fromEnv ? `env:${envVar}` : p.clientSecret ? 'idp-config' : 'none';
226
- logSecretSource(`${p.provider} clientSecret`, source);
227
- const clientSecret = fromEnv || p.clientSecret;
228
- markNeverInResponse(clientSecret);
229
- return { ...p, clientSecret };
230
- }),
231
- };
232
- }
233
-
234
- /** What may be persisted to Redis: env-sourced secrets are blanked (the environment re-supplies them on read). */
235
- function stripEnvSecrets(config: IDPClientConfig): IDPClientConfig {
236
- return {
237
- ...config,
238
- authSecret: OPERATOR_AUTH_SECRET || OPERATOR_NEXTAUTH_SECRET ? '' : config.authSecret,
239
- oauthProviders: (config.oauthProviders ?? []).map((p) =>
240
- process.env[providerSecretEnvVar(p.provider)] ? { ...p, clientSecret: '' } : p
241
- ),
242
- };
243
- }
244
-
245
- /** Publish the resolved auth secret WITHOUT ever overwriting an operator-set value. */
246
- function publishAuthSecret(value: string, source: string): void {
247
- if (OPERATOR_AUTH_SECRET) {
248
- if (!operatorOverrideLogged) {
249
- operatorOverrideLogged = true;
250
- console.log(
251
- `[IDP_CONFIG] BETTER_AUTH_SECRET was set by the deployment; keeping it and ignoring the ${source} value (261588). ` +
252
- 'Unset it if you want the IDP to supply the signing secret.'
253
- );
254
- }
255
- } else {
256
- process.env.BETTER_AUTH_SECRET = value;
257
- }
258
- if (!OPERATOR_NEXTAUTH_SECRET) {
259
- process.env.NEXTAUTH_SECRET = value;
260
- }
261
- }
262
-
263
- let cachedConfig: IDPClientConfig | null = null;
264
- let cacheExpiry: number = 0;
265
- let pendingFetch: Promise<IDPClientConfig> | null = null; // Prevents parallel fetches
266
-
267
- // ============================================================================
268
- // Redis Cache Configuration
269
- // ============================================================================
270
-
271
- const REDIS_CONFIG_KEY_PREFIX = 'idp_config:';
272
-
273
- /**
274
- * 263437 - THE CACHE KEY MUST NAME THE BACKEND THE VALUE CAME FROM.
275
- *
276
- * The key was `idp_config:{CLIENT_ID}` - client slug ONLY. The cached value is fully
277
- * determined by (backend origin, clientId): Layer 3 below fetches from
278
- * INTERNAL_IDP_URL || IDP_URL for CLIENT_ID. Two instances pointed at DIFFERENT IDP
279
- * backends but carrying the same slug are therefore indistinguishable to this cache,
280
- * and whichever writes last serves BOTH - with ITS backend's client scope.
281
- *
282
- * WHY NOT REDIS_INSTANCE_ID (the originally proposed discriminator):
283
- * it names the STORE, not the backend. Two legs on separate stores cannot collide in
284
- * the first place; put them on ONE store - the shape this fix exists to survive - and
285
- * they report the SAME instance id, so the keys recollide and the bug returns with a
286
- * fix sitting in front of it. A discriminator that is only distinct in the
287
- * configuration where the collision is already impossible discriminates nothing.
288
- *
289
- * SCOPE NOTE, so this is not over-read the way 273385 was: an undiscriminated key is a
290
- * LATENT hazard, not the explanation for any wrong-tenant symptom measured on the walk
291
- * rigs. Measured 2026-08-28 from three seats - the 93 and AKS legs were never on one
292
- * store, and each backend independently returns the same blob for this slug. If you are
293
- * chasing a wrong clientId, the value came from the IDP record, not from this cache.
294
- */
295
- function getConfigBackendTag(): string {
296
- // Must mirror Layer 3's selection EXACTLY (INTERNAL_IDP_URL wins), or the key would
297
- // name a backend the value did not come from.
298
- const rawBackend = process.env.INTERNAL_IDP_URL || process.env.IDP_URL;
299
- if (!rawBackend) {
300
- throw new Error(
301
- '[IDP_CONFIG] FATAL: INTERNAL_IDP_URL or IDP_URL must be set - the config cache key names the backend the cached value came from'
302
- );
303
- }
304
-
305
- // ORIGIN, not the full URL: a path or trailing-slash difference is not a different
306
- // backend, and letting it fork the key would silently halve the cache hit rate - a
307
- // quieter bug in the same family. An unparseable value is used VERBATIM rather than
308
- // collapsed to a constant, which would reintroduce the collision it is meant to stop.
309
- let backendIdentity: string;
310
- try {
311
- backendIdentity = new URL(rawBackend).origin.toLowerCase();
312
- } catch {
313
- backendIdentity = rawBackend.trim().toLowerCase();
314
- }
315
-
316
- // A short digest, not the raw origin: keeps keys bounded and does not publish the
317
- // internal topology to anyone holding KEYS on a shared store.
318
- return createHash('sha256').update(backendIdentity).digest('hex').slice(0, 12);
319
- }
320
-
321
- function getRedisConfigKey(): string {
322
- // ONE ENV VAR: CLIENT_ID. Collapsed 2026-08-26.
323
- // There were three names for this value - CLIENT_ID, NEXT_PUBLIC_CLIENT_ID and
324
- // NEXT_CLIENT_ID. The NEXT_PUBLIC_ prefix exists so Next inlines a value into the
325
- // BROWSER bundle; nothing in the browser ever read it, so it bought nothing and
326
- // merely looked justified. Verified before removal: all 7 consuming sites set
327
- // CLIENT_ID in their env; none relied on the fallback.
328
- //
329
- // AND IT MUST THROW, NOT DEFAULT. This clientId is the REDIS CACHE KEY. The old
330
- // `|| 'default'` meant an app that failed to set the id did not fail - it read and
331
- // wrote config under `idp_config:default`, silently sharing one cache slot with
332
- // every other misconfigured app, while Layer 3 THREW for that same missing value.
333
- // Two behaviours for one missing variable, and the silent one owned the cache.
334
- const clientId = process.env.CLIENT_ID;
335
- if (!clientId) {
336
- throw new Error(
337
- '[IDP_CONFIG] FATAL: CLIENT_ID must be set - it is the config cache key, and defaulting it shares one cache entry across misconfigured apps'
338
- );
339
- }
340
- return `${REDIS_CONFIG_KEY_PREFIX}${clientId}:${getConfigBackendTag()}`;
341
- }
342
-
343
- interface RedisCachedConfig {
344
- config: IDPClientConfig;
345
- expiresAt: number;
346
- }
347
-
348
- async function getConfigFromRedis(): Promise<IDPClientConfig | null> {
349
- try {
350
- const key = getRedisConfigKey();
351
- const cached = await (await getRedis()).get(key);
352
- if (!cached) return null;
353
-
354
- const parsed: RedisCachedConfig = JSON.parse(cached);
355
- if (Date.now() >= parsed.expiresAt) {
356
- // Expired, delete it
357
- await (await getRedis()).del(key);
358
- return null;
359
- }
360
-
361
- return parsed.config;
362
- } catch (error) {
363
- console.warn('[IDP_CONFIG] Failed to read from Redis cache:', error);
364
- return null;
365
- }
366
- }
367
-
368
- async function setConfigInRedis(config: IDPClientConfig): Promise<void> {
369
- try {
370
- const key = getRedisConfigKey();
371
- const ttlSeconds = config.configCacheTtlSeconds || 300;
372
- const data: RedisCachedConfig = {
373
- // PAY-1460: never persist a secret the environment supplied.
374
- config: stripEnvSecrets(config),
375
- expiresAt: Date.now() + (ttlSeconds * 1000)
376
- };
377
- // Store with TTL slightly longer than the logical expiry to allow for clock skew
378
- await (await getRedis()).set(key, JSON.stringify(data), 'EX', ttlSeconds + 10);
379
- } catch (error) {
380
- console.warn('[IDP_CONFIG] Failed to write to Redis cache:', error);
381
- }
382
- }
383
-
384
- // ============================================================================
385
- // Circuit Breaker & Backoff State
386
- // ============================================================================
387
-
388
- let consecutiveFailures = 0;
389
- let lastFailureTime = 0;
390
- const MAX_FAILURES = 3;
391
- const CIRCUIT_OPEN_MS = 300000; // 5 minutes
392
- const MAX_BACKOFF_MS = 30000; // 30 seconds max backoff
393
-
394
- // ============================================================================
395
- // Main Functions
396
- // ============================================================================
397
-
398
- /**
399
- * Get IDP client configuration with multi-tier caching.
400
- *
401
- * Caching layers (checked in order):
402
- * 1. In-memory cache (fastest, lost on module reload in dev)
403
- * 2. Redis cache (survives module reloads)
404
- * 3. IDP fetch (when both caches miss)
405
- *
406
- * THROWS if IDP is unavailable or misconfigured. No fallbacks.
407
- */
408
- export async function getIDPClientConfig(forceRefresh: boolean = false): Promise<IDPClientConfig> {
409
- const now = Date.now();
410
-
411
- // Layer 1: Return in-memory cached if still valid (skip if forceRefresh)
412
- //
413
- // The `cachedConfig.authSecret` term is DEFENCE IN DEPTH, not redundancy.
414
- // Both writers are validated (the fetch path checks before caching; the
415
- // Redis path requires a non-empty secret to count as a hit), so in principle
416
- // an empty config can no longer be cached. This makes that property TRUE AT
417
- // THE POINT OF USE instead of only provable by reading the whole file —
418
- // DotNetPert 21184 and DotNetPert-Scout 21178 both argued for guarding the
419
- // return, and they are right that it is the shape that cannot drift: a
420
- // future third writer, or a cache restored from an older build's Redis
421
- // entry, is caught here regardless of what the writers do.
422
- // The cost is one boolean; the failure it prevents is every session signed
423
- // with a secret published on npm.
424
- if (!forceRefresh && cachedConfig && cachedConfig.authSecret && now < cacheExpiry) {
425
- return cachedConfig;
426
- }
427
-
428
- // If a fetch is already in progress, wait for it instead of starting another
429
- if (pendingFetch) {
430
- return pendingFetch;
431
- }
432
-
433
- // Layer 2: Check Redis cache (skip if forceRefresh - startup should always get fresh data)
434
- if (!forceRefresh) {
435
- // PAY-1460: a cached entry never carries env-sourced secrets (stripEnvSecrets), so the
436
- // environment is re-applied on read, BEFORE the emptiness test below.
437
- const cachedRaw = await getConfigFromRedis();
438
- const redisConfig = cachedRaw ? applyEnvSecrets(cachedRaw) : null;
439
- // A cached entry is only USABLE if it carries a secret — see the note
440
- // below. `redisConfig && !authSecret` must behave like a cache MISS, so
441
- // the emptiness test belongs here, in the condition that gates the
442
- // `return`, and not merely around the assignment inside it.
443
- // ── 249808 FOLLOW-THROUGH: THE CACHE MAY NEVER *ESTABLISH* THE SECRET. ──
444
- //
445
- // The rule stated just above - "a cached entry is only USABLE if it
446
- // carries a secret" - was right and did not go far enough. The cache
447
- // must also never be the FIRST source of the signing secret, because
448
- // this blob is unauthenticated (bare JSON.parse, no MAC, and its own
449
- // `expiresAt` sits inside the attacker-writable value).
450
- //
451
- // Guarding the ASSIGNMENT (the earlier 4.1.39 fix) protects a secret
452
- // that already exists. It does nothing when none exists yet - and that
453
- // window is real: the graft CATCHES the FATAL that startup-init throws
454
- // when the IDP is unreachable at boot (graft instrumentation.ts:65-69,
455
- // "BETTER_AUTH_SECRET may be unavailable until then"), so the app can
456
- // serve requests with no secret established. The first request would
457
- // then take the signing key from the cache - which is the whole
458
- // forgery vector, arriving through the back door of a fix that had
459
- // closed the front one.
460
- //
461
- // So: with no trusted secret in hand, THIS CACHE ENTRY IS A MISS. Fall
462
- // through to Layer 3, the IDP fetch, which is authoritative and already
463
- // guarded to throw on an empty secret. Escalation to truth, exactly as
464
- // the empty-secret case above does.
465
- //
466
- // Accepts either name, matching startup-init.ts:147 which verifies
467
- // `BETTER_AUTH_SECRET || NEXTAUTH_SECRET` during the rename transition.
468
- //
469
- // COST, bounded: only while no secret is established does this force an
470
- // IDP round trip. startup-init calls getIDPClientConfig(TRUE) and so
471
- // skips Layer 2 regardless, meaning normal boot is unaffected; once the
472
- // secret exists the cache serves every non-forced caller as before. The
473
- // only behaviour that changes is in the window where the alternative was
474
- // trusting an unauthenticated store for a signing key.
475
- const haveTrustedSecret = !!(process.env.BETTER_AUTH_SECRET || process.env.NEXTAUTH_SECRET);
476
- if (redisConfig && redisConfig.authSecret && haveTrustedSecret) {
477
- // Restore to in-memory cache
478
- cachedConfig = redisConfig;
479
- cacheExpiry = Date.now() + ((redisConfig.configCacheTtlSeconds || 300) * 1000);
480
-
481
- // Set BETTER_AUTH_SECRET from cached config (also set legacy
482
- // NEXTAUTH_SECRET during the rename transition).
483
- //
484
- // ⚠ THE EMPTY CASE IS NOT A NO-OP — IT WAS, AND THAT WAS THE HOLE.
485
- // The three FETCH paths (:225 external, :353 internal, post-parse)
486
- // throw on an empty authSecret, because an empty secret reaching
487
- // betterAuth() is silently replaced by the PUBLIC DEFAULT_SECRET
488
- // (create-context.mjs:79) rather than rejected. This path used to
489
- // just skip the assignment and `return redisConfig` below — so a
490
- // cached config with an empty secret bypassed ALL THREE guards, and
491
- // it is checked FIRST, before any of them. Redis persistence made it
492
- // durable across restarts. Found by QAPert (21175) after I claimed
493
- // the guards covered every path; they covered every FETCH path.
494
- //
495
- // Resolution is deliberately NOT a throw: a corrupt cache entry
496
- // should not take the app down when the authoritative source is one
497
- // call away. Treat it as a MISS and fall through to the fetch paths,
498
- // which are guarded and will throw if the IDP is genuinely empty.
499
- // That is escalation to truth, not a fallback that weakens the check.
500
- // ── 249808: AN EXPLICIT BETTER_AUTH_SECRET NOW WINS OVER THE CACHE. ──
501
- //
502
- // These two assignments were UNCONDITIONAL. That made the Redis
503
- // cache authoritative for the SESSION SIGNING KEY, and the cache is
504
- // not a trusted input: getConfigFromRedis() does a bare JSON.parse
505
- // with no signature or MAC, and its only freshness check is an
506
- // `expiresAt` field that lives INSIDE the same attacker-writable
507
- // blob. So anyone who could write that key chose the signing secret,
508
- // and knowing the secret means minting a validly-signed session for
509
- // any user — including one who has never logged in. Forgery, not
510
- // theft: it survives flushing the store and revoking refresh tokens,
511
- // because it needs neither.
512
- //
513
- // THE FILE ALREADY KNEW THE RIGHT PATTERN. Five lines below, a mere
514
- // URL defers to an explicitly-configured value, with a comment
515
- // blessing deployment override. startup-init.ts:129 defers on THIS
516
- // VERY SECRET (`config.authSecret && !process.env.BETTER_AUTH_SECRET`).
517
- // The signing key was the one value in the package taking an
518
- // untrusted cache's word unconditionally — protected less carefully
519
- // than a base URL.
520
- //
521
- // Deferring restores that consistency and makes an explicitly-set
522
- // BETTER_AUTH_SECRET a REAL secret: a value the deployment owns and
523
- // the cache cannot override.
524
- //
525
- // BOUND, stated so nobody over-reads this: it closes the vector only
526
- // where BETTER_AUTH_SECRET IS ACTUALLY SET. Where it is unset the
527
- // cache still supplies it and the exposure stands — so this is
528
- // necessary, cheap and strictly-better, NOT sufficient on its own.
529
- // Locking the store and setting the secret explicitly in every
530
- // deployment remain required.
531
- publishAuthSecret(redisConfig.authSecret, 'Redis-cached IDP config');
532
-
533
- // Set IDENTITY_CLIENT_BASE_EXTERNAL_URL from cached config.
534
- // Only set if not already defined (allows deployment override for beta/staging).
535
- if (redisConfig.baseClientUrl && !process.env.IDENTITY_CLIENT_BASE_EXTERNAL_URL) {
536
- process.env.IDENTITY_CLIENT_BASE_EXTERNAL_URL = redisConfig.baseClientUrl;
537
- }
538
-
539
- return redisConfig;
540
- }
541
- }
542
-
543
- // Layer 3: Fetch from IDP
544
- const internalIdpUrl = process.env.INTERNAL_IDP_URL;
545
- const idpUrl = process.env.IDP_URL;
546
- // CLIENT_ID only - see the note at the cache-key site above.
547
- const clientIdStr = process.env.CLIENT_ID;
548
-
549
- if (!clientIdStr) {
550
- throw new Error('[IDP_CONFIG] FATAL: CLIENT_ID must be set');
551
- }
552
- if (!internalIdpUrl && !idpUrl) {
553
- throw new Error('[IDP_CONFIG] FATAL: INTERNAL_IDP_URL or IDP_URL must be set');
554
- }
555
-
556
- // Start fetch and store promise so concurrent callers wait for same result
557
- const fetcher = internalIdpUrl
558
- ? fetchConfigFromInternalIDP(internalIdpUrl, clientIdStr)
559
- : fetchConfigFromIDP(idpUrl!, clientIdStr);
560
- pendingFetch = fetcher
561
- .then(async config => {
562
- // ── VALIDATE BEFORE CACHING. ORDER IS THE WHOLE FIX. ────────────
563
- // This block used to assign cachedConfig and await setConfigInRedis
564
- // FIRST and throw afterwards — so a response with an empty secret
565
- // poisoned BOTH caches on its way to failing. The throw only stopped
566
- // THAT call: the next one hit the in-memory layer (`return
567
- // cachedConfig`), which has no guard because it is a cache read, and
568
- // handed the empty config to betterAuth() — where empty silently
569
- // becomes the public DEFAULT_SECRET. Redis made it survive restarts.
570
- //
571
- // Guarding every cached RETURN would also work, and DotNetPert-Scout
572
- // (21178) is right that it is the more robust shape in general. But
573
- // there are four such returns and one writer, and the invariant we
574
- // actually want is "an unvalidated config is never cached at all".
575
- // Enforce it where it is cheapest to keep true.
576
- if (!config.authSecret) {
577
- // ⚠ LOAD-BEARING THROW — DO NOT SOFTEN TO A DEFAULT OR A WARNING.
578
- // better-auth does NOT treat an empty secret as missing: at
579
- // create-context.mjs:79 it substitutes DEFAULT_SECRET
580
- // ("better-auth-secret-12345678901234567890"), which ships in the
581
- // PUBLIC npm package, and its !secret guard can then never fire.
582
- // The only remaining protection there is an isProduction gate — so
583
- // outside production an empty secret means every session is signed
584
- // with a string anyone can read off npm and forge.
585
- // This throw is what stops that. It is also what turned the
586
- // 2026-08-05 nextauth_secret spelling bug into a loud FATAL instead
587
- // of a silently forgeable app.
588
- throw new Error('[IDP_CONFIG] FATAL: IDP did not return authSecret and neither BETTER_AUTH_SECRET nor NEXTAUTH_SECRET is set');
589
- }
590
-
591
- // Validated above — only now is it safe to cache or publish.
592
- publishAuthSecret(config.authSecret, 'freshly-fetched IDP config');
593
-
594
- // Cache with TTL from response (default 5 minutes)
595
- cachedConfig = config;
596
- cacheExpiry = Date.now() + ((config.configCacheTtlSeconds || 300) * 1000);
597
-
598
- // Store in Redis for persistence across module reloads. This is the
599
- // write that made a bad config outlive the process, so it must stay
600
- // BELOW the guard, never above it.
601
- await setConfigInRedis(config);
602
-
603
- // Set IDENTITY_CLIENT_BASE_EXTERNAL_URL from config.
604
- // Only set if not already defined (allows deployment override for beta/staging).
605
- if (config.baseClientUrl && !process.env.IDENTITY_CLIENT_BASE_EXTERNAL_URL) {
606
- process.env.IDENTITY_CLIENT_BASE_EXTERNAL_URL = config.baseClientUrl;
607
- console.log("[IDP_CONFIG] Set IDENTITY_CLIENT_BASE_EXTERNAL_URL:", config.baseClientUrl);
608
- }
609
-
610
- return config;
611
- })
612
- .finally(() => {
613
- pendingFetch = null; // Clear so next cache miss can fetch again
614
- });
615
-
616
- return pendingFetch;
617
- }
618
-
619
- /**
620
- * Clear the config cache (useful for testing or forced refresh)
621
- */
622
- export function clearConfigCache(): void {
623
- cachedConfig = null;
624
- cacheExpiry = 0;
625
- }
626
-
627
- /**
628
- * Clear the Redis config cache so the next fetch always goes to IDP.
629
- */
630
- export async function clearConfigRedisCache(): Promise<void> {
631
- try {
632
- const key = getRedisConfigKey();
633
- await (await getRedis()).del(key);
634
- console.log('[IDP_CONFIG] Redis cache cleared:', key);
635
- } catch (error) {
636
- console.warn('[IDP_CONFIG] Failed to clear Redis cache:', error);
637
- }
638
- }
639
-
640
- /**
641
- * Get enabled OAuth providers from config
642
- */
643
- export function getEnabledProviders(config: IDPClientConfig): OAuthProviderConfig[] {
644
- return config.oauthProviders?.filter(p => p.enabled) || [];
645
- }
646
-
647
- // ============================================================================
648
- // Internal Functions
649
- // ============================================================================
650
-
651
- async function fetchConfigFromInternalIDP(internalIdpUrl: string, clientIdStr: string): Promise<IDPClientConfig> {
652
- const containersKey = process.env.CONTAINERS_KEY;
653
- if (!containersKey) {
654
- throw new Error('[IDP_CONFIG] FATAL: CONTAINERS_KEY is required when using INTERNAL_IDP_URL');
655
- }
656
-
657
- const url = `${internalIdpUrl.replace(/\/$/, '')}/InternalClientConfig/${encodeURIComponent(clientIdStr)}`;
658
- console.log(`[IDP_CONFIG] Fetching config from internal IDP: ${url}`);
659
-
660
- const resp = await fetch(url, {
661
- method: 'GET',
662
- headers: {
663
- 'Accept': 'application/json',
664
- 'Authorization': `Secret ${containersKey}`,
665
- },
666
- cache: 'no-store'
667
- } as RequestInit);
668
-
669
- if (!resp.ok) {
670
- const txt = await resp.text().catch(() => 'Unknown error');
671
- throw new Error(`[IDP_CONFIG] FATAL: Internal IDP returned ${resp.status} (body ${txt.length} chars)`); // PAY-1427
672
- }
673
-
674
- const body: any = await resp.json().catch(() => null);
675
- if (!body) {
676
- throw new Error('[IDP_CONFIG] FATAL: Internal IDP returned empty or invalid JSON');
677
- }
678
-
679
- const configData = body?.data ?? body;
680
-
681
- const rawClientId = configData.clientId ?? configData.client_id;
682
- if (rawClientId === undefined || rawClientId === null) {
683
- throw new Error(`[IDP_CONFIG] FATAL: Internal IDP response missing clientId. Got: ${JSON.stringify(Object.keys(configData))}`);
684
- }
685
-
686
- const config: IDPClientConfig = applyEnvSecrets({
687
- clientId: String(rawClientId),
688
- clientSlug: configData.clientSlug ?? configData.client_slug ?? configData.slug ?? '',
689
- // Wire compatibility: accept new authSecret first, fall back to legacy
690
- // nextAuthSecret/next_auth_secret while IDP rename rolls out.
691
- authSecret:
692
- configData.authSecret ??
693
- configData.auth_secret ??
694
- configData.nextAuthSecret ??
695
- configData.next_auth_secret ??
696
- '',
697
- configCacheTtlSeconds: configData.configCacheTtlSeconds ?? configData.config_cache_ttl_seconds ?? 300,
698
- oauthProviders: (configData.oauthProviders ?? configData.oauth_providers ?? []).map((p: any) => ({
699
- provider: p.provider ?? '',
700
- enabled: p.enabled ?? false,
701
- clientId: p.clientId ?? p.client_id ?? '',
702
- clientSecret: p.clientSecret ?? p.client_secret ?? '',
703
- scopes: p.scopes,
704
- additionalParams: p.additionalParams ?? p.additional_params
705
- })),
706
- authSettings: {
707
- // 257255: terminal `?? true` / `?? ['email','sms']` REMOVED. They
708
- // made a document with NO auth policy indistinguishable from one
709
- // carrying exactly those values, one layer below the route that
710
- // reports policy - so even a corrected settings.ts would have
711
- // validated a fabrication. Undefined now means "not carried".
712
- require2FA: configData.authSettings?.require2FA ?? configData.auth_settings?.require_2fa,
713
- allowed2FAMethods: configData.authSettings?.allowed2FAMethods ?? configData.auth_settings?.allowed_2fa_methods,
714
- mfaGracePeriodHours: configData.authSettings?.mfaGracePeriodHours ?? configData.auth_settings?.mfa_grace_period_hours ?? 24,
715
- mfaRememberDeviceDays: configData.authSettings?.mfaRememberDeviceDays ?? configData.auth_settings?.mfa_remember_device_days ?? 30,
716
- sessionTimeoutMinutes: configData.authSettings?.sessionTimeoutMinutes ?? configData.auth_settings?.session_timeout_minutes ?? 60,
717
- idleTimeoutMinutes: configData.authSettings?.idleTimeoutMinutes ?? configData.auth_settings?.idle_timeout_minutes ?? 15,
718
- allowRememberMe: configData.authSettings?.allowRememberMe ?? configData.auth_settings?.allow_remember_me ?? true,
719
- rememberMeDays: configData.authSettings?.rememberMeDays ?? configData.auth_settings?.remember_me_days ?? 30,
720
- lockoutThreshold: configData.authSettings?.lockoutThreshold ?? configData.auth_settings?.lockout_threshold ?? 5,
721
- lockoutDurationMinutes: configData.authSettings?.lockoutDurationMinutes ?? configData.auth_settings?.lockout_duration_minutes ?? 15,
722
-
723
- // 240447: read the tenant policy the IDP already sends. Applied to
724
- // BOTH parse paths (fetchConfigFromInternalIDP and
725
- // fetchConfigFromIDP) — fixing one would leave the other silently
726
- // omitting these, which is the partial-fix trap.
727
- //
728
- // Same dual-casing idiom as every field above, but with NO
729
- // `?? default` tail, deliberately. The others default because a
730
- // wrong timeout is a nuisance; a wrong registration flag is a LIE
731
- // about what the tenant permits, and the hardcoded `true` in
732
- // routes/auth/settings was exactly that lie. `undefined` has to
733
- // survive to the consumer so it can decide, instead of being
734
- // handed a guess wearing the costume of a measurement.
735
- allowPublicRegistration:
736
- configData.authSettings?.allowPublicRegistration ??
737
- configData.auth_settings?.allow_public_registration,
738
- allowSocialLogin:
739
- configData.authSettings?.allowSocialLogin ??
740
- configData.auth_settings?.allow_social_login,
741
- enablePasswordReset:
742
- configData.authSettings?.enablePasswordReset ??
743
- configData.auth_settings?.enable_password_reset
744
- },
745
- branding: {
746
- theme: configData.branding?.theme,
747
- primaryColor: configData.branding?.primaryColor ?? configData.branding?.primary_color,
748
- secondaryColor: configData.branding?.secondaryColor ?? configData.branding?.secondary_color,
749
- logoUrl: configData.branding?.logoUrl ?? configData.branding?.logo_url
750
- },
751
- baseClientUrl: configData.baseClientUrl ?? configData.base_client_url ?? configData.BaseClientUrl
752
- });
753
-
754
- if (!config.authSecret) {
755
- // LOAD-BEARING — see the note on the external path's equivalent throw.
756
- // An empty secret reaching betterAuth() becomes the PUBLIC default, not an
757
- // error. Never replace this with a fallback.
758
- throw new Error('[IDP_CONFIG] FATAL: Internal IDP did not return authSecret and neither BETTER_AUTH_SECRET nor NEXTAUTH_SECRET is set');
759
- }
760
-
761
- console.log(`[IDP_CONFIG] Internal IDP config loaded for ${clientIdStr}`);
762
- consecutiveFailures = 0;
763
- return config;
764
- }
765
-
766
- async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<IDPClientConfig> {
767
- // =========================================================================
768
- // Circuit Breaker Check
769
- // =========================================================================
770
- if (consecutiveFailures >= MAX_FAILURES) {
771
- const timeSinceFailure = Date.now() - lastFailureTime;
772
- if (timeSinceFailure < CIRCUIT_OPEN_MS) {
773
- // Circuit is open - return stale cache if available
774
- // Stale cache is acceptable here; a stale config with NO SECRET is
775
- // not. These two paths fire exactly when the IDP is unreachable —
776
- // the worst moment to hand betterAuth() an empty secret, which it
777
- // silently upgrades to the public DEFAULT_SECRET. Falling through to
778
- // a hard failure is correct: unavailable beats forgeable.
779
- if (cachedConfig && cachedConfig.authSecret) {
780
- return cachedConfig;
781
- }
782
- throw new Error(`[IDP_CONFIG] Circuit breaker OPEN - no cached config available. Retry in ${Math.round((CIRCUIT_OPEN_MS - timeSinceFailure) / 1000)}s`);
783
- }
784
- // Half-open state: allow one request to test
785
- consecutiveFailures = MAX_FAILURES - 1;
786
- }
787
-
788
- // =========================================================================
789
- // Exponential Backoff Check
790
- // =========================================================================
791
- if (consecutiveFailures > 0) {
792
- const backoffMs = Math.min(1000 * Math.pow(2, consecutiveFailures), MAX_BACKOFF_MS);
793
- const timeSinceFailure = Date.now() - lastFailureTime;
794
- if (timeSinceFailure < backoffMs) {
795
- const remainingMs = backoffMs - timeSinceFailure;
796
- // Return stale cache during backoff if available
797
- // Stale cache is acceptable here; a stale config with NO SECRET is
798
- // not. These two paths fire exactly when the IDP is unreachable —
799
- // the worst moment to hand betterAuth() an empty secret, which it
800
- // silently upgrades to the public DEFAULT_SECRET. Falling through to
801
- // a hard failure is correct: unavailable beats forgeable.
802
- if (cachedConfig && cachedConfig.authSecret) {
803
- return cachedConfig;
804
- }
805
- throw new Error(`[IDP_CONFIG] In backoff period - retry in ${Math.round(remainingMs)}ms`);
806
- }
807
- }
808
-
809
- try {
810
- // Step 1: Get signed client assertion from IDP
811
- const signingUrl = `${idpUrl.replace(/\/$/, '')}/api/ExternalAuth/sign-client-assertion`;
812
-
813
- const signingPayload = {
814
- issuer: clientIdStr,
815
- subject: clientIdStr,
816
- audience: 'urn:payez:externalauth:clientconfig',
817
- expires_in: 60,
818
- };
819
-
820
- const signingResp = await fetch(signingUrl, {
821
- method: 'POST',
822
- headers: {
823
- 'Accept': 'application/json',
824
- 'Content-Type': 'application/json',
825
- 'X-Client-Id': clientIdStr,
826
- 'X-Correlation-Id': randomUUID().replace(/-/g, ''),
827
- },
828
- body: JSON.stringify(signingPayload),
829
- cache: 'no-store'
830
- } as RequestInit);
831
-
832
- if (!signingResp.ok) {
833
- const txt = await signingResp.text().catch(() => 'Unknown error');
834
- // PAY-1427: the thrown message is logged by callers - body length only, never the upstream text.
835
- throw new Error(`[IDP_CONFIG] FATAL: Failed to sign client assertion: ${signingResp.status} (body ${txt.length} chars)`);
836
- }
837
-
838
- const signingBody: any = await signingResp.json().catch(() => null);
839
-
840
- if (!signingBody) {
841
- throw new Error('[IDP_CONFIG] FATAL: IDP returned empty or invalid JSON for sign-client-assertion');
842
- }
843
-
844
- // Per PayEz API standard: response is { success, data: { client_assertion }, ... }
845
- // But IDP might use camelCase (clientAssertion) - check both
846
- const client_assertion = (
847
- signingBody?.data?.client_assertion ??
848
- signingBody?.data?.clientAssertion
849
- ) as string | undefined;
850
-
851
- if (!client_assertion) {
852
- // PAY-1427: never the full sign-client-assertion body (assertion material). Key names only.
853
- console.error('[IDP_CONFIG] FATAL: sign-client-assertion response shape:', { keys: Object.keys(signingBody || {}), dataKeys: Object.keys(signingBody?.data || {}) });
854
- throw new Error(`[IDP_CONFIG] FATAL: IDP response missing client_assertion. Got keys: ${JSON.stringify(Object.keys(signingBody?.data || signingBody || {}))}`);
855
- }
856
-
857
- // Step 2: Fetch client config using the assertion
858
- const configUrl = `${idpUrl.replace(/\/$/, '')}/api/ExternalAuth/client-config`;
859
-
860
- const configResp = await fetch(configUrl, {
861
- method: 'POST',
862
- headers: {
863
- 'Accept': 'application/json',
864
- 'Content-Type': 'application/json',
865
- 'X-Client-Id': clientIdStr,
866
- 'X-Correlation-Id': randomUUID().replace(/-/g, ''),
867
- },
868
- body: JSON.stringify({ client_assertion }),
869
- cache: 'no-store'
870
- } as RequestInit);
871
-
872
- if (!configResp.ok) {
873
- const txt = await configResp.text().catch(() => 'Unknown error');
874
- throw new Error(`[IDP_CONFIG] FATAL: Failed to fetch client config: ${configResp.status} (body ${txt.length} chars)`);
875
- }
876
-
877
- const configBody: any = await configResp.json().catch(() => null);
878
-
879
- if (!configBody) {
880
- throw new Error('[IDP_CONFIG] FATAL: IDP returned empty or invalid JSON for client-config');
881
- }
882
-
883
- // Per PayEz API standard: response is wrapped in { success, data: {...} }
884
- const configData = configBody?.data;
885
-
886
- if (!configData || typeof configData !== 'object') {
887
- // PAY-1427: never the full client-config body (client configuration / provider secrets). Key names only.
888
- console.error('[IDP_CONFIG] FATAL: client-config response shape:', { keys: Object.keys(configBody || {}) });
889
- throw new Error('[IDP_CONFIG] FATAL: IDP client-config response missing data envelope');
890
- }
891
-
892
- // Validate required fields - handle both number and string client_id
893
- const rawClientId = configData.clientId ?? configData.client_id;
894
- if (rawClientId === undefined || rawClientId === null) {
895
- throw new Error(`[IDP_CONFIG] FATAL: IDP response missing clientId/client_id. Got: ${JSON.stringify(Object.keys(configData))}`);
896
- }
897
-
898
- // Map response to our interface (IDP returns camelCase or snake_case).
899
- // Wire compatibility: accept new authSecret first, fall back to legacy
900
- // nextAuthSecret/next_auth_secret while IDP rename rolls out.
901
- const config: IDPClientConfig = applyEnvSecrets({
902
- clientId: String(rawClientId),
903
- clientSlug: configData.clientSlug ?? configData.client_slug ?? configData.slug ?? '',
904
- authSecret:
905
- configData.authSecret ??
906
- configData.auth_secret ??
907
- configData.nextAuthSecret ??
908
- configData.next_auth_secret ??
909
- '',
910
- configCacheTtlSeconds: configData.configCacheTtlSeconds ?? configData.config_cache_ttl_seconds ?? 300,
911
- oauthProviders: (configData.oauthProviders ?? configData.oauth_providers ?? []).map((p: any) => ({
912
- provider: p.provider ?? '',
913
- enabled: p.enabled ?? false,
914
- clientId: p.clientId ?? p.client_id ?? '',
915
- clientSecret: p.clientSecret ?? p.client_secret ?? '',
916
- scopes: p.scopes,
917
- additionalParams: p.additionalParams ?? p.additional_params
918
- })),
919
- authSettings: {
920
- require2FA: (() => {
921
- // Check nested locations first (canonical)
922
- const nested = configData.authSettings?.require2FA ?? configData.auth_settings?.require_2fa;
923
- if (nested !== undefined) return nested;
924
- // TRANSITION FALLBACK: Check top-level (deprecated)
925
- const topLevel = configData.require2FA ?? configData.require_2fa;
926
- if (topLevel !== undefined) {
927
- console.warn('[IDP_CONFIG] DEPRECATION: require2FA found at top-level. Should be nested under auth_settings. Update IDP.');
928
- return topLevel;
929
- }
930
- // 257255: was `return true`. A default here is a FABRICATED
931
- // POLICY - QAPert measured the same client as require_2fa TRUE
932
- // on AKS and FALSE on dev-93, so any constant is wrong in one
933
- // environment by construction. Undefined = "not carried".
934
- return undefined;
935
- })(),
936
- allowed2FAMethods: configData.authSettings?.allowed2FAMethods ?? configData.auth_settings?.allowed_2fa_methods,
937
- mfaGracePeriodHours: configData.authSettings?.mfaGracePeriodHours ?? configData.auth_settings?.mfa_grace_period_hours ?? 24,
938
- mfaRememberDeviceDays: configData.authSettings?.mfaRememberDeviceDays ?? configData.auth_settings?.mfa_remember_device_days ?? 30,
939
- sessionTimeoutMinutes: configData.authSettings?.sessionTimeoutMinutes ?? configData.auth_settings?.session_timeout_minutes ?? 60,
940
- idleTimeoutMinutes: configData.authSettings?.idleTimeoutMinutes ?? configData.auth_settings?.idle_timeout_minutes ?? 15,
941
- allowRememberMe: configData.authSettings?.allowRememberMe ?? configData.auth_settings?.allow_remember_me ?? true,
942
- rememberMeDays: configData.authSettings?.rememberMeDays ?? configData.auth_settings?.remember_me_days ?? 30,
943
- lockoutThreshold: configData.authSettings?.lockoutThreshold ?? configData.auth_settings?.lockout_threshold ?? 5,
944
- lockoutDurationMinutes: configData.authSettings?.lockoutDurationMinutes ?? configData.auth_settings?.lockout_duration_minutes ?? 15,
945
-
946
- // 240447: read the tenant policy the IDP already sends. Applied to
947
- // BOTH parse paths (fetchConfigFromInternalIDP and
948
- // fetchConfigFromIDP) — fixing one would leave the other silently
949
- // omitting these, which is the partial-fix trap.
950
- //
951
- // Same dual-casing idiom as every field above, but with NO
952
- // `?? default` tail, deliberately. The others default because a
953
- // wrong timeout is a nuisance; a wrong registration flag is a LIE
954
- // about what the tenant permits, and the hardcoded `true` in
955
- // routes/auth/settings was exactly that lie. `undefined` has to
956
- // survive to the consumer so it can decide, instead of being
957
- // handed a guess wearing the costume of a measurement.
958
- allowPublicRegistration:
959
- configData.authSettings?.allowPublicRegistration ??
960
- configData.auth_settings?.allow_public_registration,
961
- allowSocialLogin:
962
- configData.authSettings?.allowSocialLogin ??
963
- configData.auth_settings?.allow_social_login,
964
- enablePasswordReset:
965
- configData.authSettings?.enablePasswordReset ??
966
- configData.auth_settings?.enable_password_reset
967
- },
968
- branding: {
969
- theme: configData.branding?.theme,
970
- primaryColor: configData.branding?.primaryColor ?? configData.branding?.primary_color,
971
- secondaryColor: configData.branding?.secondaryColor ?? configData.branding?.secondary_color,
972
- logoUrl: configData.branding?.logoUrl ?? configData.branding?.logo_url
973
- },
974
- baseClientUrl: configData.baseClientUrl ?? configData.base_client_url ?? configData.BaseClientUrl
975
- });
976
-
977
- // Debug: log what we got for baseClientUrl
978
- console.log(`[IDP_CONFIG] Parsed baseClientUrl:`, config.baseClientUrl, `| raw keys:`, Object.keys(configData).filter(k => k.toLowerCase().includes('client')));
979
-
980
- // Validate we got what we need
981
- if (!config.clientId) {
982
- throw new Error('[IDP_CONFIG] FATAL: clientId is empty or missing after parsing');
983
- }
984
- if (!config.authSecret) {
985
- // LOAD-BEARING — empty must fail here, never downstream. betterAuth()
986
- // silently substitutes a public constant for an empty secret.
987
- throw new Error('[IDP_CONFIG] FATAL: authSecret is empty after parsing and neither BETTER_AUTH_SECRET nor NEXTAUTH_SECRET is set');
988
- }
989
-
990
- // Success - reset failure tracking
991
- consecutiveFailures = 0;
992
- return config;
993
-
994
- } catch (error) {
995
- // Track failure for circuit breaker
996
- consecutiveFailures++;
997
- lastFailureTime = Date.now();
998
- console.error('[IDP_CONFIG] Fetch failed', {
999
- consecutiveFailures,
1000
- maxFailures: MAX_FAILURES,
1001
- error: error instanceof Error ? error.message : String(error)
1002
- });
1003
- throw error;
1004
- }
1005
- }
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
+ * ============================================================================ */
23
+ /**
24
+ * IDP Client Configuration
25
+ *
26
+ * Fetches full client configuration from IDP including:
27
+ * - OAuth provider credentials (from Key Vault)
28
+ * - 2FA/MFA settings
29
+ * - Session configuration
30
+ * - Better Auth signing secret
31
+ * - Branding
32
+ *
33
+ * CACHING STRATEGY:
34
+ * 1. In-memory cache (fastest, but lost on module reload in dev)
35
+ * 2. Redis cache (survives module reloads, shared across instances)
36
+ * 3. IDP fetch (when both caches miss)
37
+ *
38
+ * NO FALLBACKS. If IDP doesn't respond correctly, we fail loud.
39
+ *
40
+ * @version 2.0.0 - Added Redis-backed caching
41
+ */
42
+
43
+ import 'server-only';
44
+ import { createHash, randomUUID } from 'crypto';
45
+ import { markNeverInResponse } from './never-in-response';
46
+ // REDIS IS IMPORTED LAZILY, AND THAT IS LOAD-BEARING.
47
+ // A module-scope `import redis from './redis'` drags ioredis into EVERY bundle that
48
+ // touches this file - including instrumentation.ts. Next compiles the instrumentation
49
+ // hook for the EDGE runtime as well as node, and ioredis internally does
50
+ // require('stream'), which does not exist on edge. The result was a BUILD ERROR in the
51
+ // browser overlay for a code path the edge build never executes.
52
+ //
53
+ // The NEXT_RUNTIME check in instrumentation.ts is a RUNTIME guard; the bundler still
54
+ // traces the import statically. Deferring the import to first use is what actually
55
+ // keeps ioredis out of the graph. All four call sites below are already async.
56
+ let _redisMod: typeof import('./redis').default | null = null;
57
+ async function getRedis() {
58
+ if (!_redisMod) _redisMod = (await import('./redis')).default;
59
+ return _redisMod;
60
+ }
61
+
62
+ // ============================================================================
63
+ // Types
64
+ // ============================================================================
65
+
66
+ export interface OAuthProviderConfig {
67
+ provider: string;
68
+ enabled: boolean;
69
+ clientId: string;
70
+ clientSecret: string;
71
+ scopes?: string;
72
+ additionalParams?: Record<string, any>;
73
+ }
74
+
75
+ export interface AuthSettings {
76
+ // 257255 / BAPert v34 DATA sites: these two are POLICY. They are OPTIONAL
77
+ // because a config document that does not carry a policy must produce a
78
+ // config with NO policy - not an invented one. Consumers must handle
79
+ // undefined; routes/auth/settings.ts refuses to serve when they are absent.
80
+ // The remaining fields below are operational tuning, not authorization, so
81
+ // their defaults are not in this class.
82
+ require2FA?: boolean;
83
+ allowed2FAMethods?: string[];
84
+ mfaGracePeriodHours: number;
85
+ mfaRememberDeviceDays: number;
86
+ sessionTimeoutMinutes: number;
87
+ idleTimeoutMinutes: number;
88
+ allowRememberMe: boolean;
89
+ rememberMeDays: number;
90
+ lockoutThreshold: number;
91
+ lockoutDurationMinutes: number;
92
+
93
+ /**
94
+ * 240447: tenant policy the IDP has always sent and this loader never read.
95
+ *
96
+ * The IDP emits these inside `auth_settings` on the internal client-config
97
+ * response (PayEz.Identity.Api InternalClientConfigController, the
98
+ * `auth_settings` object: allow_public_registration, allow_social_login,
99
+ * enable_password_reset — backed by IDPClientSecurityPolicy).
100
+ * routes/auth/settings was answering with hardcoded `true` literals
101
+ * carrying the comment "Could come from config.authSettings in future",
102
+ * so the portal advertised self-serve registration for a tenant whose IDP
103
+ * forbids it (measured same-client same-minute: IDP
104
+ * allow_public_registration=FALSE, portal allowPublicRegistration=TRUE).
105
+ *
106
+ * OPTIONAL ON PURPOSE — `undefined` must stay distinguishable from
107
+ * `false`. undefined = the IDP did not tell us (older IDP build, or
108
+ * auth_settings absent entirely); false = the IDP affirmatively said no.
109
+ * Collapsing the first into a boolean default is exactly how the previous
110
+ * hardcoded `true` became a lie that read like a measurement. Consumers
111
+ * must decide explicitly what an unknown means for them.
112
+ */
113
+ allowPublicRegistration?: boolean;
114
+ allowSocialLogin?: boolean;
115
+ enablePasswordReset?: boolean;
116
+ }
117
+
118
+ export interface BrandingConfig {
119
+ theme?: string;
120
+ primaryColor?: string;
121
+ secondaryColor?: string;
122
+ logoUrl?: string;
123
+ }
124
+
125
+ export interface IDPClientConfig {
126
+ clientId: string;
127
+ clientSlug: string;
128
+ /**
129
+ * Cryptographic secret used by Better Auth to sign session JWTs.
130
+ * Historically named "nextAuthSecret" — kept under the new name now.
131
+ */
132
+ authSecret: string;
133
+ configCacheTtlSeconds: number;
134
+ oauthProviders: OAuthProviderConfig[];
135
+ authSettings: AuthSettings;
136
+ branding: BrandingConfig;
137
+ baseClientUrl?: string; // Public base URL - sets IDENTITY_CLIENT_BASE_EXTERNAL_URL
138
+ }
139
+
140
+ // ============================================================================
141
+ // Cache & Fetch Deduplication
142
+ // ============================================================================
143
+
144
+ /**
145
+ * 261588 - THE OPERATOR'S SECRET, CAPTURED BEFORE THIS MODULE CAN CLOBBER IT.
146
+ *
147
+ * startup-init.ts:129 defers to an operator-set BETTER_AUTH_SECRET, and its sibling
148
+ * comment says that override is INTENDED ('allows deployment override for beta/staging').
149
+ * The config-refresh paths in this file then overwrote it minutes later, so a deliberate
150
+ * deployment override survived startup and was silently swapped on the first refresh -
151
+ * sessions signed before and after the swap carry DIFFERENT secrets. A real guard
152
+ * neutralised by a later path that did not get the memo.
153
+ *
154
+ * *** WHY THIS IS A MODULE-LOAD SNAPSHOT AND NOT `if (!process.env.BETTER_AUTH_SECRET)`. ***
155
+ * That naive guard is what the cache path uses (249808). It fixes the override, but it
156
+ * also means that once ANY refresh has populated the variable, NO later refresh can ever
157
+ * update it - so an IDP-side secret ROTATION silently stops propagating and the app keeps
158
+ * signing with a retired key. That trades one silent failure for another.
159
+ *
160
+ * Snapshotting at module load separates the two cases properly:
161
+ * operator set it -> never overwritten, for the process lifetime (the override is real)
162
+ * operator did not -> every refresh may publish, so rotation still works
163
+ *
164
+ * This is read ONCE, at import, before any function here has had a chance to write.
165
+ */
166
+ const OPERATOR_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
167
+ const OPERATOR_NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET;
168
+ let operatorOverrideLogged = false;
169
+
170
+ // ============================================================================
171
+ // PAY-1460 - SECRETS ARE ENV-FIRST
172
+ // ============================================================================
173
+ /**
174
+ * The IdP serves the signing secret and every OAuth provider client secret in the client config, to any
175
+ * caller holding a client assertion (PAY-1460). It cannot stop serving them while this package depends on
176
+ * them: every earlier version throws FATAL on an empty config authSecret, whatever the environment says.
177
+ *
178
+ * So both are now resolved ENV FIRST, config second:
179
+ * signing secret BETTER_AUTH_SECRET (canonical), NEXTAUTH_SECRET (legacy alias), then config authSecret.
180
+ * Uses the module-load snapshot above: this module publishes the config value into
181
+ * process.env, so a live read would mistake the IdP's value for the operator's.
182
+ * provider secret <PROVIDER>_CLIENT_SECRET, provider name upper-cased with non-alphanumerics as '_'
183
+ * (GOOGLE_CLIENT_SECRET, APPLE_CLIENT_SECRET, MICROSOFT_CLIENT_SECRET), then config.
184
+ *
185
+ * The FATAL on an empty signing secret is unchanged and still load-bearing: it now fires only when BOTH the
186
+ * environment and the config are empty.
187
+ *
188
+ * Values are never logged. The SOURCE of each is logged once per change. An env-sourced value is never
189
+ * written to the Redis config cache (stripEnvSecrets); a cache read re-applies the environment.
190
+ */
191
+ export type SecretSource = string; // 'env:<VAR>' | 'idp-config' | 'none'
192
+
193
+ export function providerSecretEnvVar(provider: string): string {
194
+ return `${String(provider).trim().toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_CLIENT_SECRET`;
195
+ }
196
+
197
+ const loggedSecretSources = new Map<string, SecretSource>();
198
+ function logSecretSource(what: string, source: SecretSource): void {
199
+ if (loggedSecretSources.get(what) === source) return;
200
+ loggedSecretSources.set(what, source);
201
+ console.log(`[IDP_CONFIG] ${what} source: ${source}`);
202
+ }
203
+
204
+ function resolveAuthSecret(fromConfig: string | undefined): { value: string; source: SecretSource } {
205
+ if (OPERATOR_AUTH_SECRET) return { value: OPERATOR_AUTH_SECRET, source: 'env:BETTER_AUTH_SECRET' };
206
+ if (OPERATOR_NEXTAUTH_SECRET) return { value: OPERATOR_NEXTAUTH_SECRET, source: 'env:NEXTAUTH_SECRET' };
207
+ if (fromConfig) return { value: fromConfig, source: 'idp-config' };
208
+ return { value: '', source: 'none' };
209
+ }
210
+
211
+ /** A copy of `config` with every secret resolved env-first. Never mutates (the argument may be cached). */
212
+ function applyEnvSecrets(config: IDPClientConfig): IDPClientConfig {
213
+ const auth = resolveAuthSecret(config.authSecret);
214
+ logSecretSource('authSecret', auth.source);
215
+ // PAY-1529: every secret this module resolves is registered as never-in-response HERE, the one place
216
+ // both fetch paths and the Redis read pass through, so the runtime backstop knows the value whichever
217
+ // handler later tries to serialize it.
218
+ markNeverInResponse(auth.value);
219
+ return {
220
+ ...config,
221
+ authSecret: auth.value,
222
+ oauthProviders: (config.oauthProviders ?? []).map((p) => {
223
+ const envVar = providerSecretEnvVar(p.provider);
224
+ const fromEnv = process.env[envVar];
225
+ const source: SecretSource = fromEnv ? `env:${envVar}` : p.clientSecret ? 'idp-config' : 'none';
226
+ logSecretSource(`${p.provider} clientSecret`, source);
227
+ const clientSecret = fromEnv || p.clientSecret;
228
+ markNeverInResponse(clientSecret);
229
+ return { ...p, clientSecret };
230
+ }),
231
+ };
232
+ }
233
+
234
+ /** What may be persisted to Redis: env-sourced secrets are blanked (the environment re-supplies them on read). */
235
+ function stripEnvSecrets(config: IDPClientConfig): IDPClientConfig {
236
+ return {
237
+ ...config,
238
+ authSecret: OPERATOR_AUTH_SECRET || OPERATOR_NEXTAUTH_SECRET ? '' : config.authSecret,
239
+ oauthProviders: (config.oauthProviders ?? []).map((p) =>
240
+ process.env[providerSecretEnvVar(p.provider)] ? { ...p, clientSecret: '' } : p
241
+ ),
242
+ };
243
+ }
244
+
245
+ /** Publish the resolved auth secret WITHOUT ever overwriting an operator-set value. */
246
+ function publishAuthSecret(value: string, source: string): void {
247
+ if (OPERATOR_AUTH_SECRET) {
248
+ if (!operatorOverrideLogged) {
249
+ operatorOverrideLogged = true;
250
+ console.log(
251
+ `[IDP_CONFIG] BETTER_AUTH_SECRET was set by the deployment; keeping it and ignoring the ${source} value (261588). ` +
252
+ 'Unset it if you want the IDP to supply the signing secret.'
253
+ );
254
+ }
255
+ } else {
256
+ process.env.BETTER_AUTH_SECRET = value;
257
+ }
258
+ if (!OPERATOR_NEXTAUTH_SECRET) {
259
+ process.env.NEXTAUTH_SECRET = value;
260
+ }
261
+ }
262
+
263
+ let cachedConfig: IDPClientConfig | null = null;
264
+ let cacheExpiry: number = 0;
265
+ let pendingFetch: Promise<IDPClientConfig> | null = null; // Prevents parallel fetches
266
+
267
+ // ============================================================================
268
+ // Redis Cache Configuration
269
+ // ============================================================================
270
+
271
+ const REDIS_CONFIG_KEY_PREFIX = 'idp_config:';
272
+
273
+ /**
274
+ * 263437 - THE CACHE KEY MUST NAME THE BACKEND THE VALUE CAME FROM.
275
+ *
276
+ * The key was `idp_config:{CLIENT_ID}` - client slug ONLY. The cached value is fully
277
+ * determined by (backend origin, clientId): Layer 3 below fetches from
278
+ * INTERNAL_IDP_URL || IDP_URL for CLIENT_ID. Two instances pointed at DIFFERENT IDP
279
+ * backends but carrying the same slug are therefore indistinguishable to this cache,
280
+ * and whichever writes last serves BOTH - with ITS backend's client scope.
281
+ *
282
+ * WHY NOT REDIS_INSTANCE_ID (the originally proposed discriminator):
283
+ * it names the STORE, not the backend. Two legs on separate stores cannot collide in
284
+ * the first place; put them on ONE store - the shape this fix exists to survive - and
285
+ * they report the SAME instance id, so the keys recollide and the bug returns with a
286
+ * fix sitting in front of it. A discriminator that is only distinct in the
287
+ * configuration where the collision is already impossible discriminates nothing.
288
+ *
289
+ * SCOPE NOTE, so this is not over-read the way 273385 was: an undiscriminated key is a
290
+ * LATENT hazard, not the explanation for any wrong-tenant symptom measured on the walk
291
+ * rigs. Measured 2026-08-28 from three seats - the 93 and AKS legs were never on one
292
+ * store, and each backend independently returns the same blob for this slug. If you are
293
+ * chasing a wrong clientId, the value came from the IDP record, not from this cache.
294
+ */
295
+ function getConfigBackendTag(): string {
296
+ // Must mirror Layer 3's selection EXACTLY (INTERNAL_IDP_URL wins), or the key would
297
+ // name a backend the value did not come from.
298
+ const rawBackend = process.env.INTERNAL_IDP_URL || process.env.IDP_URL;
299
+ if (!rawBackend) {
300
+ throw new Error(
301
+ '[IDP_CONFIG] FATAL: INTERNAL_IDP_URL or IDP_URL must be set - the config cache key names the backend the cached value came from'
302
+ );
303
+ }
304
+
305
+ // ORIGIN, not the full URL: a path or trailing-slash difference is not a different
306
+ // backend, and letting it fork the key would silently halve the cache hit rate - a
307
+ // quieter bug in the same family. An unparseable value is used VERBATIM rather than
308
+ // collapsed to a constant, which would reintroduce the collision it is meant to stop.
309
+ let backendIdentity: string;
310
+ try {
311
+ backendIdentity = new URL(rawBackend).origin.toLowerCase();
312
+ } catch {
313
+ backendIdentity = rawBackend.trim().toLowerCase();
314
+ }
315
+
316
+ // A short digest, not the raw origin: keeps keys bounded and does not publish the
317
+ // internal topology to anyone holding KEYS on a shared store.
318
+ return createHash('sha256').update(backendIdentity).digest('hex').slice(0, 12);
319
+ }
320
+
321
+ function getRedisConfigKey(): string {
322
+ // ONE ENV VAR: CLIENT_ID. Collapsed 2026-08-26.
323
+ // There were three names for this value - CLIENT_ID, NEXT_PUBLIC_CLIENT_ID and
324
+ // NEXT_CLIENT_ID. The NEXT_PUBLIC_ prefix exists so Next inlines a value into the
325
+ // BROWSER bundle; nothing in the browser ever read it, so it bought nothing and
326
+ // merely looked justified. Verified before removal: all 7 consuming sites set
327
+ // CLIENT_ID in their env; none relied on the fallback.
328
+ //
329
+ // AND IT MUST THROW, NOT DEFAULT. This clientId is the REDIS CACHE KEY. The old
330
+ // `|| 'default'` meant an app that failed to set the id did not fail - it read and
331
+ // wrote config under `idp_config:default`, silently sharing one cache slot with
332
+ // every other misconfigured app, while Layer 3 THREW for that same missing value.
333
+ // Two behaviours for one missing variable, and the silent one owned the cache.
334
+ const clientId = process.env.CLIENT_ID;
335
+ if (!clientId) {
336
+ throw new Error(
337
+ '[IDP_CONFIG] FATAL: CLIENT_ID must be set - it is the config cache key, and defaulting it shares one cache entry across misconfigured apps'
338
+ );
339
+ }
340
+ return `${REDIS_CONFIG_KEY_PREFIX}${clientId}:${getConfigBackendTag()}`;
341
+ }
342
+
343
+ interface RedisCachedConfig {
344
+ config: IDPClientConfig;
345
+ expiresAt: number;
346
+ }
347
+
348
+ async function getConfigFromRedis(): Promise<IDPClientConfig | null> {
349
+ try {
350
+ const key = getRedisConfigKey();
351
+ const cached = await (await getRedis()).get(key);
352
+ if (!cached) return null;
353
+
354
+ const parsed: RedisCachedConfig = JSON.parse(cached);
355
+ if (Date.now() >= parsed.expiresAt) {
356
+ // Expired, delete it
357
+ await (await getRedis()).del(key);
358
+ return null;
359
+ }
360
+
361
+ return parsed.config;
362
+ } catch (error) {
363
+ console.warn('[IDP_CONFIG] Failed to read from Redis cache:', error);
364
+ return null;
365
+ }
366
+ }
367
+
368
+ async function setConfigInRedis(config: IDPClientConfig): Promise<void> {
369
+ try {
370
+ const key = getRedisConfigKey();
371
+ const ttlSeconds = config.configCacheTtlSeconds || 300;
372
+ const data: RedisCachedConfig = {
373
+ // PAY-1460: never persist a secret the environment supplied.
374
+ config: stripEnvSecrets(config),
375
+ expiresAt: Date.now() + (ttlSeconds * 1000)
376
+ };
377
+ // Store with TTL slightly longer than the logical expiry to allow for clock skew
378
+ await (await getRedis()).set(key, JSON.stringify(data), 'EX', ttlSeconds + 10);
379
+ } catch (error) {
380
+ console.warn('[IDP_CONFIG] Failed to write to Redis cache:', error);
381
+ }
382
+ }
383
+
384
+ // ============================================================================
385
+ // Circuit Breaker & Backoff State
386
+ // ============================================================================
387
+
388
+ let consecutiveFailures = 0;
389
+ let lastFailureTime = 0;
390
+ const MAX_FAILURES = 3;
391
+ const CIRCUIT_OPEN_MS = 300000; // 5 minutes
392
+ const MAX_BACKOFF_MS = 30000; // 30 seconds max backoff
393
+
394
+ // ============================================================================
395
+ // Main Functions
396
+ // ============================================================================
397
+
398
+ /**
399
+ * Get IDP client configuration with multi-tier caching.
400
+ *
401
+ * Caching layers (checked in order):
402
+ * 1. In-memory cache (fastest, lost on module reload in dev)
403
+ * 2. Redis cache (survives module reloads)
404
+ * 3. IDP fetch (when both caches miss)
405
+ *
406
+ * THROWS if IDP is unavailable or misconfigured. No fallbacks.
407
+ */
408
+ export async function getIDPClientConfig(forceRefresh: boolean = false): Promise<IDPClientConfig> {
409
+ const now = Date.now();
410
+
411
+ // Layer 1: Return in-memory cached if still valid (skip if forceRefresh)
412
+ //
413
+ // The `cachedConfig.authSecret` term is DEFENCE IN DEPTH, not redundancy.
414
+ // Both writers are validated (the fetch path checks before caching; the
415
+ // Redis path requires a non-empty secret to count as a hit), so in principle
416
+ // an empty config can no longer be cached. This makes that property TRUE AT
417
+ // THE POINT OF USE instead of only provable by reading the whole file —
418
+ // DotNetPert 21184 and DotNetPert-Scout 21178 both argued for guarding the
419
+ // return, and they are right that it is the shape that cannot drift: a
420
+ // future third writer, or a cache restored from an older build's Redis
421
+ // entry, is caught here regardless of what the writers do.
422
+ // The cost is one boolean; the failure it prevents is every session signed
423
+ // with a secret published on npm.
424
+ if (!forceRefresh && cachedConfig && cachedConfig.authSecret && now < cacheExpiry) {
425
+ return cachedConfig;
426
+ }
427
+
428
+ // If a fetch is already in progress, wait for it instead of starting another
429
+ if (pendingFetch) {
430
+ return pendingFetch;
431
+ }
432
+
433
+ // Layer 2: Check Redis cache (skip if forceRefresh - startup should always get fresh data)
434
+ if (!forceRefresh) {
435
+ // PAY-1460: a cached entry never carries env-sourced secrets (stripEnvSecrets), so the
436
+ // environment is re-applied on read, BEFORE the emptiness test below.
437
+ const cachedRaw = await getConfigFromRedis();
438
+ const redisConfig = cachedRaw ? applyEnvSecrets(cachedRaw) : null;
439
+ // A cached entry is only USABLE if it carries a secret — see the note
440
+ // below. `redisConfig && !authSecret` must behave like a cache MISS, so
441
+ // the emptiness test belongs here, in the condition that gates the
442
+ // `return`, and not merely around the assignment inside it.
443
+ // ── 249808 FOLLOW-THROUGH: THE CACHE MAY NEVER *ESTABLISH* THE SECRET. ──
444
+ //
445
+ // The rule stated just above - "a cached entry is only USABLE if it
446
+ // carries a secret" - was right and did not go far enough. The cache
447
+ // must also never be the FIRST source of the signing secret, because
448
+ // this blob is unauthenticated (bare JSON.parse, no MAC, and its own
449
+ // `expiresAt` sits inside the attacker-writable value).
450
+ //
451
+ // Guarding the ASSIGNMENT (the earlier 4.1.39 fix) protects a secret
452
+ // that already exists. It does nothing when none exists yet - and that
453
+ // window is real: the graft CATCHES the FATAL that startup-init throws
454
+ // when the IDP is unreachable at boot (graft instrumentation.ts:65-69,
455
+ // "BETTER_AUTH_SECRET may be unavailable until then"), so the app can
456
+ // serve requests with no secret established. The first request would
457
+ // then take the signing key from the cache - which is the whole
458
+ // forgery vector, arriving through the back door of a fix that had
459
+ // closed the front one.
460
+ //
461
+ // So: with no trusted secret in hand, THIS CACHE ENTRY IS A MISS. Fall
462
+ // through to Layer 3, the IDP fetch, which is authoritative and already
463
+ // guarded to throw on an empty secret. Escalation to truth, exactly as
464
+ // the empty-secret case above does.
465
+ //
466
+ // Accepts either name, matching startup-init.ts:147 which verifies
467
+ // `BETTER_AUTH_SECRET || NEXTAUTH_SECRET` during the rename transition.
468
+ //
469
+ // COST, bounded: only while no secret is established does this force an
470
+ // IDP round trip. startup-init calls getIDPClientConfig(TRUE) and so
471
+ // skips Layer 2 regardless, meaning normal boot is unaffected; once the
472
+ // secret exists the cache serves every non-forced caller as before. The
473
+ // only behaviour that changes is in the window where the alternative was
474
+ // trusting an unauthenticated store for a signing key.
475
+ const haveTrustedSecret = !!(process.env.BETTER_AUTH_SECRET || process.env.NEXTAUTH_SECRET);
476
+ if (redisConfig && redisConfig.authSecret && haveTrustedSecret) {
477
+ // Restore to in-memory cache
478
+ cachedConfig = redisConfig;
479
+ cacheExpiry = Date.now() + ((redisConfig.configCacheTtlSeconds || 300) * 1000);
480
+
481
+ // Set BETTER_AUTH_SECRET from cached config (also set legacy
482
+ // NEXTAUTH_SECRET during the rename transition).
483
+ //
484
+ // ⚠ THE EMPTY CASE IS NOT A NO-OP — IT WAS, AND THAT WAS THE HOLE.
485
+ // The three FETCH paths (:225 external, :353 internal, post-parse)
486
+ // throw on an empty authSecret, because an empty secret reaching
487
+ // betterAuth() is silently replaced by the PUBLIC DEFAULT_SECRET
488
+ // (create-context.mjs:79) rather than rejected. This path used to
489
+ // just skip the assignment and `return redisConfig` below — so a
490
+ // cached config with an empty secret bypassed ALL THREE guards, and
491
+ // it is checked FIRST, before any of them. Redis persistence made it
492
+ // durable across restarts. Found by QAPert (21175) after I claimed
493
+ // the guards covered every path; they covered every FETCH path.
494
+ //
495
+ // Resolution is deliberately NOT a throw: a corrupt cache entry
496
+ // should not take the app down when the authoritative source is one
497
+ // call away. Treat it as a MISS and fall through to the fetch paths,
498
+ // which are guarded and will throw if the IDP is genuinely empty.
499
+ // That is escalation to truth, not a fallback that weakens the check.
500
+ // ── 249808: AN EXPLICIT BETTER_AUTH_SECRET NOW WINS OVER THE CACHE. ──
501
+ //
502
+ // These two assignments were UNCONDITIONAL. That made the Redis
503
+ // cache authoritative for the SESSION SIGNING KEY, and the cache is
504
+ // not a trusted input: getConfigFromRedis() does a bare JSON.parse
505
+ // with no signature or MAC, and its only freshness check is an
506
+ // `expiresAt` field that lives INSIDE the same attacker-writable
507
+ // blob. So anyone who could write that key chose the signing secret,
508
+ // and knowing the secret means minting a validly-signed session for
509
+ // any user — including one who has never logged in. Forgery, not
510
+ // theft: it survives flushing the store and revoking refresh tokens,
511
+ // because it needs neither.
512
+ //
513
+ // THE FILE ALREADY KNEW THE RIGHT PATTERN. Five lines below, a mere
514
+ // URL defers to an explicitly-configured value, with a comment
515
+ // blessing deployment override. startup-init.ts:129 defers on THIS
516
+ // VERY SECRET (`config.authSecret && !process.env.BETTER_AUTH_SECRET`).
517
+ // The signing key was the one value in the package taking an
518
+ // untrusted cache's word unconditionally — protected less carefully
519
+ // than a base URL.
520
+ //
521
+ // Deferring restores that consistency and makes an explicitly-set
522
+ // BETTER_AUTH_SECRET a REAL secret: a value the deployment owns and
523
+ // the cache cannot override.
524
+ //
525
+ // BOUND, stated so nobody over-reads this: it closes the vector only
526
+ // where BETTER_AUTH_SECRET IS ACTUALLY SET. Where it is unset the
527
+ // cache still supplies it and the exposure stands — so this is
528
+ // necessary, cheap and strictly-better, NOT sufficient on its own.
529
+ // Locking the store and setting the secret explicitly in every
530
+ // deployment remain required.
531
+ publishAuthSecret(redisConfig.authSecret, 'Redis-cached IDP config');
532
+
533
+ // Set IDENTITY_CLIENT_BASE_EXTERNAL_URL from cached config.
534
+ // Only set if not already defined (allows deployment override for beta/staging).
535
+ if (redisConfig.baseClientUrl && !process.env.IDENTITY_CLIENT_BASE_EXTERNAL_URL) {
536
+ process.env.IDENTITY_CLIENT_BASE_EXTERNAL_URL = redisConfig.baseClientUrl;
537
+ }
538
+
539
+ return redisConfig;
540
+ }
541
+ }
542
+
543
+ // Layer 3: Fetch from IDP
544
+ const internalIdpUrl = process.env.INTERNAL_IDP_URL;
545
+ const idpUrl = process.env.IDP_URL;
546
+ // CLIENT_ID only - see the note at the cache-key site above.
547
+ const clientIdStr = process.env.CLIENT_ID;
548
+
549
+ if (!clientIdStr) {
550
+ throw new Error('[IDP_CONFIG] FATAL: CLIENT_ID must be set');
551
+ }
552
+ if (!internalIdpUrl && !idpUrl) {
553
+ throw new Error('[IDP_CONFIG] FATAL: INTERNAL_IDP_URL or IDP_URL must be set');
554
+ }
555
+
556
+ // Start fetch and store promise so concurrent callers wait for same result
557
+ const fetcher = internalIdpUrl
558
+ ? fetchConfigFromInternalIDP(internalIdpUrl, clientIdStr)
559
+ : fetchConfigFromIDP(idpUrl!, clientIdStr);
560
+ pendingFetch = fetcher
561
+ .then(async config => {
562
+ // ── VALIDATE BEFORE CACHING. ORDER IS THE WHOLE FIX. ────────────
563
+ // This block used to assign cachedConfig and await setConfigInRedis
564
+ // FIRST and throw afterwards — so a response with an empty secret
565
+ // poisoned BOTH caches on its way to failing. The throw only stopped
566
+ // THAT call: the next one hit the in-memory layer (`return
567
+ // cachedConfig`), which has no guard because it is a cache read, and
568
+ // handed the empty config to betterAuth() — where empty silently
569
+ // becomes the public DEFAULT_SECRET. Redis made it survive restarts.
570
+ //
571
+ // Guarding every cached RETURN would also work, and DotNetPert-Scout
572
+ // (21178) is right that it is the more robust shape in general. But
573
+ // there are four such returns and one writer, and the invariant we
574
+ // actually want is "an unvalidated config is never cached at all".
575
+ // Enforce it where it is cheapest to keep true.
576
+ if (!config.authSecret) {
577
+ // ⚠ LOAD-BEARING THROW — DO NOT SOFTEN TO A DEFAULT OR A WARNING.
578
+ // better-auth does NOT treat an empty secret as missing: at
579
+ // create-context.mjs:79 it substitutes DEFAULT_SECRET
580
+ // ("better-auth-secret-12345678901234567890"), which ships in the
581
+ // PUBLIC npm package, and its !secret guard can then never fire.
582
+ // The only remaining protection there is an isProduction gate — so
583
+ // outside production an empty secret means every session is signed
584
+ // with a string anyone can read off npm and forge.
585
+ // This throw is what stops that. It is also what turned the
586
+ // 2026-08-05 nextauth_secret spelling bug into a loud FATAL instead
587
+ // of a silently forgeable app.
588
+ throw new Error('[IDP_CONFIG] FATAL: IDP did not return authSecret and neither BETTER_AUTH_SECRET nor NEXTAUTH_SECRET is set');
589
+ }
590
+
591
+ // Validated above — only now is it safe to cache or publish.
592
+ publishAuthSecret(config.authSecret, 'freshly-fetched IDP config');
593
+
594
+ // Cache with TTL from response (default 5 minutes)
595
+ cachedConfig = config;
596
+ cacheExpiry = Date.now() + ((config.configCacheTtlSeconds || 300) * 1000);
597
+
598
+ // Store in Redis for persistence across module reloads. This is the
599
+ // write that made a bad config outlive the process, so it must stay
600
+ // BELOW the guard, never above it.
601
+ await setConfigInRedis(config);
602
+
603
+ // Set IDENTITY_CLIENT_BASE_EXTERNAL_URL from config.
604
+ // Only set if not already defined (allows deployment override for beta/staging).
605
+ if (config.baseClientUrl && !process.env.IDENTITY_CLIENT_BASE_EXTERNAL_URL) {
606
+ process.env.IDENTITY_CLIENT_BASE_EXTERNAL_URL = config.baseClientUrl;
607
+ console.log("[IDP_CONFIG] Set IDENTITY_CLIENT_BASE_EXTERNAL_URL:", config.baseClientUrl);
608
+ }
609
+
610
+ return config;
611
+ })
612
+ .finally(() => {
613
+ pendingFetch = null; // Clear so next cache miss can fetch again
614
+ });
615
+
616
+ return pendingFetch;
617
+ }
618
+
619
+ /**
620
+ * Clear the config cache (useful for testing or forced refresh)
621
+ */
622
+ export function clearConfigCache(): void {
623
+ cachedConfig = null;
624
+ cacheExpiry = 0;
625
+ }
626
+
627
+ /**
628
+ * Clear the Redis config cache so the next fetch always goes to IDP.
629
+ */
630
+ export async function clearConfigRedisCache(): Promise<void> {
631
+ try {
632
+ const key = getRedisConfigKey();
633
+ await (await getRedis()).del(key);
634
+ console.log('[IDP_CONFIG] Redis cache cleared:', key);
635
+ } catch (error) {
636
+ console.warn('[IDP_CONFIG] Failed to clear Redis cache:', error);
637
+ }
638
+ }
639
+
640
+ /**
641
+ * Get enabled OAuth providers from config
642
+ */
643
+ export function getEnabledProviders(config: IDPClientConfig): OAuthProviderConfig[] {
644
+ return config.oauthProviders?.filter(p => p.enabled) || [];
645
+ }
646
+
647
+ // ============================================================================
648
+ // Internal Functions
649
+ // ============================================================================
650
+
651
+ async function fetchConfigFromInternalIDP(internalIdpUrl: string, clientIdStr: string): Promise<IDPClientConfig> {
652
+ const containersKey = process.env.CONTAINERS_KEY;
653
+ if (!containersKey) {
654
+ throw new Error('[IDP_CONFIG] FATAL: CONTAINERS_KEY is required when using INTERNAL_IDP_URL');
655
+ }
656
+
657
+ const url = `${internalIdpUrl.replace(/\/$/, '')}/InternalClientConfig/${encodeURIComponent(clientIdStr)}`;
658
+ console.log(`[IDP_CONFIG] Fetching config from internal IDP: ${url}`);
659
+
660
+ const resp = await fetch(url, {
661
+ method: 'GET',
662
+ headers: {
663
+ 'Accept': 'application/json',
664
+ 'Authorization': `Secret ${containersKey}`,
665
+ },
666
+ cache: 'no-store'
667
+ } as RequestInit);
668
+
669
+ if (!resp.ok) {
670
+ const txt = await resp.text().catch(() => 'Unknown error');
671
+ throw new Error(`[IDP_CONFIG] FATAL: Internal IDP returned ${resp.status} (body ${txt.length} chars)`); // PAY-1427
672
+ }
673
+
674
+ const body: any = await resp.json().catch(() => null);
675
+ if (!body) {
676
+ throw new Error('[IDP_CONFIG] FATAL: Internal IDP returned empty or invalid JSON');
677
+ }
678
+
679
+ const configData = body?.data ?? body;
680
+
681
+ const rawClientId = configData.clientId ?? configData.client_id;
682
+ if (rawClientId === undefined || rawClientId === null) {
683
+ throw new Error(`[IDP_CONFIG] FATAL: Internal IDP response missing clientId. Got: ${JSON.stringify(Object.keys(configData))}`);
684
+ }
685
+
686
+ const config: IDPClientConfig = applyEnvSecrets({
687
+ clientId: String(rawClientId),
688
+ clientSlug: configData.clientSlug ?? configData.client_slug ?? configData.slug ?? '',
689
+ // Wire compatibility: accept new authSecret first, fall back to legacy
690
+ // nextAuthSecret/next_auth_secret while IDP rename rolls out.
691
+ authSecret:
692
+ configData.authSecret ??
693
+ configData.auth_secret ??
694
+ configData.nextAuthSecret ??
695
+ configData.next_auth_secret ??
696
+ '',
697
+ configCacheTtlSeconds: configData.configCacheTtlSeconds ?? configData.config_cache_ttl_seconds ?? 300,
698
+ oauthProviders: (configData.oauthProviders ?? configData.oauth_providers ?? []).map((p: any) => ({
699
+ provider: p.provider ?? '',
700
+ enabled: p.enabled ?? false,
701
+ clientId: p.clientId ?? p.client_id ?? '',
702
+ clientSecret: p.clientSecret ?? p.client_secret ?? '',
703
+ scopes: p.scopes,
704
+ additionalParams: p.additionalParams ?? p.additional_params
705
+ })),
706
+ authSettings: {
707
+ // 257255: terminal `?? true` / `?? ['email','sms']` REMOVED. They
708
+ // made a document with NO auth policy indistinguishable from one
709
+ // carrying exactly those values, one layer below the route that
710
+ // reports policy - so even a corrected settings.ts would have
711
+ // validated a fabrication. Undefined now means "not carried".
712
+ require2FA: configData.authSettings?.require2FA ?? configData.auth_settings?.require_2fa,
713
+ allowed2FAMethods: configData.authSettings?.allowed2FAMethods ?? configData.auth_settings?.allowed_2fa_methods,
714
+ mfaGracePeriodHours: configData.authSettings?.mfaGracePeriodHours ?? configData.auth_settings?.mfa_grace_period_hours ?? 24,
715
+ mfaRememberDeviceDays: configData.authSettings?.mfaRememberDeviceDays ?? configData.auth_settings?.mfa_remember_device_days ?? 30,
716
+ sessionTimeoutMinutes: configData.authSettings?.sessionTimeoutMinutes ?? configData.auth_settings?.session_timeout_minutes ?? 60,
717
+ idleTimeoutMinutes: configData.authSettings?.idleTimeoutMinutes ?? configData.auth_settings?.idle_timeout_minutes ?? 15,
718
+ allowRememberMe: configData.authSettings?.allowRememberMe ?? configData.auth_settings?.allow_remember_me ?? true,
719
+ rememberMeDays: configData.authSettings?.rememberMeDays ?? configData.auth_settings?.remember_me_days ?? 30,
720
+ lockoutThreshold: configData.authSettings?.lockoutThreshold ?? configData.auth_settings?.lockout_threshold ?? 5,
721
+ lockoutDurationMinutes: configData.authSettings?.lockoutDurationMinutes ?? configData.auth_settings?.lockout_duration_minutes ?? 15,
722
+
723
+ // 240447: read the tenant policy the IDP already sends. Applied to
724
+ // BOTH parse paths (fetchConfigFromInternalIDP and
725
+ // fetchConfigFromIDP) — fixing one would leave the other silently
726
+ // omitting these, which is the partial-fix trap.
727
+ //
728
+ // Same dual-casing idiom as every field above, but with NO
729
+ // `?? default` tail, deliberately. The others default because a
730
+ // wrong timeout is a nuisance; a wrong registration flag is a LIE
731
+ // about what the tenant permits, and the hardcoded `true` in
732
+ // routes/auth/settings was exactly that lie. `undefined` has to
733
+ // survive to the consumer so it can decide, instead of being
734
+ // handed a guess wearing the costume of a measurement.
735
+ allowPublicRegistration:
736
+ configData.authSettings?.allowPublicRegistration ??
737
+ configData.auth_settings?.allow_public_registration,
738
+ allowSocialLogin:
739
+ configData.authSettings?.allowSocialLogin ??
740
+ configData.auth_settings?.allow_social_login,
741
+ enablePasswordReset:
742
+ configData.authSettings?.enablePasswordReset ??
743
+ configData.auth_settings?.enable_password_reset
744
+ },
745
+ branding: {
746
+ theme: configData.branding?.theme,
747
+ primaryColor: configData.branding?.primaryColor ?? configData.branding?.primary_color,
748
+ secondaryColor: configData.branding?.secondaryColor ?? configData.branding?.secondary_color,
749
+ logoUrl: configData.branding?.logoUrl ?? configData.branding?.logo_url
750
+ },
751
+ baseClientUrl: configData.baseClientUrl ?? configData.base_client_url ?? configData.BaseClientUrl
752
+ });
753
+
754
+ if (!config.authSecret) {
755
+ // LOAD-BEARING — see the note on the external path's equivalent throw.
756
+ // An empty secret reaching betterAuth() becomes the PUBLIC default, not an
757
+ // error. Never replace this with a fallback.
758
+ throw new Error('[IDP_CONFIG] FATAL: Internal IDP did not return authSecret and neither BETTER_AUTH_SECRET nor NEXTAUTH_SECRET is set');
759
+ }
760
+
761
+ console.log(`[IDP_CONFIG] Internal IDP config loaded for ${clientIdStr}`);
762
+ consecutiveFailures = 0;
763
+ return config;
764
+ }
765
+
766
+ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<IDPClientConfig> {
767
+ // =========================================================================
768
+ // Circuit Breaker Check
769
+ // =========================================================================
770
+ if (consecutiveFailures >= MAX_FAILURES) {
771
+ const timeSinceFailure = Date.now() - lastFailureTime;
772
+ if (timeSinceFailure < CIRCUIT_OPEN_MS) {
773
+ // Circuit is open - return stale cache if available
774
+ // Stale cache is acceptable here; a stale config with NO SECRET is
775
+ // not. These two paths fire exactly when the IDP is unreachable —
776
+ // the worst moment to hand betterAuth() an empty secret, which it
777
+ // silently upgrades to the public DEFAULT_SECRET. Falling through to
778
+ // a hard failure is correct: unavailable beats forgeable.
779
+ if (cachedConfig && cachedConfig.authSecret) {
780
+ return cachedConfig;
781
+ }
782
+ throw new Error(`[IDP_CONFIG] Circuit breaker OPEN - no cached config available. Retry in ${Math.round((CIRCUIT_OPEN_MS - timeSinceFailure) / 1000)}s`);
783
+ }
784
+ // Half-open state: allow one request to test
785
+ consecutiveFailures = MAX_FAILURES - 1;
786
+ }
787
+
788
+ // =========================================================================
789
+ // Exponential Backoff Check
790
+ // =========================================================================
791
+ if (consecutiveFailures > 0) {
792
+ const backoffMs = Math.min(1000 * Math.pow(2, consecutiveFailures), MAX_BACKOFF_MS);
793
+ const timeSinceFailure = Date.now() - lastFailureTime;
794
+ if (timeSinceFailure < backoffMs) {
795
+ const remainingMs = backoffMs - timeSinceFailure;
796
+ // Return stale cache during backoff if available
797
+ // Stale cache is acceptable here; a stale config with NO SECRET is
798
+ // not. These two paths fire exactly when the IDP is unreachable —
799
+ // the worst moment to hand betterAuth() an empty secret, which it
800
+ // silently upgrades to the public DEFAULT_SECRET. Falling through to
801
+ // a hard failure is correct: unavailable beats forgeable.
802
+ if (cachedConfig && cachedConfig.authSecret) {
803
+ return cachedConfig;
804
+ }
805
+ throw new Error(`[IDP_CONFIG] In backoff period - retry in ${Math.round(remainingMs)}ms`);
806
+ }
807
+ }
808
+
809
+ try {
810
+ // Step 1: Get signed client assertion from IDP
811
+ const signingUrl = `${idpUrl.replace(/\/$/, '')}/api/ExternalAuth/sign-client-assertion`;
812
+
813
+ const signingPayload = {
814
+ issuer: clientIdStr,
815
+ subject: clientIdStr,
816
+ audience: 'urn:payez:externalauth:clientconfig',
817
+ expires_in: 60,
818
+ };
819
+
820
+ const signingResp = await fetch(signingUrl, {
821
+ method: 'POST',
822
+ headers: {
823
+ 'Accept': 'application/json',
824
+ 'Content-Type': 'application/json',
825
+ 'X-Client-Id': clientIdStr,
826
+ 'X-Correlation-Id': randomUUID().replace(/-/g, ''),
827
+ },
828
+ body: JSON.stringify(signingPayload),
829
+ cache: 'no-store'
830
+ } as RequestInit);
831
+
832
+ if (!signingResp.ok) {
833
+ const txt = await signingResp.text().catch(() => 'Unknown error');
834
+ // PAY-1427: the thrown message is logged by callers - body length only, never the upstream text.
835
+ throw new Error(`[IDP_CONFIG] FATAL: Failed to sign client assertion: ${signingResp.status} (body ${txt.length} chars)`);
836
+ }
837
+
838
+ const signingBody: any = await signingResp.json().catch(() => null);
839
+
840
+ if (!signingBody) {
841
+ throw new Error('[IDP_CONFIG] FATAL: IDP returned empty or invalid JSON for sign-client-assertion');
842
+ }
843
+
844
+ // Per PayEz API standard: response is { success, data: { client_assertion }, ... }
845
+ // But IDP might use camelCase (clientAssertion) - check both
846
+ const client_assertion = (
847
+ signingBody?.data?.client_assertion ??
848
+ signingBody?.data?.clientAssertion
849
+ ) as string | undefined;
850
+
851
+ if (!client_assertion) {
852
+ // PAY-1427: never the full sign-client-assertion body (assertion material). Key names only.
853
+ console.error('[IDP_CONFIG] FATAL: sign-client-assertion response shape:', { keys: Object.keys(signingBody || {}), dataKeys: Object.keys(signingBody?.data || {}) });
854
+ throw new Error(`[IDP_CONFIG] FATAL: IDP response missing client_assertion. Got keys: ${JSON.stringify(Object.keys(signingBody?.data || signingBody || {}))}`);
855
+ }
856
+
857
+ // Step 2: Fetch client config using the assertion
858
+ const configUrl = `${idpUrl.replace(/\/$/, '')}/api/ExternalAuth/client-config`;
859
+
860
+ const configResp = await fetch(configUrl, {
861
+ method: 'POST',
862
+ headers: {
863
+ 'Accept': 'application/json',
864
+ 'Content-Type': 'application/json',
865
+ 'X-Client-Id': clientIdStr,
866
+ 'X-Correlation-Id': randomUUID().replace(/-/g, ''),
867
+ },
868
+ body: JSON.stringify({ client_assertion }),
869
+ cache: 'no-store'
870
+ } as RequestInit);
871
+
872
+ if (!configResp.ok) {
873
+ const txt = await configResp.text().catch(() => 'Unknown error');
874
+ throw new Error(`[IDP_CONFIG] FATAL: Failed to fetch client config: ${configResp.status} (body ${txt.length} chars)`);
875
+ }
876
+
877
+ const configBody: any = await configResp.json().catch(() => null);
878
+
879
+ if (!configBody) {
880
+ throw new Error('[IDP_CONFIG] FATAL: IDP returned empty or invalid JSON for client-config');
881
+ }
882
+
883
+ // Per PayEz API standard: response is wrapped in { success, data: {...} }
884
+ const configData = configBody?.data;
885
+
886
+ if (!configData || typeof configData !== 'object') {
887
+ // PAY-1427: never the full client-config body (client configuration / provider secrets). Key names only.
888
+ console.error('[IDP_CONFIG] FATAL: client-config response shape:', { keys: Object.keys(configBody || {}) });
889
+ throw new Error('[IDP_CONFIG] FATAL: IDP client-config response missing data envelope');
890
+ }
891
+
892
+ // Validate required fields - handle both number and string client_id
893
+ const rawClientId = configData.clientId ?? configData.client_id;
894
+ if (rawClientId === undefined || rawClientId === null) {
895
+ throw new Error(`[IDP_CONFIG] FATAL: IDP response missing clientId/client_id. Got: ${JSON.stringify(Object.keys(configData))}`);
896
+ }
897
+
898
+ // Map response to our interface (IDP returns camelCase or snake_case).
899
+ // Wire compatibility: accept new authSecret first, fall back to legacy
900
+ // nextAuthSecret/next_auth_secret while IDP rename rolls out.
901
+ const config: IDPClientConfig = applyEnvSecrets({
902
+ clientId: String(rawClientId),
903
+ clientSlug: configData.clientSlug ?? configData.client_slug ?? configData.slug ?? '',
904
+ authSecret:
905
+ configData.authSecret ??
906
+ configData.auth_secret ??
907
+ configData.nextAuthSecret ??
908
+ configData.next_auth_secret ??
909
+ '',
910
+ configCacheTtlSeconds: configData.configCacheTtlSeconds ?? configData.config_cache_ttl_seconds ?? 300,
911
+ oauthProviders: (configData.oauthProviders ?? configData.oauth_providers ?? []).map((p: any) => ({
912
+ provider: p.provider ?? '',
913
+ enabled: p.enabled ?? false,
914
+ clientId: p.clientId ?? p.client_id ?? '',
915
+ clientSecret: p.clientSecret ?? p.client_secret ?? '',
916
+ scopes: p.scopes,
917
+ additionalParams: p.additionalParams ?? p.additional_params
918
+ })),
919
+ authSettings: {
920
+ require2FA: (() => {
921
+ // Check nested locations first (canonical)
922
+ const nested = configData.authSettings?.require2FA ?? configData.auth_settings?.require_2fa;
923
+ if (nested !== undefined) return nested;
924
+ // TRANSITION FALLBACK: Check top-level (deprecated)
925
+ const topLevel = configData.require2FA ?? configData.require_2fa;
926
+ if (topLevel !== undefined) {
927
+ console.warn('[IDP_CONFIG] DEPRECATION: require2FA found at top-level. Should be nested under auth_settings. Update IDP.');
928
+ return topLevel;
929
+ }
930
+ // 257255: was `return true`. A default here is a FABRICATED
931
+ // POLICY - QAPert measured the same client as require_2fa TRUE
932
+ // on AKS and FALSE on dev-93, so any constant is wrong in one
933
+ // environment by construction. Undefined = "not carried".
934
+ return undefined;
935
+ })(),
936
+ allowed2FAMethods: configData.authSettings?.allowed2FAMethods ?? configData.auth_settings?.allowed_2fa_methods,
937
+ mfaGracePeriodHours: configData.authSettings?.mfaGracePeriodHours ?? configData.auth_settings?.mfa_grace_period_hours ?? 24,
938
+ mfaRememberDeviceDays: configData.authSettings?.mfaRememberDeviceDays ?? configData.auth_settings?.mfa_remember_device_days ?? 30,
939
+ sessionTimeoutMinutes: configData.authSettings?.sessionTimeoutMinutes ?? configData.auth_settings?.session_timeout_minutes ?? 60,
940
+ idleTimeoutMinutes: configData.authSettings?.idleTimeoutMinutes ?? configData.auth_settings?.idle_timeout_minutes ?? 15,
941
+ allowRememberMe: configData.authSettings?.allowRememberMe ?? configData.auth_settings?.allow_remember_me ?? true,
942
+ rememberMeDays: configData.authSettings?.rememberMeDays ?? configData.auth_settings?.remember_me_days ?? 30,
943
+ lockoutThreshold: configData.authSettings?.lockoutThreshold ?? configData.auth_settings?.lockout_threshold ?? 5,
944
+ lockoutDurationMinutes: configData.authSettings?.lockoutDurationMinutes ?? configData.auth_settings?.lockout_duration_minutes ?? 15,
945
+
946
+ // 240447: read the tenant policy the IDP already sends. Applied to
947
+ // BOTH parse paths (fetchConfigFromInternalIDP and
948
+ // fetchConfigFromIDP) — fixing one would leave the other silently
949
+ // omitting these, which is the partial-fix trap.
950
+ //
951
+ // Same dual-casing idiom as every field above, but with NO
952
+ // `?? default` tail, deliberately. The others default because a
953
+ // wrong timeout is a nuisance; a wrong registration flag is a LIE
954
+ // about what the tenant permits, and the hardcoded `true` in
955
+ // routes/auth/settings was exactly that lie. `undefined` has to
956
+ // survive to the consumer so it can decide, instead of being
957
+ // handed a guess wearing the costume of a measurement.
958
+ allowPublicRegistration:
959
+ configData.authSettings?.allowPublicRegistration ??
960
+ configData.auth_settings?.allow_public_registration,
961
+ allowSocialLogin:
962
+ configData.authSettings?.allowSocialLogin ??
963
+ configData.auth_settings?.allow_social_login,
964
+ enablePasswordReset:
965
+ configData.authSettings?.enablePasswordReset ??
966
+ configData.auth_settings?.enable_password_reset
967
+ },
968
+ branding: {
969
+ theme: configData.branding?.theme,
970
+ primaryColor: configData.branding?.primaryColor ?? configData.branding?.primary_color,
971
+ secondaryColor: configData.branding?.secondaryColor ?? configData.branding?.secondary_color,
972
+ logoUrl: configData.branding?.logoUrl ?? configData.branding?.logo_url
973
+ },
974
+ baseClientUrl: configData.baseClientUrl ?? configData.base_client_url ?? configData.BaseClientUrl
975
+ });
976
+
977
+ // Debug: log what we got for baseClientUrl
978
+ console.log(`[IDP_CONFIG] Parsed baseClientUrl:`, config.baseClientUrl, `| raw keys:`, Object.keys(configData).filter(k => k.toLowerCase().includes('client')));
979
+
980
+ // Validate we got what we need
981
+ if (!config.clientId) {
982
+ throw new Error('[IDP_CONFIG] FATAL: clientId is empty or missing after parsing');
983
+ }
984
+ if (!config.authSecret) {
985
+ // LOAD-BEARING — empty must fail here, never downstream. betterAuth()
986
+ // silently substitutes a public constant for an empty secret.
987
+ throw new Error('[IDP_CONFIG] FATAL: authSecret is empty after parsing and neither BETTER_AUTH_SECRET nor NEXTAUTH_SECRET is set');
988
+ }
989
+
990
+ // Success - reset failure tracking
991
+ consecutiveFailures = 0;
992
+ return config;
993
+
994
+ } catch (error) {
995
+ // Track failure for circuit breaker
996
+ consecutiveFailures++;
997
+ lastFailureTime = Date.now();
998
+ console.error('[IDP_CONFIG] Fetch failed', {
999
+ consecutiveFailures,
1000
+ maxFailures: MAX_FAILURES,
1001
+ error: error instanceof Error ? error.message : String(error)
1002
+ });
1003
+ throw error;
1004
+ }
1005
+ }