@digilogiclabs/platform-core 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/auth.d.mts CHANGED
@@ -285,4 +285,106 @@ declare function safeValidate<T>(schema: {
285
285
  }>;
286
286
  };
287
287
 
288
- export { RateLimitOptions, type RateLimitResult, RateLimitRule, RateLimitStore, type RedisRateLimitClient, type RedisRateLimitStoreOptions, addRateLimitHeaders, createRedisRateLimitStore, enforceRateLimit, errorResponse, extractBearerToken, getClientIp, isValidBearerToken, rateLimitResponse, safeValidate, verifyCronAuth, zodErrorResponse };
288
+ /**
289
+ * Federated Logout Handler for Keycloak + Auth.js
290
+ *
291
+ * Builds a Next.js route handler that:
292
+ * 1. Revokes the refresh token with Keycloak
293
+ * 2. Returns an HTML page that clears all auth cookies
294
+ * 3. Auto-redirects to Keycloak's OIDC logout endpoint
295
+ *
296
+ * Uses HTML 200 (not 307 redirect) because browsers may not reliably
297
+ * process Set-Cookie headers on redirect responses through CDN proxies.
298
+ *
299
+ * Edge-runtime compatible.
300
+ *
301
+ * @example
302
+ * ```typescript
303
+ * // app/api/auth/federated-logout/route.ts
304
+ * import { buildFederatedLogoutHandler } from '@digilogiclabs/platform-core/auth'
305
+ * import { auth } from '@/auth'
306
+ *
307
+ * export const dynamic = 'force-dynamic'
308
+ *
309
+ * export const GET = buildFederatedLogoutHandler({
310
+ * auth,
311
+ * domain: '.digilogiclabs.com',
312
+ * baseUrlFallback: 'https://digilogiclabs.com',
313
+ * })
314
+ * ```
315
+ */
316
+ interface FederatedLogoutConfig {
317
+ /**
318
+ * Auth.js `auth()` function — used to get the session's idToken.
319
+ * Must return an object with an optional `idToken` field.
320
+ */
321
+ auth: () => Promise<{
322
+ idToken?: string;
323
+ } | null>;
324
+ /**
325
+ * Production cookie domain (e.g. '.digilogiclabs.com').
326
+ * Used to clear cookies scoped to the root domain.
327
+ */
328
+ domain: string;
329
+ /**
330
+ * Fallback base URL when NEXTAUTH_URL/AUTH_URL are not set.
331
+ * e.g. 'https://digilogiclabs.com'
332
+ */
333
+ baseUrlFallback: string;
334
+ /**
335
+ * Additional cookie names to clear beyond the standard Auth.js set.
336
+ * e.g. ['admin-session']
337
+ */
338
+ extraCookies?: string[];
339
+ /**
340
+ * Optional error reporter. Called on missing issuer and token revocation failures.
341
+ * If not provided, errors are silently ignored.
342
+ */
343
+ onError?: (message: string, error?: unknown) => void;
344
+ }
345
+ /**
346
+ * Build a Next.js route handler for federated logout with Keycloak.
347
+ *
348
+ * The returned handler:
349
+ * - Validates the callbackUrl against an allowlist (open redirect protection)
350
+ * - Revokes the Keycloak refresh token
351
+ * - Clears all Auth.js cookies (both standard and __Secure- prefixed)
352
+ * - Returns an HTML page that auto-redirects to Keycloak's OIDC logout
353
+ */
354
+ declare function buildFederatedLogoutHandler(config: FederatedLogoutConfig): (request: Request) => Promise<Response>;
355
+
356
+ /**
357
+ * Lazy Rate Limit Store
358
+ *
359
+ * Creates a singleton rate limit store backed by Redis with automatic
360
+ * fallback to in-memory when Redis is unavailable.
361
+ *
362
+ * @example
363
+ * ```typescript
364
+ * // lib/rate-limit-store.ts
365
+ * import { createLazyRateLimitStore } from '@digilogiclabs/platform-core/auth'
366
+ * import { getRedisClient } from './redis'
367
+ *
368
+ * export const getRateLimitStore = createLazyRateLimitStore(getRedisClient)
369
+ * ```
370
+ */
371
+
372
+ interface LazyRateLimitStoreOptions {
373
+ /** Redis key prefix for rate limit keys (default: 'rl:') */
374
+ keyPrefix?: string;
375
+ }
376
+ /**
377
+ * Create a lazy singleton rate limit store factory.
378
+ *
379
+ * Returns a getter function that:
380
+ * - On first call, attempts to get a Redis client and create a Redis-backed store
381
+ * - If Redis is unavailable, returns undefined (enforceRateLimit falls back to in-memory)
382
+ * - Caches the result for subsequent calls
383
+ *
384
+ * @param getRedis - Function that returns a Redis client (or null/undefined if unavailable)
385
+ * @param options - Optional configuration
386
+ * @returns A getter function that returns the RateLimitStore or undefined
387
+ */
388
+ declare function createLazyRateLimitStore(getRedis: () => RedisRateLimitClient | null | undefined, options?: LazyRateLimitStoreOptions): () => RateLimitStore | undefined;
389
+
390
+ export { type FederatedLogoutConfig, type LazyRateLimitStoreOptions, RateLimitOptions, type RateLimitResult, RateLimitRule, RateLimitStore, type RedisRateLimitClient, type RedisRateLimitStoreOptions, addRateLimitHeaders, buildFederatedLogoutHandler, createLazyRateLimitStore, createRedisRateLimitStore, enforceRateLimit, errorResponse, extractBearerToken, getClientIp, isValidBearerToken, rateLimitResponse, safeValidate, verifyCronAuth, zodErrorResponse };
package/dist/auth.d.ts CHANGED
@@ -285,4 +285,106 @@ declare function safeValidate<T>(schema: {
285
285
  }>;
286
286
  };
287
287
 
288
- export { RateLimitOptions, type RateLimitResult, RateLimitRule, RateLimitStore, type RedisRateLimitClient, type RedisRateLimitStoreOptions, addRateLimitHeaders, createRedisRateLimitStore, enforceRateLimit, errorResponse, extractBearerToken, getClientIp, isValidBearerToken, rateLimitResponse, safeValidate, verifyCronAuth, zodErrorResponse };
288
+ /**
289
+ * Federated Logout Handler for Keycloak + Auth.js
290
+ *
291
+ * Builds a Next.js route handler that:
292
+ * 1. Revokes the refresh token with Keycloak
293
+ * 2. Returns an HTML page that clears all auth cookies
294
+ * 3. Auto-redirects to Keycloak's OIDC logout endpoint
295
+ *
296
+ * Uses HTML 200 (not 307 redirect) because browsers may not reliably
297
+ * process Set-Cookie headers on redirect responses through CDN proxies.
298
+ *
299
+ * Edge-runtime compatible.
300
+ *
301
+ * @example
302
+ * ```typescript
303
+ * // app/api/auth/federated-logout/route.ts
304
+ * import { buildFederatedLogoutHandler } from '@digilogiclabs/platform-core/auth'
305
+ * import { auth } from '@/auth'
306
+ *
307
+ * export const dynamic = 'force-dynamic'
308
+ *
309
+ * export const GET = buildFederatedLogoutHandler({
310
+ * auth,
311
+ * domain: '.digilogiclabs.com',
312
+ * baseUrlFallback: 'https://digilogiclabs.com',
313
+ * })
314
+ * ```
315
+ */
316
+ interface FederatedLogoutConfig {
317
+ /**
318
+ * Auth.js `auth()` function — used to get the session's idToken.
319
+ * Must return an object with an optional `idToken` field.
320
+ */
321
+ auth: () => Promise<{
322
+ idToken?: string;
323
+ } | null>;
324
+ /**
325
+ * Production cookie domain (e.g. '.digilogiclabs.com').
326
+ * Used to clear cookies scoped to the root domain.
327
+ */
328
+ domain: string;
329
+ /**
330
+ * Fallback base URL when NEXTAUTH_URL/AUTH_URL are not set.
331
+ * e.g. 'https://digilogiclabs.com'
332
+ */
333
+ baseUrlFallback: string;
334
+ /**
335
+ * Additional cookie names to clear beyond the standard Auth.js set.
336
+ * e.g. ['admin-session']
337
+ */
338
+ extraCookies?: string[];
339
+ /**
340
+ * Optional error reporter. Called on missing issuer and token revocation failures.
341
+ * If not provided, errors are silently ignored.
342
+ */
343
+ onError?: (message: string, error?: unknown) => void;
344
+ }
345
+ /**
346
+ * Build a Next.js route handler for federated logout with Keycloak.
347
+ *
348
+ * The returned handler:
349
+ * - Validates the callbackUrl against an allowlist (open redirect protection)
350
+ * - Revokes the Keycloak refresh token
351
+ * - Clears all Auth.js cookies (both standard and __Secure- prefixed)
352
+ * - Returns an HTML page that auto-redirects to Keycloak's OIDC logout
353
+ */
354
+ declare function buildFederatedLogoutHandler(config: FederatedLogoutConfig): (request: Request) => Promise<Response>;
355
+
356
+ /**
357
+ * Lazy Rate Limit Store
358
+ *
359
+ * Creates a singleton rate limit store backed by Redis with automatic
360
+ * fallback to in-memory when Redis is unavailable.
361
+ *
362
+ * @example
363
+ * ```typescript
364
+ * // lib/rate-limit-store.ts
365
+ * import { createLazyRateLimitStore } from '@digilogiclabs/platform-core/auth'
366
+ * import { getRedisClient } from './redis'
367
+ *
368
+ * export const getRateLimitStore = createLazyRateLimitStore(getRedisClient)
369
+ * ```
370
+ */
371
+
372
+ interface LazyRateLimitStoreOptions {
373
+ /** Redis key prefix for rate limit keys (default: 'rl:') */
374
+ keyPrefix?: string;
375
+ }
376
+ /**
377
+ * Create a lazy singleton rate limit store factory.
378
+ *
379
+ * Returns a getter function that:
380
+ * - On first call, attempts to get a Redis client and create a Redis-backed store
381
+ * - If Redis is unavailable, returns undefined (enforceRateLimit falls back to in-memory)
382
+ * - Caches the result for subsequent calls
383
+ *
384
+ * @param getRedis - Function that returns a Redis client (or null/undefined if unavailable)
385
+ * @param options - Optional configuration
386
+ * @returns A getter function that returns the RateLimitStore or undefined
387
+ */
388
+ declare function createLazyRateLimitStore(getRedis: () => RedisRateLimitClient | null | undefined, options?: LazyRateLimitStoreOptions): () => RateLimitStore | undefined;
389
+
390
+ export { type FederatedLogoutConfig, type LazyRateLimitStoreOptions, RateLimitOptions, type RateLimitResult, RateLimitRule, RateLimitStore, type RedisRateLimitClient, type RedisRateLimitStoreOptions, addRateLimitHeaders, buildFederatedLogoutHandler, createLazyRateLimitStore, createRedisRateLimitStore, enforceRateLimit, errorResponse, extractBearerToken, getClientIp, isValidBearerToken, rateLimitResponse, safeValidate, verifyCronAuth, zodErrorResponse };
package/dist/auth.js CHANGED
@@ -42,6 +42,7 @@ __export(auth_exports, {
42
42
  buildAllowlist: () => buildAllowlist,
43
43
  buildAuthCookies: () => buildAuthCookies,
44
44
  buildErrorBody: () => buildErrorBody,
45
+ buildFederatedLogoutHandler: () => buildFederatedLogoutHandler,
45
46
  buildKeycloakCallbacks: () => buildKeycloakCallbacks,
46
47
  buildPagination: () => buildPagination,
47
48
  buildRateLimitHeaders: () => buildRateLimitHeaders,
@@ -59,6 +60,7 @@ __export(auth_exports, {
59
60
  createAuditLogger: () => createAuditLogger,
60
61
  createBetaClient: () => createBetaClient,
61
62
  createFeatureFlags: () => createFeatureFlags,
63
+ createLazyRateLimitStore: () => createLazyRateLimitStore,
62
64
  createMemoryRateLimitStore: () => createMemoryRateLimitStore,
63
65
  createRedisRateLimitStore: () => createRedisRateLimitStore,
64
66
  createSafeTextSchema: () => createSafeTextSchema,
@@ -1223,10 +1225,10 @@ function isValidBearerToken(request, secret) {
1223
1225
  function verifyCronAuth(request, secret) {
1224
1226
  const authHeader = request.headers.get("authorization");
1225
1227
  if (!authHeader?.startsWith("Bearer ")) {
1226
- return new Response(
1227
- JSON.stringify({ error: "Missing authorization" }),
1228
- { status: 401, headers: { "Content-Type": "application/json" } }
1229
- );
1228
+ return new Response(JSON.stringify({ error: "Missing authorization" }), {
1229
+ status: 401,
1230
+ headers: { "Content-Type": "application/json" }
1231
+ });
1230
1232
  }
1231
1233
  const token = authHeader.slice(7);
1232
1234
  const expectedSecret = secret ?? process.env.CRON_SECRET;
@@ -1238,10 +1240,10 @@ function verifyCronAuth(request, secret) {
1238
1240
  );
1239
1241
  }
1240
1242
  if (!constantTimeEqual(token, expectedSecret)) {
1241
- return new Response(
1242
- JSON.stringify({ error: "Invalid authorization" }),
1243
- { status: 401, headers: { "Content-Type": "application/json" } }
1244
- );
1243
+ return new Response(JSON.stringify({ error: "Invalid authorization" }), {
1244
+ status: 401,
1245
+ headers: { "Content-Type": "application/json" }
1246
+ });
1245
1247
  }
1246
1248
  return null;
1247
1249
  }
@@ -1393,6 +1395,172 @@ function clearStoredBetaCode(config = {}) {
1393
1395
  }
1394
1396
  }
1395
1397
 
1398
+ // src/auth/federated-logout.ts
1399
+ function expireCookie(name, options) {
1400
+ const parts = [
1401
+ `${name}=`,
1402
+ "Path=/",
1403
+ "Expires=Thu, 01 Jan 1970 00:00:00 GMT",
1404
+ "Max-Age=0",
1405
+ "SameSite=Lax"
1406
+ ];
1407
+ if (options?.hostPrefix) {
1408
+ parts.push("Secure");
1409
+ } else {
1410
+ parts.push("HttpOnly");
1411
+ if (options?.domain) parts.push(`Domain=${options.domain}`);
1412
+ if (options?.secure) parts.push("Secure");
1413
+ }
1414
+ return parts.join("; ");
1415
+ }
1416
+ function isAllowedCallbackUrl(url, baseUrl) {
1417
+ if (url.startsWith("/") && !url.startsWith("//")) return true;
1418
+ try {
1419
+ const parsed = new URL(url);
1420
+ const base = new URL(baseUrl);
1421
+ const allowedHosts = [base.hostname, `www.${base.hostname}`];
1422
+ if (base.hostname.startsWith("www.")) {
1423
+ allowedHosts.push(base.hostname.slice(4));
1424
+ }
1425
+ return allowedHosts.includes(parsed.hostname);
1426
+ } catch {
1427
+ return false;
1428
+ }
1429
+ }
1430
+ var AUTH_COOKIE_NAMES = [
1431
+ "authjs.session-token",
1432
+ "__Secure-authjs.session-token",
1433
+ "authjs.callback-url",
1434
+ "__Secure-authjs.callback-url",
1435
+ "authjs.csrf-token",
1436
+ "__Secure-authjs.csrf-token",
1437
+ "authjs.pkce.code_verifier",
1438
+ "__Secure-authjs.pkce.code_verifier",
1439
+ "authjs.state",
1440
+ "__Secure-authjs.state",
1441
+ // Legacy next-auth names
1442
+ "next-auth.session-token",
1443
+ "__Secure-next-auth.session-token",
1444
+ "next-auth.callback-url",
1445
+ "__Secure-next-auth.callback-url",
1446
+ "next-auth.csrf-token",
1447
+ "__Secure-next-auth.csrf-token"
1448
+ ];
1449
+ var HOST_COOKIES = [
1450
+ "__Host-authjs.csrf-token",
1451
+ "__Host-next-auth.csrf-token"
1452
+ ];
1453
+ function buildFederatedLogoutHandler(config) {
1454
+ const { auth, domain, baseUrlFallback, extraCookies = [], onError } = config;
1455
+ return async function GET(request) {
1456
+ const session = await auth();
1457
+ const url = new URL(request.url);
1458
+ const rawCallbackUrl = url.searchParams.get("callbackUrl") || "/";
1459
+ const queryIdToken = url.searchParams.get("id_token_hint");
1460
+ const baseUrl = process.env.NEXTAUTH_URL || process.env.AUTH_URL || baseUrlFallback;
1461
+ const callbackUrl = isAllowedCallbackUrl(rawCallbackUrl, baseUrl) ? rawCallbackUrl : "/";
1462
+ const postLogoutRedirectUri = callbackUrl.startsWith("http") ? callbackUrl : `${baseUrl}${callbackUrl}`;
1463
+ const keycloakIssuer = process.env.AUTH_KEYCLOAK_ISSUER;
1464
+ if (!keycloakIssuer) {
1465
+ onError?.("Missing AUTH_KEYCLOAK_ISSUER");
1466
+ return Response.redirect(
1467
+ new URL(
1468
+ "/api/auth/signout?callbackUrl=" + encodeURIComponent(callbackUrl),
1469
+ request.url
1470
+ ).toString()
1471
+ );
1472
+ }
1473
+ const refreshToken = session?.refreshToken;
1474
+ if (refreshToken) {
1475
+ try {
1476
+ const revokeUrl = `${keycloakIssuer}/protocol/openid-connect/revoke`;
1477
+ const clientId2 = process.env.AUTH_KEYCLOAK_ID;
1478
+ const clientSecret = process.env.AUTH_KEYCLOAK_SECRET;
1479
+ await fetch(revokeUrl, {
1480
+ method: "POST",
1481
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1482
+ body: new URLSearchParams({
1483
+ token: refreshToken,
1484
+ token_type_hint: "refresh_token",
1485
+ ...clientId2 && { client_id: clientId2 },
1486
+ ...clientSecret && { client_secret: clientSecret }
1487
+ })
1488
+ });
1489
+ } catch (err) {
1490
+ onError?.("Token revocation failed", err);
1491
+ }
1492
+ }
1493
+ const keycloakLogoutUrl = new URL(
1494
+ `${keycloakIssuer}/protocol/openid-connect/logout`
1495
+ );
1496
+ keycloakLogoutUrl.searchParams.set(
1497
+ "post_logout_redirect_uri",
1498
+ postLogoutRedirectUri
1499
+ );
1500
+ const clientId = process.env.AUTH_KEYCLOAK_ID;
1501
+ if (clientId) keycloakLogoutUrl.searchParams.set("client_id", clientId);
1502
+ const idToken = session?.idToken || queryIdToken;
1503
+ if (idToken) keycloakLogoutUrl.searchParams.set("id_token_hint", idToken);
1504
+ const escapedUrl = keycloakLogoutUrl.toString().replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1505
+ const html = `<!DOCTYPE html>
1506
+ <html><head>
1507
+ <meta charset="utf-8">
1508
+ <meta http-equiv="refresh" content="0;url=${escapedUrl}">
1509
+ <title>Signing out...</title>
1510
+ <style>body{display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;font-family:system-ui,sans-serif;background:#0a0a0a;color:#fff}p{font-size:1.1rem;opacity:0.7}</style>
1511
+ </head><body>
1512
+ <p>Signing out&hellip;</p>
1513
+ <script>window.location.replace(${JSON.stringify(keycloakLogoutUrl.toString()).replace(/</g, "\\u003c")});</script>
1514
+ </body></html>`;
1515
+ const response = new Response(html, {
1516
+ status: 200,
1517
+ headers: {
1518
+ "Content-Type": "text/html; charset=utf-8",
1519
+ "Cache-Control": "no-store, no-cache, must-revalidate",
1520
+ Pragma: "no-cache"
1521
+ }
1522
+ });
1523
+ const isProduction = process.env.NODE_ENV === "production";
1524
+ const isSecure = isProduction;
1525
+ const allCookieNames = [...AUTH_COOKIE_NAMES, ...extraCookies];
1526
+ for (const name of allCookieNames) {
1527
+ const needsSecure = isSecure || name.startsWith("__Secure-");
1528
+ response.headers.append(
1529
+ "Set-Cookie",
1530
+ expireCookie(name, { secure: needsSecure })
1531
+ );
1532
+ if (isProduction) {
1533
+ response.headers.append(
1534
+ "Set-Cookie",
1535
+ expireCookie(name, { domain, secure: needsSecure })
1536
+ );
1537
+ }
1538
+ }
1539
+ for (const name of HOST_COOKIES) {
1540
+ response.headers.append(
1541
+ "Set-Cookie",
1542
+ expireCookie(name, { hostPrefix: true })
1543
+ );
1544
+ }
1545
+ return response;
1546
+ };
1547
+ }
1548
+
1549
+ // src/auth/lazy-rate-limit-store.ts
1550
+ function createLazyRateLimitStore(getRedis, options = {}) {
1551
+ const { keyPrefix = "rl:" } = options;
1552
+ let store;
1553
+ let initialized = false;
1554
+ return function getRateLimitStore() {
1555
+ if (initialized) return store;
1556
+ initialized = true;
1557
+ const redis = getRedis();
1558
+ if (!redis) return void 0;
1559
+ store = createRedisRateLimitStore(redis, { keyPrefix });
1560
+ return store;
1561
+ };
1562
+ }
1563
+
1396
1564
  // src/env.ts
1397
1565
  function getRequiredEnv(key) {
1398
1566
  const value = process.env[key];
@@ -1507,6 +1675,7 @@ function getEnvSummary(keys) {
1507
1675
  buildAllowlist,
1508
1676
  buildAuthCookies,
1509
1677
  buildErrorBody,
1678
+ buildFederatedLogoutHandler,
1510
1679
  buildKeycloakCallbacks,
1511
1680
  buildPagination,
1512
1681
  buildRateLimitHeaders,
@@ -1524,6 +1693,7 @@ function getEnvSummary(keys) {
1524
1693
  createAuditLogger,
1525
1694
  createBetaClient,
1526
1695
  createFeatureFlags,
1696
+ createLazyRateLimitStore,
1527
1697
  createMemoryRateLimitStore,
1528
1698
  createRedisRateLimitStore,
1529
1699
  createSafeTextSchema,