@payez/next-mvp 4.1.11 → 4.1.13

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 (92) hide show
  1. package/dist/api-handlers/auth/refresh.js +15 -38
  2. package/dist/auth/better-auth.d.ts +0 -3
  3. package/dist/auth/better-auth.js +164 -69
  4. package/dist/client/better-auth-client.d.ts +14 -2
  5. package/dist/lib/ensure-fresh-access-token.js +8 -42
  6. package/dist/lib/session-store.d.ts +0 -1
  7. package/dist/lib/session-store.js +29 -33
  8. package/dist/lib/startup-init.js +19 -19
  9. package/dist/lib/token-lifecycle.d.ts +0 -2
  10. package/dist/lib/token-lifecycle.js +3 -21
  11. package/package.json +5 -4
  12. package/scripts/check-internal-url-usage.sh +73 -73
  13. package/src/api/index.ts +17 -17
  14. package/src/api-handlers/account/masked-info.ts +45 -45
  15. package/src/api-handlers/account/profile.ts +80 -80
  16. package/src/api-handlers/account/recovery/initiate.ts +23 -23
  17. package/src/api-handlers/account/recovery/send-code.ts +25 -25
  18. package/src/api-handlers/account/recovery/verify-code.ts +25 -25
  19. package/src/api-handlers/account/reset-password.ts +23 -23
  20. package/src/api-handlers/account/send-code.ts +76 -76
  21. package/src/api-handlers/account/validate-password.ts +118 -118
  22. package/src/api-handlers/admin/stats.ts +249 -249
  23. package/src/api-handlers/anon/preferences.ts +123 -123
  24. package/src/api-handlers/auth/jwks.ts +20 -20
  25. package/src/api-handlers/auth/login.ts +240 -240
  26. package/src/api-handlers/auth/refresh.ts +15 -38
  27. package/src/api-handlers/auth/validate.ts +43 -43
  28. package/src/api-handlers/session/viability.ts +142 -142
  29. package/src/auth/better-auth.ts +130 -29
  30. package/src/auth/unauthenticated-routes.ts +2 -2
  31. package/src/client/better-auth-client.ts +70 -70
  32. package/src/client/fetchWithSession.ts +21 -21
  33. package/src/client/index.ts +13 -13
  34. package/src/client/useAnonSession.ts +131 -131
  35. package/src/components/admin/VibeAdminContext.tsx +87 -87
  36. package/src/config/logging-config.ts +82 -82
  37. package/src/config/unauthenticated-routes.ts +19 -19
  38. package/src/edge/internal-api-url.ts +65 -65
  39. package/src/edge/middleware.ts +42 -42
  40. package/src/hooks/useProfile.ts +75 -75
  41. package/src/lib/anon-session.ts +213 -213
  42. package/src/lib/auth-secret.ts +137 -137
  43. package/src/lib/demo-mode.ts +17 -17
  44. package/src/lib/ensure-fresh-access-token.ts +8 -42
  45. package/src/lib/idp-client-config.ts +559 -559
  46. package/src/lib/idp-fetch.ts +126 -126
  47. package/src/lib/internal-api.ts +171 -171
  48. package/src/lib/jwt-decode-client.ts +45 -45
  49. package/src/lib/rate-limit-service.ts +9 -9
  50. package/src/lib/redis.ts +26 -26
  51. package/src/lib/refresh-token-validator.ts +64 -64
  52. package/src/lib/secret-validation.ts +8 -8
  53. package/src/lib/session-store.ts +692 -695
  54. package/src/lib/session.ts +34 -34
  55. package/src/lib/startup-init.ts +251 -251
  56. package/src/lib/token-expiry.ts +40 -40
  57. package/src/lib/token-lifecycle.ts +470 -497
  58. package/src/lib/types/api-responses.ts +336 -336
  59. package/src/middleware/auth-decision.ts +43 -43
  60. package/src/models/DecodedAccessToken.ts +16 -16
  61. package/src/models/SessionModel.ts +264 -264
  62. package/src/pages/admin-roles/index.ts +9 -9
  63. package/src/pages/security/EnhancedSecurityPage.tsx +9 -9
  64. package/src/routes/account/index.ts +31 -31
  65. package/src/routes/account/masked-info.ts +36 -36
  66. package/src/routes/account/send-code.ts +39 -39
  67. package/src/routes/account/verify-email.ts +40 -40
  68. package/src/routes/account/verify-sms.ts +40 -40
  69. package/src/routes/auth/index.ts +22 -22
  70. package/src/routes/auth/refresh.ts +48 -48
  71. package/src/routes/auth/session.ts +166 -166
  72. package/src/routes/index.ts +17 -17
  73. package/src/routes/session/index.ts +7 -7
  74. package/src/routes/session/refresh-viability.ts +17 -17
  75. package/src/server/auth.ts +373 -373
  76. package/src/server/decode-session.ts +202 -202
  77. package/src/services/signalrActivityService.ts +258 -258
  78. package/src/theme/ThemeProvider.tsx +39 -39
  79. package/src/theme/default.ts +33 -33
  80. package/src/theme/index.ts +31 -31
  81. package/src/theme/types.ts +69 -69
  82. package/src/theme/useTheme.ts +57 -57
  83. package/src/theme/utils.ts +40 -40
  84. package/src/types/api.ts +12 -12
  85. package/src/types/auth.d.ts +23 -23
  86. package/src/types/auth.ts +22 -22
  87. package/src/types/logging.ts +11 -11
  88. package/src/types/recovery.ts +54 -54
  89. package/src/utils/api.ts +353 -353
  90. package/src/utils/circuitBreaker.ts +40 -40
  91. package/src/utils/error-message.ts +108 -108
  92. package/src/utils/layout/reservedSpace.ts +124 -124
@@ -1,559 +1,559 @@
1
- /**
2
- * IDP Client Configuration
3
- *
4
- * Fetches full client configuration from IDP including:
5
- * - OAuth provider credentials (from Key Vault)
6
- * - 2FA/MFA settings
7
- * - Session configuration
8
- * - Better Auth signing secret
9
- * - Branding
10
- *
11
- * CACHING STRATEGY:
12
- * 1. In-memory cache (fastest, but lost on module reload in dev)
13
- * 2. Redis cache (survives module reloads, shared across instances)
14
- * 3. IDP fetch (when both caches miss)
15
- *
16
- * NO FALLBACKS. If IDP doesn't respond correctly, we fail loud.
17
- *
18
- * @version 2.0.0 - Added Redis-backed caching
19
- */
20
-
21
- import 'server-only';
22
- import { randomUUID } from 'crypto';
23
- import redis from './redis';
24
-
25
- // ============================================================================
26
- // Types
27
- // ============================================================================
28
-
29
- export interface OAuthProviderConfig {
30
- provider: string;
31
- enabled: boolean;
32
- clientId: string;
33
- clientSecret: string;
34
- scopes?: string;
35
- additionalParams?: Record<string, any>;
36
- }
37
-
38
- export interface AuthSettings {
39
- require2FA: boolean;
40
- allowed2FAMethods: string[];
41
- mfaGracePeriodHours: number;
42
- mfaRememberDeviceDays: number;
43
- sessionTimeoutMinutes: number;
44
- idleTimeoutMinutes: number;
45
- allowRememberMe: boolean;
46
- rememberMeDays: number;
47
- lockoutThreshold: number;
48
- lockoutDurationMinutes: number;
49
- }
50
-
51
- export interface BrandingConfig {
52
- theme?: string;
53
- primaryColor?: string;
54
- secondaryColor?: string;
55
- logoUrl?: string;
56
- }
57
-
58
- export interface IDPClientConfig {
59
- clientId: string;
60
- clientSlug: string;
61
- /**
62
- * Cryptographic secret used by Better Auth to sign session JWTs.
63
- * Historically named "nextAuthSecret" — kept under the new name now.
64
- */
65
- authSecret: string;
66
- configCacheTtlSeconds: number;
67
- oauthProviders: OAuthProviderConfig[];
68
- authSettings: AuthSettings;
69
- branding: BrandingConfig;
70
- baseClientUrl?: string; // Public base URL - sets IDENTITY_CLIENT_BASE_EXTERNAL_URL
71
- }
72
-
73
- // ============================================================================
74
- // Cache & Fetch Deduplication
75
- // ============================================================================
76
-
77
- let cachedConfig: IDPClientConfig | null = null;
78
- let cacheExpiry: number = 0;
79
- let pendingFetch: Promise<IDPClientConfig> | null = null; // Prevents parallel fetches
80
-
81
- // ============================================================================
82
- // Redis Cache Configuration
83
- // ============================================================================
84
-
85
- const REDIS_CONFIG_KEY_PREFIX = 'idp_config:';
86
-
87
- function getRedisConfigKey(): string {
88
- const clientId = process.env.CLIENT_ID || process.env.NEXT_PUBLIC_CLIENT_ID || 'default';
89
- return `${REDIS_CONFIG_KEY_PREFIX}${clientId}`;
90
- }
91
-
92
- interface RedisCachedConfig {
93
- config: IDPClientConfig;
94
- expiresAt: number;
95
- }
96
-
97
- async function getConfigFromRedis(): Promise<IDPClientConfig | null> {
98
- try {
99
- const key = getRedisConfigKey();
100
- const cached = await redis.get(key);
101
- if (!cached) return null;
102
-
103
- const parsed: RedisCachedConfig = JSON.parse(cached);
104
- if (Date.now() >= parsed.expiresAt) {
105
- // Expired, delete it
106
- await redis.del(key);
107
- return null;
108
- }
109
-
110
- return parsed.config;
111
- } catch (error) {
112
- console.warn('[IDP_CONFIG] Failed to read from Redis cache:', error);
113
- return null;
114
- }
115
- }
116
-
117
- async function setConfigInRedis(config: IDPClientConfig): Promise<void> {
118
- try {
119
- const key = getRedisConfigKey();
120
- const ttlSeconds = config.configCacheTtlSeconds || 300;
121
- const data: RedisCachedConfig = {
122
- config,
123
- expiresAt: Date.now() + (ttlSeconds * 1000)
124
- };
125
- // Store with TTL slightly longer than the logical expiry to allow for clock skew
126
- await redis.set(key, JSON.stringify(data), 'EX', ttlSeconds + 10);
127
- } catch (error) {
128
- console.warn('[IDP_CONFIG] Failed to write to Redis cache:', error);
129
- }
130
- }
131
-
132
- // ============================================================================
133
- // Circuit Breaker & Backoff State
134
- // ============================================================================
135
-
136
- let consecutiveFailures = 0;
137
- let lastFailureTime = 0;
138
- const MAX_FAILURES = 3;
139
- const CIRCUIT_OPEN_MS = 300000; // 5 minutes
140
- const MAX_BACKOFF_MS = 30000; // 30 seconds max backoff
141
-
142
- // ============================================================================
143
- // Main Functions
144
- // ============================================================================
145
-
146
- /**
147
- * Get IDP client configuration with multi-tier caching.
148
- *
149
- * Caching layers (checked in order):
150
- * 1. In-memory cache (fastest, lost on module reload in dev)
151
- * 2. Redis cache (survives module reloads)
152
- * 3. IDP fetch (when both caches miss)
153
- *
154
- * THROWS if IDP is unavailable or misconfigured. No fallbacks.
155
- */
156
- export async function getIDPClientConfig(forceRefresh: boolean = false): Promise<IDPClientConfig> {
157
- const now = Date.now();
158
-
159
- // Layer 1: Return in-memory cached if still valid (skip if forceRefresh)
160
- if (!forceRefresh && cachedConfig && now < cacheExpiry) {
161
- return cachedConfig;
162
- }
163
-
164
- // If a fetch is already in progress, wait for it instead of starting another
165
- if (pendingFetch) {
166
- return pendingFetch;
167
- }
168
-
169
- // Layer 2: Check Redis cache (skip if forceRefresh - startup should always get fresh data)
170
- if (!forceRefresh) {
171
- const redisConfig = await getConfigFromRedis();
172
- if (redisConfig) {
173
- // Restore to in-memory cache
174
- cachedConfig = redisConfig;
175
- cacheExpiry = Date.now() + ((redisConfig.configCacheTtlSeconds || 300) * 1000);
176
-
177
- // Set BETTER_AUTH_SECRET from cached config (also set legacy
178
- // NEXTAUTH_SECRET during the rename transition).
179
- if (redisConfig.authSecret) {
180
- process.env.BETTER_AUTH_SECRET = redisConfig.authSecret;
181
- process.env.NEXTAUTH_SECRET = redisConfig.authSecret;
182
- }
183
-
184
- // Set IDENTITY_CLIENT_BASE_EXTERNAL_URL from cached config.
185
- // Only set if not already defined (allows deployment override for beta/staging).
186
- if (redisConfig.baseClientUrl && !process.env.IDENTITY_CLIENT_BASE_EXTERNAL_URL) {
187
- process.env.IDENTITY_CLIENT_BASE_EXTERNAL_URL = redisConfig.baseClientUrl;
188
- }
189
-
190
- return redisConfig;
191
- }
192
- }
193
-
194
- // Layer 3: Fetch from IDP
195
- const internalIdpUrl = process.env.INTERNAL_IDP_URL;
196
- const idpUrl = process.env.IDP_URL;
197
- const clientIdStr = process.env.CLIENT_ID || process.env.NEXT_PUBLIC_CLIENT_ID;
198
-
199
- if (!clientIdStr) {
200
- throw new Error('[IDP_CONFIG] FATAL: CLIENT_ID or NEXT_PUBLIC_CLIENT_ID must be set');
201
- }
202
- if (!internalIdpUrl && !idpUrl) {
203
- throw new Error('[IDP_CONFIG] FATAL: INTERNAL_IDP_URL or IDP_URL must be set');
204
- }
205
-
206
- // Start fetch and store promise so concurrent callers wait for same result
207
- const fetcher = internalIdpUrl
208
- ? fetchConfigFromInternalIDP(internalIdpUrl, clientIdStr)
209
- : fetchConfigFromIDP(idpUrl!, clientIdStr);
210
- pendingFetch = fetcher
211
- .then(async config => {
212
- // Cache with TTL from response (default 5 minutes)
213
- cachedConfig = config;
214
- cacheExpiry = Date.now() + ((config.configCacheTtlSeconds || 300) * 1000);
215
-
216
- // Store in Redis for persistence across module reloads
217
- await setConfigInRedis(config);
218
-
219
- // Set BETTER_AUTH_SECRET from config (also set legacy
220
- // NEXTAUTH_SECRET during the rename transition).
221
- if (config.authSecret) {
222
- process.env.BETTER_AUTH_SECRET = config.authSecret;
223
- process.env.NEXTAUTH_SECRET = config.authSecret;
224
- } else {
225
- throw new Error('[IDP_CONFIG] FATAL: IDP did not return authSecret');
226
- }
227
-
228
- // Set IDENTITY_CLIENT_BASE_EXTERNAL_URL from config.
229
- // Only set if not already defined (allows deployment override for beta/staging).
230
- if (config.baseClientUrl && !process.env.IDENTITY_CLIENT_BASE_EXTERNAL_URL) {
231
- process.env.IDENTITY_CLIENT_BASE_EXTERNAL_URL = config.baseClientUrl;
232
- console.log("[IDP_CONFIG] Set IDENTITY_CLIENT_BASE_EXTERNAL_URL:", config.baseClientUrl);
233
- }
234
-
235
- return config;
236
- })
237
- .finally(() => {
238
- pendingFetch = null; // Clear so next cache miss can fetch again
239
- });
240
-
241
- return pendingFetch;
242
- }
243
-
244
- /**
245
- * Clear the config cache (useful for testing or forced refresh)
246
- */
247
- export function clearConfigCache(): void {
248
- cachedConfig = null;
249
- cacheExpiry = 0;
250
- }
251
-
252
- /**
253
- * Clear the Redis config cache so the next fetch always goes to IDP.
254
- */
255
- export async function clearConfigRedisCache(): Promise<void> {
256
- try {
257
- const key = getRedisConfigKey();
258
- await redis.del(key);
259
- console.log('[IDP_CONFIG] Redis cache cleared:', key);
260
- } catch (error) {
261
- console.warn('[IDP_CONFIG] Failed to clear Redis cache:', error);
262
- }
263
- }
264
-
265
- /**
266
- * Get enabled OAuth providers from config
267
- */
268
- export function getEnabledProviders(config: IDPClientConfig): OAuthProviderConfig[] {
269
- return config.oauthProviders?.filter(p => p.enabled) || [];
270
- }
271
-
272
- // ============================================================================
273
- // Internal Functions
274
- // ============================================================================
275
-
276
- async function fetchConfigFromInternalIDP(internalIdpUrl: string, clientIdStr: string): Promise<IDPClientConfig> {
277
- const containersKey = process.env.CONTAINERS_KEY;
278
- if (!containersKey) {
279
- throw new Error('[IDP_CONFIG] FATAL: CONTAINERS_KEY is required when using INTERNAL_IDP_URL');
280
- }
281
-
282
- const url = `${internalIdpUrl.replace(/\/$/, '')}/InternalClientConfig/${encodeURIComponent(clientIdStr)}`;
283
- console.log(`[IDP_CONFIG] Fetching config from internal IDP: ${url}`);
284
-
285
- const resp = await fetch(url, {
286
- method: 'GET',
287
- headers: {
288
- 'Accept': 'application/json',
289
- 'Authorization': `Secret ${containersKey}`,
290
- },
291
- cache: 'no-store'
292
- } as RequestInit);
293
-
294
- if (!resp.ok) {
295
- const txt = await resp.text().catch(() => 'Unknown error');
296
- throw new Error(`[IDP_CONFIG] FATAL: Internal IDP returned ${resp.status} - ${txt}`);
297
- }
298
-
299
- const body: any = await resp.json().catch(() => null);
300
- if (!body) {
301
- throw new Error('[IDP_CONFIG] FATAL: Internal IDP returned empty or invalid JSON');
302
- }
303
-
304
- const configData = body?.data ?? body;
305
-
306
- const rawClientId = configData.clientId ?? configData.client_id;
307
- if (rawClientId === undefined || rawClientId === null) {
308
- throw new Error(`[IDP_CONFIG] FATAL: Internal IDP response missing clientId. Got: ${JSON.stringify(Object.keys(configData))}`);
309
- }
310
-
311
- const config: IDPClientConfig = {
312
- clientId: String(rawClientId),
313
- clientSlug: configData.clientSlug ?? configData.client_slug ?? configData.slug ?? '',
314
- // Wire compatibility: accept new authSecret first, fall back to legacy
315
- // nextAuthSecret/next_auth_secret while IDP rename rolls out.
316
- authSecret:
317
- configData.authSecret ??
318
- configData.auth_secret ??
319
- configData.nextAuthSecret ??
320
- configData.next_auth_secret ??
321
- '',
322
- configCacheTtlSeconds: configData.configCacheTtlSeconds ?? configData.config_cache_ttl_seconds ?? 300,
323
- oauthProviders: (configData.oauthProviders ?? configData.oauth_providers ?? []).map((p: any) => ({
324
- provider: p.provider ?? '',
325
- enabled: p.enabled ?? false,
326
- clientId: p.clientId ?? p.client_id ?? '',
327
- clientSecret: p.clientSecret ?? p.client_secret ?? '',
328
- scopes: p.scopes,
329
- additionalParams: p.additionalParams ?? p.additional_params
330
- })),
331
- authSettings: {
332
- require2FA: configData.authSettings?.require2FA ?? configData.auth_settings?.require_2fa ?? true,
333
- allowed2FAMethods: configData.authSettings?.allowed2FAMethods ?? configData.auth_settings?.allowed_2fa_methods ?? ['email', 'sms'],
334
- mfaGracePeriodHours: configData.authSettings?.mfaGracePeriodHours ?? configData.auth_settings?.mfa_grace_period_hours ?? 24,
335
- mfaRememberDeviceDays: configData.authSettings?.mfaRememberDeviceDays ?? configData.auth_settings?.mfa_remember_device_days ?? 30,
336
- sessionTimeoutMinutes: configData.authSettings?.sessionTimeoutMinutes ?? configData.auth_settings?.session_timeout_minutes ?? 60,
337
- idleTimeoutMinutes: configData.authSettings?.idleTimeoutMinutes ?? configData.auth_settings?.idle_timeout_minutes ?? 15,
338
- allowRememberMe: configData.authSettings?.allowRememberMe ?? configData.auth_settings?.allow_remember_me ?? true,
339
- rememberMeDays: configData.authSettings?.rememberMeDays ?? configData.auth_settings?.remember_me_days ?? 30,
340
- lockoutThreshold: configData.authSettings?.lockoutThreshold ?? configData.auth_settings?.lockout_threshold ?? 5,
341
- lockoutDurationMinutes: configData.authSettings?.lockoutDurationMinutes ?? configData.auth_settings?.lockout_duration_minutes ?? 15
342
- },
343
- branding: {
344
- theme: configData.branding?.theme,
345
- primaryColor: configData.branding?.primaryColor ?? configData.branding?.primary_color,
346
- secondaryColor: configData.branding?.secondaryColor ?? configData.branding?.secondary_color,
347
- logoUrl: configData.branding?.logoUrl ?? configData.branding?.logo_url
348
- },
349
- baseClientUrl: configData.baseClientUrl ?? configData.base_client_url ?? configData.BaseClientUrl
350
- };
351
-
352
- if (!config.authSecret) {
353
- throw new Error('[IDP_CONFIG] FATAL: Internal IDP did not return authSecret');
354
- }
355
-
356
- console.log(`[IDP_CONFIG] Internal IDP config loaded for ${clientIdStr}`);
357
- consecutiveFailures = 0;
358
- return config;
359
- }
360
-
361
- async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<IDPClientConfig> {
362
- // =========================================================================
363
- // Circuit Breaker Check
364
- // =========================================================================
365
- if (consecutiveFailures >= MAX_FAILURES) {
366
- const timeSinceFailure = Date.now() - lastFailureTime;
367
- if (timeSinceFailure < CIRCUIT_OPEN_MS) {
368
- // Circuit is open - return stale cache if available
369
- if (cachedConfig) {
370
- return cachedConfig;
371
- }
372
- throw new Error(`[IDP_CONFIG] Circuit breaker OPEN - no cached config available. Retry in ${Math.round((CIRCUIT_OPEN_MS - timeSinceFailure) / 1000)}s`);
373
- }
374
- // Half-open state: allow one request to test
375
- consecutiveFailures = MAX_FAILURES - 1;
376
- }
377
-
378
- // =========================================================================
379
- // Exponential Backoff Check
380
- // =========================================================================
381
- if (consecutiveFailures > 0) {
382
- const backoffMs = Math.min(1000 * Math.pow(2, consecutiveFailures), MAX_BACKOFF_MS);
383
- const timeSinceFailure = Date.now() - lastFailureTime;
384
- if (timeSinceFailure < backoffMs) {
385
- const remainingMs = backoffMs - timeSinceFailure;
386
- // Return stale cache during backoff if available
387
- if (cachedConfig) {
388
- return cachedConfig;
389
- }
390
- throw new Error(`[IDP_CONFIG] In backoff period - retry in ${Math.round(remainingMs)}ms`);
391
- }
392
- }
393
-
394
- try {
395
- // Step 1: Get signed client assertion from IDP
396
- const signingUrl = `${idpUrl.replace(/\/$/, '')}/api/ExternalAuth/sign-client-assertion`;
397
-
398
- const signingPayload = {
399
- issuer: clientIdStr,
400
- subject: clientIdStr,
401
- audience: 'urn:payez:externalauth:clientconfig',
402
- expires_in: 60,
403
- };
404
-
405
- const signingResp = await fetch(signingUrl, {
406
- method: 'POST',
407
- headers: {
408
- 'Accept': 'application/json',
409
- 'Content-Type': 'application/json',
410
- 'X-Client-Id': clientIdStr,
411
- 'X-Correlation-Id': randomUUID().replace(/-/g, ''),
412
- },
413
- body: JSON.stringify(signingPayload),
414
- cache: 'no-store'
415
- } as RequestInit);
416
-
417
- if (!signingResp.ok) {
418
- const txt = await signingResp.text().catch(() => 'Unknown error');
419
- throw new Error(`[IDP_CONFIG] FATAL: Failed to sign client assertion: ${signingResp.status} - ${txt}`);
420
- }
421
-
422
- const signingBody: any = await signingResp.json().catch(() => null);
423
-
424
- if (!signingBody) {
425
- throw new Error('[IDP_CONFIG] FATAL: IDP returned empty or invalid JSON for sign-client-assertion');
426
- }
427
-
428
- // Per PayEz API standard: response is { success, data: { client_assertion }, ... }
429
- // But IDP might use camelCase (clientAssertion) - check both
430
- const client_assertion = (
431
- signingBody?.data?.client_assertion ??
432
- signingBody?.data?.clientAssertion
433
- ) as string | undefined;
434
-
435
- if (!client_assertion) {
436
- console.error('[IDP_CONFIG] FATAL: Full response body:', JSON.stringify(signingBody, null, 2));
437
- throw new Error(`[IDP_CONFIG] FATAL: IDP response missing client_assertion. Got keys: ${JSON.stringify(Object.keys(signingBody?.data || signingBody || {}))}`);
438
- }
439
-
440
- // Step 2: Fetch client config using the assertion
441
- const configUrl = `${idpUrl.replace(/\/$/, '')}/api/ExternalAuth/client-config`;
442
-
443
- const configResp = await fetch(configUrl, {
444
- method: 'POST',
445
- headers: {
446
- 'Accept': 'application/json',
447
- 'Content-Type': 'application/json',
448
- 'X-Client-Id': clientIdStr,
449
- 'X-Correlation-Id': randomUUID().replace(/-/g, ''),
450
- },
451
- body: JSON.stringify({ client_assertion }),
452
- cache: 'no-store'
453
- } as RequestInit);
454
-
455
- if (!configResp.ok) {
456
- const txt = await configResp.text().catch(() => 'Unknown error');
457
- throw new Error(`[IDP_CONFIG] FATAL: Failed to fetch client config: ${configResp.status} - ${txt}`);
458
- }
459
-
460
- const configBody: any = await configResp.json().catch(() => null);
461
-
462
- if (!configBody) {
463
- throw new Error('[IDP_CONFIG] FATAL: IDP returned empty or invalid JSON for client-config');
464
- }
465
-
466
- // Per PayEz API standard: response is wrapped in { success, data: {...} }
467
- const configData = configBody?.data;
468
-
469
- if (!configData || typeof configData !== 'object') {
470
- console.error('[IDP_CONFIG] FATAL: Full config response body:', JSON.stringify(configBody, null, 2));
471
- throw new Error('[IDP_CONFIG] FATAL: IDP client-config response missing data envelope');
472
- }
473
-
474
- // Validate required fields - handle both number and string client_id
475
- const rawClientId = configData.clientId ?? configData.client_id;
476
- if (rawClientId === undefined || rawClientId === null) {
477
- throw new Error(`[IDP_CONFIG] FATAL: IDP response missing clientId/client_id. Got: ${JSON.stringify(Object.keys(configData))}`);
478
- }
479
-
480
- // Map response to our interface (IDP returns camelCase or snake_case).
481
- // Wire compatibility: accept new authSecret first, fall back to legacy
482
- // nextAuthSecret/next_auth_secret while IDP rename rolls out.
483
- const config: IDPClientConfig = {
484
- clientId: String(rawClientId),
485
- clientSlug: configData.clientSlug ?? configData.client_slug ?? configData.slug ?? '',
486
- authSecret:
487
- configData.authSecret ??
488
- configData.auth_secret ??
489
- configData.nextAuthSecret ??
490
- configData.next_auth_secret ??
491
- '',
492
- configCacheTtlSeconds: configData.configCacheTtlSeconds ?? configData.config_cache_ttl_seconds ?? 300,
493
- oauthProviders: (configData.oauthProviders ?? configData.oauth_providers ?? []).map((p: any) => ({
494
- provider: p.provider ?? '',
495
- enabled: p.enabled ?? false,
496
- clientId: p.clientId ?? p.client_id ?? '',
497
- clientSecret: p.clientSecret ?? p.client_secret ?? '',
498
- scopes: p.scopes,
499
- additionalParams: p.additionalParams ?? p.additional_params
500
- })),
501
- authSettings: {
502
- require2FA: (() => {
503
- // Check nested locations first (canonical)
504
- const nested = configData.authSettings?.require2FA ?? configData.auth_settings?.require_2fa;
505
- if (nested !== undefined) return nested;
506
- // TRANSITION FALLBACK: Check top-level (deprecated)
507
- const topLevel = configData.require2FA ?? configData.require_2fa;
508
- if (topLevel !== undefined) {
509
- console.warn('[IDP_CONFIG] DEPRECATION: require2FA found at top-level. Should be nested under auth_settings. Update IDP.');
510
- return topLevel;
511
- }
512
- return true; // Default to true for security
513
- })(),
514
- allowed2FAMethods: configData.authSettings?.allowed2FAMethods ?? configData.auth_settings?.allowed_2fa_methods ?? ['email', 'sms'],
515
- mfaGracePeriodHours: configData.authSettings?.mfaGracePeriodHours ?? configData.auth_settings?.mfa_grace_period_hours ?? 24,
516
- mfaRememberDeviceDays: configData.authSettings?.mfaRememberDeviceDays ?? configData.auth_settings?.mfa_remember_device_days ?? 30,
517
- sessionTimeoutMinutes: configData.authSettings?.sessionTimeoutMinutes ?? configData.auth_settings?.session_timeout_minutes ?? 60,
518
- idleTimeoutMinutes: configData.authSettings?.idleTimeoutMinutes ?? configData.auth_settings?.idle_timeout_minutes ?? 15,
519
- allowRememberMe: configData.authSettings?.allowRememberMe ?? configData.auth_settings?.allow_remember_me ?? true,
520
- rememberMeDays: configData.authSettings?.rememberMeDays ?? configData.auth_settings?.remember_me_days ?? 30,
521
- lockoutThreshold: configData.authSettings?.lockoutThreshold ?? configData.auth_settings?.lockout_threshold ?? 5,
522
- lockoutDurationMinutes: configData.authSettings?.lockoutDurationMinutes ?? configData.auth_settings?.lockout_duration_minutes ?? 15
523
- },
524
- branding: {
525
- theme: configData.branding?.theme,
526
- primaryColor: configData.branding?.primaryColor ?? configData.branding?.primary_color,
527
- secondaryColor: configData.branding?.secondaryColor ?? configData.branding?.secondary_color,
528
- logoUrl: configData.branding?.logoUrl ?? configData.branding?.logo_url
529
- },
530
- baseClientUrl: configData.baseClientUrl ?? configData.base_client_url ?? configData.BaseClientUrl
531
- };
532
-
533
- // Debug: log what we got for baseClientUrl
534
- console.log(`[IDP_CONFIG] Parsed baseClientUrl:`, config.baseClientUrl, `| raw keys:`, Object.keys(configData).filter(k => k.toLowerCase().includes('client')));
535
-
536
- // Validate we got what we need
537
- if (!config.clientId) {
538
- throw new Error('[IDP_CONFIG] FATAL: clientId is empty or missing after parsing');
539
- }
540
- if (!config.authSecret) {
541
- throw new Error('[IDP_CONFIG] FATAL: authSecret is empty after parsing');
542
- }
543
-
544
- // Success - reset failure tracking
545
- consecutiveFailures = 0;
546
- return config;
547
-
548
- } catch (error) {
549
- // Track failure for circuit breaker
550
- consecutiveFailures++;
551
- lastFailureTime = Date.now();
552
- console.error('[IDP_CONFIG] Fetch failed', {
553
- consecutiveFailures,
554
- maxFailures: MAX_FAILURES,
555
- error: error instanceof Error ? error.message : String(error)
556
- });
557
- throw error;
558
- }
559
- }
1
+ /**
2
+ * IDP Client Configuration
3
+ *
4
+ * Fetches full client configuration from IDP including:
5
+ * - OAuth provider credentials (from Key Vault)
6
+ * - 2FA/MFA settings
7
+ * - Session configuration
8
+ * - Better Auth signing secret
9
+ * - Branding
10
+ *
11
+ * CACHING STRATEGY:
12
+ * 1. In-memory cache (fastest, but lost on module reload in dev)
13
+ * 2. Redis cache (survives module reloads, shared across instances)
14
+ * 3. IDP fetch (when both caches miss)
15
+ *
16
+ * NO FALLBACKS. If IDP doesn't respond correctly, we fail loud.
17
+ *
18
+ * @version 2.0.0 - Added Redis-backed caching
19
+ */
20
+
21
+ import 'server-only';
22
+ import { randomUUID } from 'crypto';
23
+ import redis from './redis';
24
+
25
+ // ============================================================================
26
+ // Types
27
+ // ============================================================================
28
+
29
+ export interface OAuthProviderConfig {
30
+ provider: string;
31
+ enabled: boolean;
32
+ clientId: string;
33
+ clientSecret: string;
34
+ scopes?: string;
35
+ additionalParams?: Record<string, any>;
36
+ }
37
+
38
+ export interface AuthSettings {
39
+ require2FA: boolean;
40
+ allowed2FAMethods: string[];
41
+ mfaGracePeriodHours: number;
42
+ mfaRememberDeviceDays: number;
43
+ sessionTimeoutMinutes: number;
44
+ idleTimeoutMinutes: number;
45
+ allowRememberMe: boolean;
46
+ rememberMeDays: number;
47
+ lockoutThreshold: number;
48
+ lockoutDurationMinutes: number;
49
+ }
50
+
51
+ export interface BrandingConfig {
52
+ theme?: string;
53
+ primaryColor?: string;
54
+ secondaryColor?: string;
55
+ logoUrl?: string;
56
+ }
57
+
58
+ export interface IDPClientConfig {
59
+ clientId: string;
60
+ clientSlug: string;
61
+ /**
62
+ * Cryptographic secret used by Better Auth to sign session JWTs.
63
+ * Historically named "nextAuthSecret" — kept under the new name now.
64
+ */
65
+ authSecret: string;
66
+ configCacheTtlSeconds: number;
67
+ oauthProviders: OAuthProviderConfig[];
68
+ authSettings: AuthSettings;
69
+ branding: BrandingConfig;
70
+ baseClientUrl?: string; // Public base URL - sets IDENTITY_CLIENT_BASE_EXTERNAL_URL
71
+ }
72
+
73
+ // ============================================================================
74
+ // Cache & Fetch Deduplication
75
+ // ============================================================================
76
+
77
+ let cachedConfig: IDPClientConfig | null = null;
78
+ let cacheExpiry: number = 0;
79
+ let pendingFetch: Promise<IDPClientConfig> | null = null; // Prevents parallel fetches
80
+
81
+ // ============================================================================
82
+ // Redis Cache Configuration
83
+ // ============================================================================
84
+
85
+ const REDIS_CONFIG_KEY_PREFIX = 'idp_config:';
86
+
87
+ function getRedisConfigKey(): string {
88
+ const clientId = process.env.CLIENT_ID || process.env.NEXT_PUBLIC_CLIENT_ID || 'default';
89
+ return `${REDIS_CONFIG_KEY_PREFIX}${clientId}`;
90
+ }
91
+
92
+ interface RedisCachedConfig {
93
+ config: IDPClientConfig;
94
+ expiresAt: number;
95
+ }
96
+
97
+ async function getConfigFromRedis(): Promise<IDPClientConfig | null> {
98
+ try {
99
+ const key = getRedisConfigKey();
100
+ const cached = await redis.get(key);
101
+ if (!cached) return null;
102
+
103
+ const parsed: RedisCachedConfig = JSON.parse(cached);
104
+ if (Date.now() >= parsed.expiresAt) {
105
+ // Expired, delete it
106
+ await redis.del(key);
107
+ return null;
108
+ }
109
+
110
+ return parsed.config;
111
+ } catch (error) {
112
+ console.warn('[IDP_CONFIG] Failed to read from Redis cache:', error);
113
+ return null;
114
+ }
115
+ }
116
+
117
+ async function setConfigInRedis(config: IDPClientConfig): Promise<void> {
118
+ try {
119
+ const key = getRedisConfigKey();
120
+ const ttlSeconds = config.configCacheTtlSeconds || 300;
121
+ const data: RedisCachedConfig = {
122
+ config,
123
+ expiresAt: Date.now() + (ttlSeconds * 1000)
124
+ };
125
+ // Store with TTL slightly longer than the logical expiry to allow for clock skew
126
+ await redis.set(key, JSON.stringify(data), 'EX', ttlSeconds + 10);
127
+ } catch (error) {
128
+ console.warn('[IDP_CONFIG] Failed to write to Redis cache:', error);
129
+ }
130
+ }
131
+
132
+ // ============================================================================
133
+ // Circuit Breaker & Backoff State
134
+ // ============================================================================
135
+
136
+ let consecutiveFailures = 0;
137
+ let lastFailureTime = 0;
138
+ const MAX_FAILURES = 3;
139
+ const CIRCUIT_OPEN_MS = 300000; // 5 minutes
140
+ const MAX_BACKOFF_MS = 30000; // 30 seconds max backoff
141
+
142
+ // ============================================================================
143
+ // Main Functions
144
+ // ============================================================================
145
+
146
+ /**
147
+ * Get IDP client configuration with multi-tier caching.
148
+ *
149
+ * Caching layers (checked in order):
150
+ * 1. In-memory cache (fastest, lost on module reload in dev)
151
+ * 2. Redis cache (survives module reloads)
152
+ * 3. IDP fetch (when both caches miss)
153
+ *
154
+ * THROWS if IDP is unavailable or misconfigured. No fallbacks.
155
+ */
156
+ export async function getIDPClientConfig(forceRefresh: boolean = false): Promise<IDPClientConfig> {
157
+ const now = Date.now();
158
+
159
+ // Layer 1: Return in-memory cached if still valid (skip if forceRefresh)
160
+ if (!forceRefresh && cachedConfig && now < cacheExpiry) {
161
+ return cachedConfig;
162
+ }
163
+
164
+ // If a fetch is already in progress, wait for it instead of starting another
165
+ if (pendingFetch) {
166
+ return pendingFetch;
167
+ }
168
+
169
+ // Layer 2: Check Redis cache (skip if forceRefresh - startup should always get fresh data)
170
+ if (!forceRefresh) {
171
+ const redisConfig = await getConfigFromRedis();
172
+ if (redisConfig) {
173
+ // Restore to in-memory cache
174
+ cachedConfig = redisConfig;
175
+ cacheExpiry = Date.now() + ((redisConfig.configCacheTtlSeconds || 300) * 1000);
176
+
177
+ // Set BETTER_AUTH_SECRET from cached config (also set legacy
178
+ // NEXTAUTH_SECRET during the rename transition).
179
+ if (redisConfig.authSecret) {
180
+ process.env.BETTER_AUTH_SECRET = redisConfig.authSecret;
181
+ process.env.NEXTAUTH_SECRET = redisConfig.authSecret;
182
+ }
183
+
184
+ // Set IDENTITY_CLIENT_BASE_EXTERNAL_URL from cached config.
185
+ // Only set if not already defined (allows deployment override for beta/staging).
186
+ if (redisConfig.baseClientUrl && !process.env.IDENTITY_CLIENT_BASE_EXTERNAL_URL) {
187
+ process.env.IDENTITY_CLIENT_BASE_EXTERNAL_URL = redisConfig.baseClientUrl;
188
+ }
189
+
190
+ return redisConfig;
191
+ }
192
+ }
193
+
194
+ // Layer 3: Fetch from IDP
195
+ const internalIdpUrl = process.env.INTERNAL_IDP_URL;
196
+ const idpUrl = process.env.IDP_URL;
197
+ const clientIdStr = process.env.CLIENT_ID || process.env.NEXT_PUBLIC_CLIENT_ID;
198
+
199
+ if (!clientIdStr) {
200
+ throw new Error('[IDP_CONFIG] FATAL: CLIENT_ID or NEXT_PUBLIC_CLIENT_ID must be set');
201
+ }
202
+ if (!internalIdpUrl && !idpUrl) {
203
+ throw new Error('[IDP_CONFIG] FATAL: INTERNAL_IDP_URL or IDP_URL must be set');
204
+ }
205
+
206
+ // Start fetch and store promise so concurrent callers wait for same result
207
+ const fetcher = internalIdpUrl
208
+ ? fetchConfigFromInternalIDP(internalIdpUrl, clientIdStr)
209
+ : fetchConfigFromIDP(idpUrl!, clientIdStr);
210
+ pendingFetch = fetcher
211
+ .then(async config => {
212
+ // Cache with TTL from response (default 5 minutes)
213
+ cachedConfig = config;
214
+ cacheExpiry = Date.now() + ((config.configCacheTtlSeconds || 300) * 1000);
215
+
216
+ // Store in Redis for persistence across module reloads
217
+ await setConfigInRedis(config);
218
+
219
+ // Set BETTER_AUTH_SECRET from config (also set legacy
220
+ // NEXTAUTH_SECRET during the rename transition).
221
+ if (config.authSecret) {
222
+ process.env.BETTER_AUTH_SECRET = config.authSecret;
223
+ process.env.NEXTAUTH_SECRET = config.authSecret;
224
+ } else {
225
+ throw new Error('[IDP_CONFIG] FATAL: IDP did not return authSecret');
226
+ }
227
+
228
+ // Set IDENTITY_CLIENT_BASE_EXTERNAL_URL from config.
229
+ // Only set if not already defined (allows deployment override for beta/staging).
230
+ if (config.baseClientUrl && !process.env.IDENTITY_CLIENT_BASE_EXTERNAL_URL) {
231
+ process.env.IDENTITY_CLIENT_BASE_EXTERNAL_URL = config.baseClientUrl;
232
+ console.log("[IDP_CONFIG] Set IDENTITY_CLIENT_BASE_EXTERNAL_URL:", config.baseClientUrl);
233
+ }
234
+
235
+ return config;
236
+ })
237
+ .finally(() => {
238
+ pendingFetch = null; // Clear so next cache miss can fetch again
239
+ });
240
+
241
+ return pendingFetch;
242
+ }
243
+
244
+ /**
245
+ * Clear the config cache (useful for testing or forced refresh)
246
+ */
247
+ export function clearConfigCache(): void {
248
+ cachedConfig = null;
249
+ cacheExpiry = 0;
250
+ }
251
+
252
+ /**
253
+ * Clear the Redis config cache so the next fetch always goes to IDP.
254
+ */
255
+ export async function clearConfigRedisCache(): Promise<void> {
256
+ try {
257
+ const key = getRedisConfigKey();
258
+ await redis.del(key);
259
+ console.log('[IDP_CONFIG] Redis cache cleared:', key);
260
+ } catch (error) {
261
+ console.warn('[IDP_CONFIG] Failed to clear Redis cache:', error);
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Get enabled OAuth providers from config
267
+ */
268
+ export function getEnabledProviders(config: IDPClientConfig): OAuthProviderConfig[] {
269
+ return config.oauthProviders?.filter(p => p.enabled) || [];
270
+ }
271
+
272
+ // ============================================================================
273
+ // Internal Functions
274
+ // ============================================================================
275
+
276
+ async function fetchConfigFromInternalIDP(internalIdpUrl: string, clientIdStr: string): Promise<IDPClientConfig> {
277
+ const containersKey = process.env.CONTAINERS_KEY;
278
+ if (!containersKey) {
279
+ throw new Error('[IDP_CONFIG] FATAL: CONTAINERS_KEY is required when using INTERNAL_IDP_URL');
280
+ }
281
+
282
+ const url = `${internalIdpUrl.replace(/\/$/, '')}/InternalClientConfig/${encodeURIComponent(clientIdStr)}`;
283
+ console.log(`[IDP_CONFIG] Fetching config from internal IDP: ${url}`);
284
+
285
+ const resp = await fetch(url, {
286
+ method: 'GET',
287
+ headers: {
288
+ 'Accept': 'application/json',
289
+ 'Authorization': `Secret ${containersKey}`,
290
+ },
291
+ cache: 'no-store'
292
+ } as RequestInit);
293
+
294
+ if (!resp.ok) {
295
+ const txt = await resp.text().catch(() => 'Unknown error');
296
+ throw new Error(`[IDP_CONFIG] FATAL: Internal IDP returned ${resp.status} - ${txt}`);
297
+ }
298
+
299
+ const body: any = await resp.json().catch(() => null);
300
+ if (!body) {
301
+ throw new Error('[IDP_CONFIG] FATAL: Internal IDP returned empty or invalid JSON');
302
+ }
303
+
304
+ const configData = body?.data ?? body;
305
+
306
+ const rawClientId = configData.clientId ?? configData.client_id;
307
+ if (rawClientId === undefined || rawClientId === null) {
308
+ throw new Error(`[IDP_CONFIG] FATAL: Internal IDP response missing clientId. Got: ${JSON.stringify(Object.keys(configData))}`);
309
+ }
310
+
311
+ const config: IDPClientConfig = {
312
+ clientId: String(rawClientId),
313
+ clientSlug: configData.clientSlug ?? configData.client_slug ?? configData.slug ?? '',
314
+ // Wire compatibility: accept new authSecret first, fall back to legacy
315
+ // nextAuthSecret/next_auth_secret while IDP rename rolls out.
316
+ authSecret:
317
+ configData.authSecret ??
318
+ configData.auth_secret ??
319
+ configData.nextAuthSecret ??
320
+ configData.next_auth_secret ??
321
+ '',
322
+ configCacheTtlSeconds: configData.configCacheTtlSeconds ?? configData.config_cache_ttl_seconds ?? 300,
323
+ oauthProviders: (configData.oauthProviders ?? configData.oauth_providers ?? []).map((p: any) => ({
324
+ provider: p.provider ?? '',
325
+ enabled: p.enabled ?? false,
326
+ clientId: p.clientId ?? p.client_id ?? '',
327
+ clientSecret: p.clientSecret ?? p.client_secret ?? '',
328
+ scopes: p.scopes,
329
+ additionalParams: p.additionalParams ?? p.additional_params
330
+ })),
331
+ authSettings: {
332
+ require2FA: configData.authSettings?.require2FA ?? configData.auth_settings?.require_2fa ?? true,
333
+ allowed2FAMethods: configData.authSettings?.allowed2FAMethods ?? configData.auth_settings?.allowed_2fa_methods ?? ['email', 'sms'],
334
+ mfaGracePeriodHours: configData.authSettings?.mfaGracePeriodHours ?? configData.auth_settings?.mfa_grace_period_hours ?? 24,
335
+ mfaRememberDeviceDays: configData.authSettings?.mfaRememberDeviceDays ?? configData.auth_settings?.mfa_remember_device_days ?? 30,
336
+ sessionTimeoutMinutes: configData.authSettings?.sessionTimeoutMinutes ?? configData.auth_settings?.session_timeout_minutes ?? 60,
337
+ idleTimeoutMinutes: configData.authSettings?.idleTimeoutMinutes ?? configData.auth_settings?.idle_timeout_minutes ?? 15,
338
+ allowRememberMe: configData.authSettings?.allowRememberMe ?? configData.auth_settings?.allow_remember_me ?? true,
339
+ rememberMeDays: configData.authSettings?.rememberMeDays ?? configData.auth_settings?.remember_me_days ?? 30,
340
+ lockoutThreshold: configData.authSettings?.lockoutThreshold ?? configData.auth_settings?.lockout_threshold ?? 5,
341
+ lockoutDurationMinutes: configData.authSettings?.lockoutDurationMinutes ?? configData.auth_settings?.lockout_duration_minutes ?? 15
342
+ },
343
+ branding: {
344
+ theme: configData.branding?.theme,
345
+ primaryColor: configData.branding?.primaryColor ?? configData.branding?.primary_color,
346
+ secondaryColor: configData.branding?.secondaryColor ?? configData.branding?.secondary_color,
347
+ logoUrl: configData.branding?.logoUrl ?? configData.branding?.logo_url
348
+ },
349
+ baseClientUrl: configData.baseClientUrl ?? configData.base_client_url ?? configData.BaseClientUrl
350
+ };
351
+
352
+ if (!config.authSecret) {
353
+ throw new Error('[IDP_CONFIG] FATAL: Internal IDP did not return authSecret');
354
+ }
355
+
356
+ console.log(`[IDP_CONFIG] Internal IDP config loaded for ${clientIdStr}`);
357
+ consecutiveFailures = 0;
358
+ return config;
359
+ }
360
+
361
+ async function fetchConfigFromIDP(idpUrl: string, clientIdStr: string): Promise<IDPClientConfig> {
362
+ // =========================================================================
363
+ // Circuit Breaker Check
364
+ // =========================================================================
365
+ if (consecutiveFailures >= MAX_FAILURES) {
366
+ const timeSinceFailure = Date.now() - lastFailureTime;
367
+ if (timeSinceFailure < CIRCUIT_OPEN_MS) {
368
+ // Circuit is open - return stale cache if available
369
+ if (cachedConfig) {
370
+ return cachedConfig;
371
+ }
372
+ throw new Error(`[IDP_CONFIG] Circuit breaker OPEN - no cached config available. Retry in ${Math.round((CIRCUIT_OPEN_MS - timeSinceFailure) / 1000)}s`);
373
+ }
374
+ // Half-open state: allow one request to test
375
+ consecutiveFailures = MAX_FAILURES - 1;
376
+ }
377
+
378
+ // =========================================================================
379
+ // Exponential Backoff Check
380
+ // =========================================================================
381
+ if (consecutiveFailures > 0) {
382
+ const backoffMs = Math.min(1000 * Math.pow(2, consecutiveFailures), MAX_BACKOFF_MS);
383
+ const timeSinceFailure = Date.now() - lastFailureTime;
384
+ if (timeSinceFailure < backoffMs) {
385
+ const remainingMs = backoffMs - timeSinceFailure;
386
+ // Return stale cache during backoff if available
387
+ if (cachedConfig) {
388
+ return cachedConfig;
389
+ }
390
+ throw new Error(`[IDP_CONFIG] In backoff period - retry in ${Math.round(remainingMs)}ms`);
391
+ }
392
+ }
393
+
394
+ try {
395
+ // Step 1: Get signed client assertion from IDP
396
+ const signingUrl = `${idpUrl.replace(/\/$/, '')}/api/ExternalAuth/sign-client-assertion`;
397
+
398
+ const signingPayload = {
399
+ issuer: clientIdStr,
400
+ subject: clientIdStr,
401
+ audience: 'urn:payez:externalauth:clientconfig',
402
+ expires_in: 60,
403
+ };
404
+
405
+ const signingResp = await fetch(signingUrl, {
406
+ method: 'POST',
407
+ headers: {
408
+ 'Accept': 'application/json',
409
+ 'Content-Type': 'application/json',
410
+ 'X-Client-Id': clientIdStr,
411
+ 'X-Correlation-Id': randomUUID().replace(/-/g, ''),
412
+ },
413
+ body: JSON.stringify(signingPayload),
414
+ cache: 'no-store'
415
+ } as RequestInit);
416
+
417
+ if (!signingResp.ok) {
418
+ const txt = await signingResp.text().catch(() => 'Unknown error');
419
+ throw new Error(`[IDP_CONFIG] FATAL: Failed to sign client assertion: ${signingResp.status} - ${txt}`);
420
+ }
421
+
422
+ const signingBody: any = await signingResp.json().catch(() => null);
423
+
424
+ if (!signingBody) {
425
+ throw new Error('[IDP_CONFIG] FATAL: IDP returned empty or invalid JSON for sign-client-assertion');
426
+ }
427
+
428
+ // Per PayEz API standard: response is { success, data: { client_assertion }, ... }
429
+ // But IDP might use camelCase (clientAssertion) - check both
430
+ const client_assertion = (
431
+ signingBody?.data?.client_assertion ??
432
+ signingBody?.data?.clientAssertion
433
+ ) as string | undefined;
434
+
435
+ if (!client_assertion) {
436
+ console.error('[IDP_CONFIG] FATAL: Full response body:', JSON.stringify(signingBody, null, 2));
437
+ throw new Error(`[IDP_CONFIG] FATAL: IDP response missing client_assertion. Got keys: ${JSON.stringify(Object.keys(signingBody?.data || signingBody || {}))}`);
438
+ }
439
+
440
+ // Step 2: Fetch client config using the assertion
441
+ const configUrl = `${idpUrl.replace(/\/$/, '')}/api/ExternalAuth/client-config`;
442
+
443
+ const configResp = await fetch(configUrl, {
444
+ method: 'POST',
445
+ headers: {
446
+ 'Accept': 'application/json',
447
+ 'Content-Type': 'application/json',
448
+ 'X-Client-Id': clientIdStr,
449
+ 'X-Correlation-Id': randomUUID().replace(/-/g, ''),
450
+ },
451
+ body: JSON.stringify({ client_assertion }),
452
+ cache: 'no-store'
453
+ } as RequestInit);
454
+
455
+ if (!configResp.ok) {
456
+ const txt = await configResp.text().catch(() => 'Unknown error');
457
+ throw new Error(`[IDP_CONFIG] FATAL: Failed to fetch client config: ${configResp.status} - ${txt}`);
458
+ }
459
+
460
+ const configBody: any = await configResp.json().catch(() => null);
461
+
462
+ if (!configBody) {
463
+ throw new Error('[IDP_CONFIG] FATAL: IDP returned empty or invalid JSON for client-config');
464
+ }
465
+
466
+ // Per PayEz API standard: response is wrapped in { success, data: {...} }
467
+ const configData = configBody?.data;
468
+
469
+ if (!configData || typeof configData !== 'object') {
470
+ console.error('[IDP_CONFIG] FATAL: Full config response body:', JSON.stringify(configBody, null, 2));
471
+ throw new Error('[IDP_CONFIG] FATAL: IDP client-config response missing data envelope');
472
+ }
473
+
474
+ // Validate required fields - handle both number and string client_id
475
+ const rawClientId = configData.clientId ?? configData.client_id;
476
+ if (rawClientId === undefined || rawClientId === null) {
477
+ throw new Error(`[IDP_CONFIG] FATAL: IDP response missing clientId/client_id. Got: ${JSON.stringify(Object.keys(configData))}`);
478
+ }
479
+
480
+ // Map response to our interface (IDP returns camelCase or snake_case).
481
+ // Wire compatibility: accept new authSecret first, fall back to legacy
482
+ // nextAuthSecret/next_auth_secret while IDP rename rolls out.
483
+ const config: IDPClientConfig = {
484
+ clientId: String(rawClientId),
485
+ clientSlug: configData.clientSlug ?? configData.client_slug ?? configData.slug ?? '',
486
+ authSecret:
487
+ configData.authSecret ??
488
+ configData.auth_secret ??
489
+ configData.nextAuthSecret ??
490
+ configData.next_auth_secret ??
491
+ '',
492
+ configCacheTtlSeconds: configData.configCacheTtlSeconds ?? configData.config_cache_ttl_seconds ?? 300,
493
+ oauthProviders: (configData.oauthProviders ?? configData.oauth_providers ?? []).map((p: any) => ({
494
+ provider: p.provider ?? '',
495
+ enabled: p.enabled ?? false,
496
+ clientId: p.clientId ?? p.client_id ?? '',
497
+ clientSecret: p.clientSecret ?? p.client_secret ?? '',
498
+ scopes: p.scopes,
499
+ additionalParams: p.additionalParams ?? p.additional_params
500
+ })),
501
+ authSettings: {
502
+ require2FA: (() => {
503
+ // Check nested locations first (canonical)
504
+ const nested = configData.authSettings?.require2FA ?? configData.auth_settings?.require_2fa;
505
+ if (nested !== undefined) return nested;
506
+ // TRANSITION FALLBACK: Check top-level (deprecated)
507
+ const topLevel = configData.require2FA ?? configData.require_2fa;
508
+ if (topLevel !== undefined) {
509
+ console.warn('[IDP_CONFIG] DEPRECATION: require2FA found at top-level. Should be nested under auth_settings. Update IDP.');
510
+ return topLevel;
511
+ }
512
+ return true; // Default to true for security
513
+ })(),
514
+ allowed2FAMethods: configData.authSettings?.allowed2FAMethods ?? configData.auth_settings?.allowed_2fa_methods ?? ['email', 'sms'],
515
+ mfaGracePeriodHours: configData.authSettings?.mfaGracePeriodHours ?? configData.auth_settings?.mfa_grace_period_hours ?? 24,
516
+ mfaRememberDeviceDays: configData.authSettings?.mfaRememberDeviceDays ?? configData.auth_settings?.mfa_remember_device_days ?? 30,
517
+ sessionTimeoutMinutes: configData.authSettings?.sessionTimeoutMinutes ?? configData.auth_settings?.session_timeout_minutes ?? 60,
518
+ idleTimeoutMinutes: configData.authSettings?.idleTimeoutMinutes ?? configData.auth_settings?.idle_timeout_minutes ?? 15,
519
+ allowRememberMe: configData.authSettings?.allowRememberMe ?? configData.auth_settings?.allow_remember_me ?? true,
520
+ rememberMeDays: configData.authSettings?.rememberMeDays ?? configData.auth_settings?.remember_me_days ?? 30,
521
+ lockoutThreshold: configData.authSettings?.lockoutThreshold ?? configData.auth_settings?.lockout_threshold ?? 5,
522
+ lockoutDurationMinutes: configData.authSettings?.lockoutDurationMinutes ?? configData.auth_settings?.lockout_duration_minutes ?? 15
523
+ },
524
+ branding: {
525
+ theme: configData.branding?.theme,
526
+ primaryColor: configData.branding?.primaryColor ?? configData.branding?.primary_color,
527
+ secondaryColor: configData.branding?.secondaryColor ?? configData.branding?.secondary_color,
528
+ logoUrl: configData.branding?.logoUrl ?? configData.branding?.logo_url
529
+ },
530
+ baseClientUrl: configData.baseClientUrl ?? configData.base_client_url ?? configData.BaseClientUrl
531
+ };
532
+
533
+ // Debug: log what we got for baseClientUrl
534
+ console.log(`[IDP_CONFIG] Parsed baseClientUrl:`, config.baseClientUrl, `| raw keys:`, Object.keys(configData).filter(k => k.toLowerCase().includes('client')));
535
+
536
+ // Validate we got what we need
537
+ if (!config.clientId) {
538
+ throw new Error('[IDP_CONFIG] FATAL: clientId is empty or missing after parsing');
539
+ }
540
+ if (!config.authSecret) {
541
+ throw new Error('[IDP_CONFIG] FATAL: authSecret is empty after parsing');
542
+ }
543
+
544
+ // Success - reset failure tracking
545
+ consecutiveFailures = 0;
546
+ return config;
547
+
548
+ } catch (error) {
549
+ // Track failure for circuit breaker
550
+ consecutiveFailures++;
551
+ lastFailureTime = Date.now();
552
+ console.error('[IDP_CONFIG] Fetch failed', {
553
+ consecutiveFailures,
554
+ maxFailures: MAX_FAILURES,
555
+ error: error instanceof Error ? error.message : String(error)
556
+ });
557
+ throw error;
558
+ }
559
+ }