@payez/next-mvp 4.0.21 → 4.0.23

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