@rodit/rodit-auth-be 9.13.0 → 9.15.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/CHANGELOG.md CHANGED
@@ -2,6 +2,44 @@
2
2
 
3
3
  All notable changes to `@rodit/rodit-auth-be` are documented here.
4
4
 
5
+ ## [9.15.0] — 2026-08-11
6
+
7
+ ### Added
8
+
9
+ - **Outbound webhook SSRF controls** in `send_webhook` (CC-0001 residual):
10
+ before `fetch`, reject userinfo and private / loopback / link-local / ULA /
11
+ CGNAT / cloud-metadata hostnames and resolved A/AAAA addresses (same policy
12
+ as SLC `url-join-identity.js`); enforce JWT/peer `rodit_webhookcidr` when
13
+ present; resolve-once and pin the IP for the request via undici `lookup`.
14
+ `SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY` is unchanged for **public**
15
+ self-signed destinations.
16
+ - **`req.authenticatedRoditId` + login JSON `roditid`** after successful
17
+ `login_client` / `login_client_withnep413` signature verification (server-
18
+ derived peer token id only).
19
+ - **`enforceRateLimitFromClaims()`** middleware (and
20
+ `RoditClient#getClaimRateLimitMiddleware`) that consumes `req.rateLimit`
21
+ set by `validatepermissions`.
22
+
23
+ ### Security
24
+
25
+ - Compromised or DNS-rebound JWT `rodit_webhookurl` values can no longer reach
26
+ internal targets through the SDK outbound webhook path.
27
+
28
+ ## [9.14.1] — 2026-08-07
29
+
30
+ ### Changed
31
+
32
+ - Package version bumped to **9.14.1** (local `package.json` version restored).
33
+
34
+ ## [9.14.0] — 2026-07-16
35
+
36
+ ### Removed
37
+
38
+ - **`createExpressSessionMiddleware`** — unused cookie-session HMAC helper; JWT
39
+ auth uses EdDSA only.
40
+ - **`SECURITY_OPTIONS.SESSION_SECRET`** config key and env mapping
41
+ (`SECURITY_OPTIONS_SESSION_SECRET`). Not part of the RODiT/JWT auth path.
42
+
5
43
  ## [9.13.0] — 2026-07-15
6
44
 
7
45
  ### Added
package/README.md CHANGED
@@ -2,12 +2,14 @@
2
2
 
3
3
  A comprehensive Node.js SDK for implementing RODiT-based mutual authentication, authorization, self-configuration, and session management in Express.js applications.
4
4
 
5
- **Version:** 9.13.0
5
+ **Version:** 9.15.0
6
6
  **License:** Proprietary
7
7
  **Author:** Discernible IO
8
8
 
9
9
  **Login `POST` /api/login:** Use **`accountid`** (or **`roditid`**), **`timestamp`**, and **`base64url_signature`**. Sign UTF-8 bytes of `identifier + timestamp_iso`, and reject deprecated keys such as **`signature`** and **`account_id`**. See [CHANGELOG.md](./CHANGELOG.md).
10
10
 
11
+ **9.15.0:** Outbound `send_webhook` SSRF controls (private/metadata blocklist, `webhook_cidr` enforcement, DNS pin); login success exposes verified peer id as JSON `roditid` and `req.authenticatedRoditId`; optional `enforceRateLimitFromClaims()` for per-route claim quotas. See [CHANGELOG.md](./CHANGELOG.md).
12
+
11
13
  **9.13 federated login:** A client RODiT issued for API A can log into API B in the same SR/CR family via `login_server({ apiEndpoint })`. See [Federated login](#federated-login-same-family-different-api-url).
12
14
 
13
15
  ## Table of Contents
@@ -23,6 +25,7 @@ A comprehensive Node.js SDK for implementing RODiT-based mutual authentication,
23
25
  - [Session lifetime and TTL](#session-lifetime-and-ttl)
24
26
  - [Configuration](#configuration)
25
27
  - [Environment Variables](#environment-variables)
28
+ - [Dynamic Rate Limiting](#dynamic-rate-limiting)
26
29
  - [Session Storage Configuration](#session-storage-configuration)
27
30
  - [Configuration Priority](#configuration-priority)
28
31
  - [Logging & Monitoring](#logging--monitoring)
@@ -605,26 +608,33 @@ Permissions are configured in your RODiT token metadata using the `permissioned_
605
608
  ```
606
609
 
607
610
  **Permission Format:**
608
- - `"+0"` = All methods allowed (GET, POST, PUT, DELETE, etc.)
609
- - `"+1"` = GET only
610
- - `"+2"` = POST only
611
- - Custom combinations can be defined
611
+
612
+ Values are strings: optional scope prefix (`+` / `-`) plus a numeric rate:
613
+
614
+ | Value | Meaning |
615
+ |-------|---------|
616
+ | `"+0"` | Allowed (entity + properties scope); **unlimited** rate |
617
+ | `"+60"` | Allowed; **60 requests** per window (default window 60s) |
618
+ | `"-0"` | Allowed (properties-only scope); unlimited |
619
+ | (absent / no match) | Denied (`403`) |
620
+
621
+ `validatepermissions` / `authorize` checks allow/deny and sets `req.rateLimit` from the numeric part. That metadata is inert until you mount `enforceRateLimitFromClaims()` (see [Dynamic Rate Limiting](#dynamic-rate-limiting)).
612
622
 
613
623
  ### Permission Validation Middleware
614
624
 
615
625
  The `authorize` middleware validates that the authenticated user has permission to access the requested route:
616
626
 
617
627
  ```javascript
618
- const authenticate = (req, res, next) => {
619
- req.app.locals.roditClient.authenticate(req, res, next)
620
- }
621
- const authorize = (req, res, next) => {
622
- req.app.locals.roditClient.authorize(req, res, next)
623
- }
624
- // Apply both authentication and authorization
628
+ // After RoditClient.create('server') and app.locals.roditClient = roditClient
629
+ const authenticate = (req, res, next) => roditClient.authenticate(req, res, next)
630
+ const authorize = (req, res, next) => roditClient.authorize(req, res, next)
631
+ const enforceClaimLimits = roditClient.getClaimRateLimitMiddleware()
632
+
633
+ // Auth + permission allow/deny
625
634
  app.use('/api/admin', authenticate, authorize, adminRoutes)
626
- // CRUDA endpoints with full protection
627
- app.use('/api/cruda', authenticate, authorize, crudaRoutes)
635
+
636
+ // Auth + permissions + per-route claim quotas from permissioned_routes
637
+ app.use('/api/cruda', authenticate, authorize, enforceClaimLimits(), crudaRoutes)
628
638
  ```
629
639
 
630
640
  ### Permission Enforcement
@@ -1186,16 +1196,33 @@ const dbPath = config.get('API_DEFAULT_OPTIONS.DB_PATH')
1186
1196
 
1187
1197
  ### Dynamic Rate Limiting
1188
1198
 
1199
+ Two complementary limiters:
1200
+
1201
+ 1. **Global** (`getRateLimitMiddleware` / `ratelimitmw`) — from own RODiT `max_requests` / `maxrq_window` (or fixed numbers). Applies across routes.
1202
+ 2. **Per-route claim** (`getClaimRateLimitMiddleware` / `enforceRateLimitFromClaims`) — consumes `req.rateLimit` set by `validatepermissions` from each peer’s `permissioned_routes` value (e.g. `"+60"` → 60 req / 60s for that path). Returns `429 RATE_LIMIT_EXCEEDED` when exceeded. In-memory, per process.
1203
+
1189
1204
  ```javascript
1190
- // Configure rate limiting from RODiT token
1205
+ // 1) Global limiter from own RODiT metadata
1191
1206
  const configObject = await roditClient.getConfigOwnRodit()
1192
1207
  const metadata = configObject.own_rodit.metadata
1193
1208
  if (metadata.max_requests && metadata.maxrq_window) {
1194
- const maxRequests = parseInt(metadata.max_requests)
1195
- const windowSeconds = parseInt(metadata.maxrq_window)
1209
+ const maxRequests = parseInt(metadata.max_requests, 10)
1210
+ const windowSeconds = parseInt(metadata.maxrq_window, 10)
1196
1211
  const rateLimiter = roditClient.getRateLimitMiddleware()
1197
- app.use(rateLimiter(maxRequests, windowSeconds))
1198
- }
1212
+ // Note: factory args are (maxRequests, windowMinutes)
1213
+ app.use(rateLimiter(maxRequests, Math.ceil(windowSeconds / 60) || 1))
1214
+ }
1215
+
1216
+ // 2) Per-route claim enforcement (after authenticate + validatepermissions)
1217
+ const { authenticate_apicall, validatepermissions, enforceRateLimitFromClaims } =
1218
+ require('@rodit/rodit-auth-be')
1219
+ // Or: roditClient.getClaimRateLimitMiddleware()
1220
+ app.use(
1221
+ '/api',
1222
+ (req, res, next) => roditClient.authenticate(req, res, next),
1223
+ validatepermissions,
1224
+ enforceRateLimitFromClaims()
1225
+ )
1199
1226
  ```
1200
1227
 
1201
1228
  ### Environment Variables
@@ -1249,8 +1276,8 @@ export LOKI_TLS_SKIP_VERIFY=false # true to skip TLS verification
1249
1276
 
1250
1277
  #### Security Options
1251
1278
  ```bash
1252
- // Webhook configuration
1253
- export WEBHOOK_TLS_SKIP_VERIFY=false # true to skip TLS verification
1279
+ // Webhook TLS (public destinations only; private/metadata URLs are always blocked)
1280
+ export SECURITY_OPTIONS_WEBHOOK_TLS_SKIP_VERIFY=false # true for self-signed public webhook hosts
1254
1281
  // Login mode control (see Login Mode section below)
1255
1282
  export SECURITY_OPTIONS_LOGIN_MODE=partner # partner, promiscuous, or p2p
1256
1283
  // Security thresholds
@@ -1487,26 +1514,40 @@ performanceService.recordMetric('user_action', 1, {
1487
1514
  })
1488
1515
  ```
1489
1516
 
1490
- ## Webhooks
1491
-
1492
- ### Overview
1517
+ ## Webhooks
1518
+
1519
+ ### Overview
1493
1520
 
1494
- The SDK supports sending webhooks to multiple endpoints for important events. Webhook URLs are configured in the RODiT token metadata.
1521
+ The SDK supports sending webhooks to multiple endpoints for important events. Webhook URLs come from the **peer JWT** claim `rodit_webhookurl` (from that peer’s RODiT `webhook_url` metadata).
1495
1522
 
1496
1523
  **Key Features:**
1497
1524
  - **Custom Endpoints** - Send webhooks to any endpoint path (e.g., `/hooks/wake`, `/hooks/agent`, `/webhook`)
1498
- - **Non-blocking** - Webhooks sent asynchronously without blocking the main response
1499
- - **Error Resilient** - Webhook failures don't affect the main operation
1525
+ - **Ed25519 signed** - Payload + timestamp signed; receivers verify via `X-Signature` / signer identity headers
1526
+ - **Outbound SSRF controls (9.15+)** - Before `fetch`, reject userinfo and private / loopback / link-local / ULA / CGNAT / cloud-metadata hostnames and resolved A/AAAA; enforce peer `rodit_webhookcidr` when set; resolve-once and pin DNS for the request
1527
+ - **Self-signed TLS (public only)** - `SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY=true` still allowed for **public** destinations
1500
1528
 
1501
- Webhooks are configured in your RODiT token:
1529
+ Peer RODiT metadata:
1502
1530
 
1503
1531
  ```json
1504
1532
  {
1505
- "webhook_url": "https:
1506
- "webhook_cidr": "0.0.0.0/0"
1533
+ "webhook_url": "hooks.example.com",
1534
+ "webhook_cidr": "0.0.0.0/0"
1507
1535
  }
1508
1536
  ```
1509
1537
 
1538
+ - `webhook_url` — host (optional path); SDK POSTs `https://{webhook_url}{endpoint}`
1539
+ - `webhook_cidr` — allowlist for the **resolved** destination IP (`0.0.0.0/0` / empty = any public IP still subject to the SSRF blocklist)
1540
+
1541
+ Outbound rejection codes (returned as `{ isValid: false, error: { code, message, requestId } }`):
1542
+
1543
+ | Code | Meaning |
1544
+ |------|---------|
1545
+ | `WEBHOOK_URL_MISSING` | No `rodit_webhookurl` on `req.user` |
1546
+ | `WEBHOOK_URL_INVALID` | Bad URL / userinfo / scheme |
1547
+ | `WEBHOOK_URL_BLOCKED` | Private, loopback, or metadata target |
1548
+ | `WEBHOOK_URL_UNRESOLVABLE` | DNS lookup failed |
1549
+ | `WEBHOOK_CIDR_DENIED` | Resolved IP outside peer `webhook_cidr` |
1550
+
1510
1551
  ### Sending Webhooks to Default Endpoint
1511
1552
 
1512
1553
  Send webhooks to the default `/webhook` endpoint:
@@ -2079,16 +2120,24 @@ Handle Express login requests from clients. Validates RODiT credentials and issu
2079
2120
  app.post('/api/login', (req, res) => roditClient.login_client(req, res))
2080
2121
  ```
2081
2122
 
2082
- **Request Body:** `login_client` accepts `accountid`, `timestamp`, and `base64url_signature`.
2123
+ **Request Body:** `accountid` or `roditid` (exactly one non-empty), `timestamp` (and/or `timestamp_iso`), and `base64url_signature` (or `roditid_base64url_signature`).
2124
+
2125
+ **On success:**
2126
+ - Sets `req.authenticatedRoditId` to the **verified** peer token id (`peer_rodit.token_id`) — do not use `req.body.roditid` for app hooks
2127
+ - JSON body includes the same value as `roditid` (in addition to `jwt_token` / `requestId`)
2128
+ - Creates a server session keyed by that peer id; issues JWT (`New-Token` header + body)
2083
2129
 
2084
2130
  **Response:**
2085
2131
  ```javascript
2086
2132
  {
2087
2133
  jwt_token: '<jwt-token>',
2088
- requestId: '01HQXYZ...'
2134
+ requestId: '01HQXYZ...',
2135
+ roditid: '<verified-peer-token-id>'
2089
2136
  }
2090
2137
  ```
2091
2138
 
2139
+ `login_client_withnep413` returns the same success shape (`jwt_token`, `requestId`, `roditid`) and also sets `req.authenticatedRoditId`.
2140
+
2092
2141
  ##### logout_client(req, res)
2093
2142
 
2094
2143
  Handle Express logout requests. Closes session and invalidates JWT token.
@@ -2275,6 +2324,23 @@ app.use(limiter)
2275
2324
 
2276
2325
  **Returns:** Express middleware function
2277
2326
 
2327
+ ##### getClaimRateLimitMiddleware()
2328
+
2329
+ Get the middleware factory that enforces per-route limits from
2330
+ `req.rateLimit` (populated by `validatepermissions` / `authorize`).
2331
+
2332
+ Place **after** authentication and permission validation. Skips when
2333
+ `req.rateLimit` is missing or `unlimited: true`. Default window is
2334
+ `req.rateLimit.timeWindow` seconds (60). Optional `{ store }` Map-like
2335
+ override for multi-instance deployments.
2336
+
2337
+ ```javascript
2338
+ const enforceRateLimitFromClaims = roditClient.getClaimRateLimitMiddleware()
2339
+ app.use('/api', authenticate, authorize, enforceRateLimitFromClaims())
2340
+ ```
2341
+
2342
+ **Returns:** Middleware factory `(options?) => Express middleware`
2343
+
2278
2344
  ##### getPerformanceService()
2279
2345
 
2280
2346
  Get the performance tracking service.
@@ -2310,7 +2376,12 @@ const webhookHandler = roditClient.getWebhookHandler()
2310
2376
 
2311
2377
  ##### send_webhook(payload, req)
2312
2378
 
2313
- Send a webhook notification.
2379
+ Send a webhook notification to the peer URL in `req.user.rodit_webhookurl`.
2380
+
2381
+ Outbound delivery rejects private / loopback / metadata destinations (and
2382
+ resolved addresses), enforces peer JWT `rodit_webhookcidr` when set, and pins
2383
+ DNS for the request. `SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY` still applies
2384
+ to **public** self-signed endpoints only.
2314
2385
 
2315
2386
  ```javascript
2316
2387
  const result = await roditClient.send_webhook({
@@ -2318,16 +2389,23 @@ const result = await roditClient.send_webhook({
2318
2389
  data: { userId: '123', action: 'login' },
2319
2390
  isError: false
2320
2391
  }, req)
2392
+
2393
+ if (!result.isValid) {
2394
+ // e.g. WEBHOOK_URL_BLOCKED, WEBHOOK_CIDR_DENIED, WEBHOOK_SEND_ERROR
2395
+ logger.warn('Webhook not delivered', result.error)
2396
+ }
2321
2397
  ```
2322
2398
 
2399
+ Aliases: `sendWebhook(payload, req)`, `sendWebhookToEndpoint(payload, endpoint, req)`.
2400
+
2323
2401
  **Parameters:**
2324
2402
  - `payload` (Object): Webhook payload
2325
2403
  - `event` (string): Event name
2326
2404
  - `data` (Object): Event data
2327
2405
  - `isError` (boolean): Whether this is an error event
2328
- - `req` (Object): Express request object (optional)
2406
+ - `req` (Object): Express request with authenticated `req.user` (peer JWT claims)
2329
2407
 
2330
- **Returns:** `Promise<Object>` - `{ success: boolean, ... }`
2408
+ **Returns:** `Promise<Object>` — success `{ isValid: true, message, requestId, duration }` or `{ isValid: false, error: { code, message, requestId } }`
2331
2409
 
2332
2410
  ### Exported Components
2333
2411
 
@@ -2363,13 +2441,14 @@ const {
2363
2441
  versioningMiddleware, // API versioning
2364
2442
  loggingmw, // Logging middleware
2365
2443
  ratelimitmw, // Rate limiting middleware
2444
+ enforceRateLimitFromClaims, // Consumes req.rateLimit from validatepermissions
2366
2445
  versionManager, // Version manager
2367
2446
  VersionManager, // Version manager class
2368
2447
  nearorg_rpc_timestamp // Blockchain RPC timestamp function
2369
2448
  } = require('@rodit/rodit-auth-be')
2370
2449
  // Note: Session storage configuration functions are available via:
2371
2450
  // const { setExpressSessionStore, configureStorageFromConfig,
2372
- // createExpressSessionMiddleware, InMemorySessionStorage }
2451
+ // InMemorySessionStorage }
2373
2452
  // = require('@rodit/rodit-auth-be/lib/auth/sessionmanager');
2374
2453
  ```
2375
2454
 
@@ -2393,8 +2472,8 @@ When you call `roditClient.getConfigOwnRodit()`, you get access to these metadat
2393
2472
  | `serviceprovider_signature` | string | Cryptographic signature for verification |
2394
2473
  | `subjectuniqueidentifier_url` | string | Primary API service endpoint |
2395
2474
  | `userselected_dn` | string | User-selected display name |
2396
- | `webhook_cidr` | string | Allowed IP ranges for webhooks |
2397
- | `webhook_url` | string | Webhook endpoint URL |
2475
+ | `webhook_cidr` | string | Allowlist CIDR(s) for outbound webhook resolved IPs (`0.0.0.0/0` = any public IP; private ranges are still blocked by SSRF controls) |
2476
+ | `webhook_url` | string | Peer webhook host used as `rodit_webhookurl` in issued JWTs |
2398
2477
 
2399
2478
  ## Best Practices
2400
2479
 
package/index.js CHANGED
@@ -41,6 +41,7 @@ const { versioningMiddleware } = require('./lib/middleware/versioningmw');
41
41
  const { VersionManager } = require('./services/versionmanager');
42
42
  const loggingmw = require('./lib/middleware/loggingmw');
43
43
  const ratelimitmw = require('./lib/middleware/ratelimitmw');
44
+ const enforceRateLimitFromClaims = require('./lib/middleware/enforceratelimitmw');
44
45
  const utils = require('./services/utils');
45
46
  const config = require('./services/configsdk');
46
47
  const performanceService = require('./services/performanceservice');
@@ -404,6 +405,14 @@ class RoditClient {
404
405
  return ratelimitmw;
405
406
  }
406
407
 
408
+ /**
409
+ * Get middleware that enforces `req.rateLimit` from validatepermissions.
410
+ * @returns {Function} Middleware factory: enforceRateLimitFromClaims(options?)
411
+ */
412
+ getClaimRateLimitMiddleware() {
413
+ return enforceRateLimitFromClaims;
414
+ }
415
+
407
416
  /**
408
417
  * Get utilities
409
418
  * @returns {Object} Utilities object
@@ -1950,6 +1959,7 @@ module.exports = {
1950
1959
  versioningMiddleware,
1951
1960
  loggingmw,
1952
1961
  ratelimitmw,
1962
+ enforceRateLimitFromClaims,
1953
1963
  versionManager,
1954
1964
  VersionManager,
1955
1965
  // Blockchain service functions
@@ -338,29 +338,6 @@ function configureStorageFromConfig() {
338
338
  }
339
339
  }
340
340
 
341
- // Optional helper to create a real express-session middleware using the current store
342
- function createExpressSessionMiddleware(options = {}) {
343
- if (!sessionLib) {
344
- logger.warnWithContext('express-session not installed; returning no-op session middleware', {
345
- component: 'SessionManager',
346
- operation: 'session.middleware.create'
347
- });
348
- return (req, res, next) => next();
349
- }
350
- const secret = config.get('SECURITY_OPTIONS.SESSION_SECRET');
351
- const store = (currentStorage instanceof ExpressSessionStoreAdapter)
352
- ? currentStorage.store
353
- : new sessionLib.MemoryStore();
354
-
355
- return sessionLib({
356
- saveUninitialized: false,
357
- resave: false,
358
- ...options,
359
- secret,
360
- store,
361
- });
362
- }
363
-
364
341
  class SessionManager {
365
342
  constructor() {
366
343
  const instanceId = ulid();
@@ -1411,7 +1388,6 @@ module.exports = {
1411
1388
  setStorage,
1412
1389
  setExpressSessionStore,
1413
1390
  configureStorageFromConfig,
1414
- createExpressSessionMiddleware,
1415
1391
  SESSION_CLEANUP_INTERVAL,
1416
1392
  SESSION_TOKEN_RETENTION_PERIOD
1417
1393
  };
@@ -586,9 +586,14 @@ async function login_client(req, res) {
586
586
  // Set the jwt_token in the response header
587
587
  res.setHeader('New-Token', jwt_token);
588
588
 
589
+ // Server-derived peer identity (verified RODiT only — do not trust client body).
590
+ const authenticatedRoditId = peer_rodit.token_id;
591
+ req.authenticatedRoditId = authenticatedRoditId;
592
+
589
593
  return res.json({
590
594
  jwt_token,
591
- requestId
595
+ requestId,
596
+ roditid: authenticatedRoditId
592
597
  });
593
598
  } catch (error) {
594
599
  const duration = Date.now() - startTime;
@@ -1453,13 +1458,19 @@ async function login_client(req, res) {
1453
1458
  requestId,
1454
1459
  response: {
1455
1460
  requestId: requestId,
1456
- jwt_token_length: jwt_token ? jwt_token.length : 0
1461
+ jwt_token_length: jwt_token ? jwt_token.length : 0,
1462
+ roditid: peer_rodit.token_id
1457
1463
  }
1458
1464
  });
1459
1465
 
1466
+ // Server-derived peer identity (verified RODiT only — do not trust client body).
1467
+ const authenticatedRoditId = peer_rodit.token_id;
1468
+ req.authenticatedRoditId = authenticatedRoditId;
1469
+
1460
1470
  return res.json({
1461
1471
  jwt_token,
1462
1472
  requestId,
1473
+ roditid: authenticatedRoditId,
1463
1474
  });
1464
1475
  } catch (error) {
1465
1476
  const duration = Date.now() - startTime;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Enforce per-route rate limits from `req.rateLimit` (set by validatepermissions).
3
+ * Copyright (c) 2026 Discernible IO. All rights reserved.
4
+ */
5
+
6
+ const { ulid } = require("ulid");
7
+ const logger = require("../../services/logger");
8
+ const { createLogContext } = logger;
9
+ const { sendError } = require("../../services/error-response");
10
+
11
+ /**
12
+ * In-memory fixed-window counters keyed by subject + path + operation.
13
+ * Suitable for single-process apps; replace/store externally for multi-instance.
14
+ */
15
+ const windows = new Map();
16
+
17
+ function cleanupExpired(now) {
18
+ if (windows.size < 1000) return;
19
+ for (const [key, entry] of windows) {
20
+ if (entry.resetAt <= now) {
21
+ windows.delete(key);
22
+ }
23
+ }
24
+ }
25
+
26
+ function rateLimitSubject(req) {
27
+ if (req.authenticatedRoditId) return String(req.authenticatedRoditId);
28
+ if (req.user) {
29
+ if (req.user.sub) return String(req.user.sub);
30
+ if (req.user.rodit_id) return String(req.user.rodit_id);
31
+ }
32
+ return req.ip || "anonymous";
33
+ }
34
+
35
+ /**
36
+ * Middleware factory that consumes `req.rateLimit` metadata produced by
37
+ * `validatepermissions`. Place after permission validation on protected routes.
38
+ *
39
+ * @param {Object} [options]
40
+ * @param {Map} [options.store] Optional external store (Map-like get/set)
41
+ * @returns {Function} Express middleware
42
+ *
43
+ * @example
44
+ * app.use('/api', authenticate_apicall, validatepermissions, enforceRateLimitFromClaims());
45
+ */
46
+ function enforceRateLimitFromClaims(options = {}) {
47
+ const store = options.store || windows;
48
+
49
+ return function enforceRateLimitFromClaimsMiddleware(req, res, next) {
50
+ const requestId = req.requestId || ulid();
51
+ const rate = req.rateLimit;
52
+
53
+ if (!rate || rate.unlimited === true) {
54
+ return next();
55
+ }
56
+
57
+ const limit = Number(rate.value);
58
+ if (!Number.isFinite(limit) || limit <= 0) {
59
+ // Missing / unparsable claim → do not invent a limit
60
+ return next();
61
+ }
62
+
63
+ const timeWindowSeconds =
64
+ Number.isFinite(Number(rate.timeWindow)) && Number(rate.timeWindow) > 0
65
+ ? Number(rate.timeWindow)
66
+ : 60;
67
+ const now = Date.now();
68
+ const subject = rateLimitSubject(req);
69
+ const pathKey = rate.path || req.path || "";
70
+ const operation = rate.operation || "";
71
+ const key = `${subject}|${pathKey}|${operation}`;
72
+
73
+ cleanupExpired(now);
74
+
75
+ let entry = store.get(key);
76
+ if (!entry || entry.resetAt <= now) {
77
+ entry = {
78
+ count: 0,
79
+ resetAt: now + timeWindowSeconds * 1000,
80
+ limit,
81
+ };
82
+ }
83
+
84
+ entry.count += 1;
85
+ store.set(key, entry);
86
+
87
+ const remaining = Math.max(0, limit - entry.count);
88
+ const resetSeconds = Math.max(1, Math.ceil((entry.resetAt - now) / 1000));
89
+
90
+ res.setHeader("X-RateLimit-Limit", String(limit));
91
+ res.setHeader("X-RateLimit-Remaining", String(remaining));
92
+ res.setHeader("X-RateLimit-Reset", String(resetSeconds));
93
+
94
+ if (entry.count > limit) {
95
+ const baseContext = createLogContext("EnforceRateLimit", "claimExceeded", {
96
+ requestId,
97
+ subject,
98
+ path: pathKey,
99
+ operation,
100
+ limit,
101
+ count: entry.count,
102
+ timeWindowSeconds,
103
+ });
104
+ logger.warnWithContext("Per-route claim rate limit exceeded", {
105
+ ...baseContext,
106
+ result: "blocked",
107
+ });
108
+ logger.metric &&
109
+ logger.metric("rate_limit_operations", 0, {
110
+ operation: "claim_limit_exceeded",
111
+ path: pathKey,
112
+ result: "blocked",
113
+ });
114
+
115
+ return sendError(res, {
116
+ statusCode: 429,
117
+ requestId,
118
+ code: "RATE_LIMIT_EXCEEDED",
119
+ message: "Rate limit exceeded for this permissioned route",
120
+ details: {
121
+ limit,
122
+ timeWindowSeconds,
123
+ operation: operation || undefined,
124
+ path: pathKey || undefined,
125
+ },
126
+ });
127
+ }
128
+
129
+ return next();
130
+ };
131
+ }
132
+
133
+ module.exports = enforceRateLimitFromClaims;
134
+ module.exports.enforceRateLimitFromClaims = enforceRateLimitFromClaims;
135
+ module.exports._windows = windows;
@@ -416,7 +416,7 @@ async function validatepermissions(req, res, next) {
416
416
  req.commentsRate = result.commentsRate;
417
417
  }
418
418
 
419
- // Make rate limit information available for future implementation
419
+ // Make rate limit information available for enforceRateLimitFromClaims()
420
420
  req.rateLimit = {
421
421
  value: result.rateLimit,
422
422
  unlimited: result.unlimited === true,
@@ -8,6 +8,8 @@
8
8
 
9
9
  const crypto = require("crypto");
10
10
  const https = require("https");
11
+ const dns = require("dns").promises;
12
+ const net = require("net");
11
13
  const { Agent } = require("undici");
12
14
  const config = require('../../services/configsdk');
13
15
  const { isStrictEnvironment, getNodeEnv } = require('../../services/env');
@@ -20,6 +22,304 @@ const stateManager = require("../blockchain/statemanager");
20
22
  const { sessionManager } = require("../auth/sessionmanager");
21
23
  const { authenticate_webhook } = require("../auth/authentication");
22
24
 
25
+ // ---------------------------------------------------------------------------
26
+ // Outbound webhook URL SSRF / CIDR / DNS-pin helpers
27
+ // Policy aligned with SLC `src/game/url-join-identity.js`.
28
+ // ---------------------------------------------------------------------------
29
+
30
+ function webhookUrlError(code, message) {
31
+ const err = new Error(message);
32
+ err.code = code;
33
+ return err;
34
+ }
35
+
36
+ /**
37
+ * Reject loopback / RFC1918 / link-local / ULA / CGNAT / cloud-metadata destinations.
38
+ * Self-signed TLS for public webhook hosts remains allowed (WEBHOOK_TLS_SKIP_VERIFY).
39
+ */
40
+ function isBlockedIpAddress(ip) {
41
+ const version = net.isIP(ip);
42
+ if (version === 4) {
43
+ const parts = ip.split(".").map((p) => Number(p));
44
+ if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
45
+ return true;
46
+ }
47
+ const [a, b] = parts;
48
+ if (a === 0) return true; // 0.0.0.0/8
49
+ if (a === 10) return true; // 10.0.0.0/8
50
+ if (a === 127) return true; // 127.0.0.0/8
51
+ if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local / metadata
52
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
53
+ if (a === 192 && b === 168) return true; // 192.168.0.0/16
54
+ if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
55
+ if (a >= 224) return true; // multicast / reserved
56
+ return false;
57
+ }
58
+ if (version === 6) {
59
+ const normalized = ip.toLowerCase();
60
+ if (normalized === "::" || normalized === "::1") return true;
61
+ const mapped = normalized.match(/:ffff:(\d+\.\d+\.\d+\.\d+)$/i);
62
+ if (mapped) return isBlockedIpAddress(mapped[1]);
63
+ const first = normalized.split(":")[0] || "";
64
+ const firstVal = Number.parseInt(first, 16);
65
+ if (Number.isFinite(firstVal)) {
66
+ if ((firstVal & 0xffc0) === 0xfe80) return true; // fe80::/10
67
+ if ((firstVal & 0xfe00) === 0xfc00) return true; // fc00::/7
68
+ if ((firstVal & 0xff00) === 0xff00) return true; // ff00::/8
69
+ }
70
+ return false;
71
+ }
72
+ return true;
73
+ }
74
+
75
+ function isBlockedHostname(hostname) {
76
+ const host = String(hostname || "")
77
+ .toLowerCase()
78
+ .replace(/\.$/, "")
79
+ .replace(/^\[|\]$/g, "");
80
+ if (!host) return true;
81
+ if (host === "localhost" || host.endsWith(".localhost")) return true;
82
+ if (host === "metadata.google.internal" || host.endsWith(".metadata.google.internal")) {
83
+ return true;
84
+ }
85
+ if (host === "metadata" || host.endsWith(".internal")) return true;
86
+ if (net.isIP(host)) return isBlockedIpAddress(host);
87
+ return false;
88
+ }
89
+
90
+ /**
91
+ * Synchronous URL policy checks (scheme, length, userinfo, blocked hostname literals).
92
+ * @param {string} raw Absolute http(s) URL
93
+ * @returns {URL}
94
+ */
95
+ function validateOutboundWebhookUrl(raw) {
96
+ if (typeof raw !== "string" || !raw.trim()) {
97
+ throw webhookUrlError("WEBHOOK_URL_INVALID", "Webhook URL is required");
98
+ }
99
+ const trimmed = raw.trim();
100
+ if (trimmed.length > 2048) {
101
+ throw webhookUrlError("WEBHOOK_URL_INVALID", "Webhook URL is too long");
102
+ }
103
+ let parsed;
104
+ try {
105
+ parsed = new URL(trimmed);
106
+ } catch {
107
+ throw webhookUrlError("WEBHOOK_URL_INVALID", "Webhook URL must be an absolute URL");
108
+ }
109
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
110
+ throw webhookUrlError("WEBHOOK_URL_INVALID", "Webhook URL must use http or https");
111
+ }
112
+ if (parsed.username || parsed.password) {
113
+ throw webhookUrlError("WEBHOOK_URL_INVALID", "Webhook URL must not contain userinfo");
114
+ }
115
+ if (isBlockedHostname(parsed.hostname)) {
116
+ throw webhookUrlError(
117
+ "WEBHOOK_URL_BLOCKED",
118
+ "Webhook URL must not target a private, loopback, or metadata host"
119
+ );
120
+ }
121
+ return parsed;
122
+ }
123
+
124
+ function ipv4ToInt(ip) {
125
+ return ip.split(".").reduce((acc, oct) => ((acc << 8) + Number(oct)) >>> 0, 0) >>> 0;
126
+ }
127
+
128
+ function parseCidrList(cidrClaim) {
129
+ if (cidrClaim == null) return [];
130
+ const raw = String(cidrClaim).trim();
131
+ if (!raw) return [];
132
+ return raw
133
+ .split(/[\s,;]+/)
134
+ .map((part) => part.trim())
135
+ .filter(Boolean);
136
+ }
137
+
138
+ function isUnrestrictedCidr(cidr) {
139
+ const value = String(cidr || "").trim().toLowerCase();
140
+ return value === "0.0.0.0/0" || value === "::/0" || value === "*";
141
+ }
142
+
143
+ function isIpInCidr(ip, cidr) {
144
+ const trimmed = String(cidr || "").trim();
145
+ if (!trimmed || isUnrestrictedCidr(trimmed)) return true;
146
+
147
+ const slash = trimmed.indexOf("/");
148
+ if (slash < 0) return false;
149
+ const base = trimmed.slice(0, slash);
150
+ const prefix = Number(trimmed.slice(slash + 1));
151
+ const ipVer = net.isIP(ip);
152
+ const baseVer = net.isIP(base);
153
+ if (!ipVer || !baseVer || ipVer !== baseVer || !Number.isInteger(prefix)) {
154
+ return false;
155
+ }
156
+
157
+ if (ipVer === 4) {
158
+ if (prefix < 0 || prefix > 32) return false;
159
+ const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
160
+ return (ipv4ToInt(ip) & mask) === (ipv4ToInt(base) & mask);
161
+ }
162
+
163
+ // IPv6: compare prefix bits of normalized 128-bit forms
164
+ if (prefix < 0 || prefix > 128) return false;
165
+ const toBytes = (addr) => {
166
+ const buf = Buffer.alloc(16);
167
+ const full = expandIpv6(addr);
168
+ for (let i = 0; i < 8; i++) {
169
+ const hextet = Number.parseInt(full[i], 16);
170
+ buf.writeUInt16BE(hextet, i * 2);
171
+ }
172
+ return buf;
173
+ };
174
+
175
+ try {
176
+ const ipBuf = toBytes(ip);
177
+ const baseBuf = toBytes(base);
178
+ const fullBytes = Math.floor(prefix / 8);
179
+ const remBits = prefix % 8;
180
+ for (let i = 0; i < fullBytes; i++) {
181
+ if (ipBuf[i] !== baseBuf[i]) return false;
182
+ }
183
+ if (remBits === 0) return true;
184
+ const mask = 0xff << (8 - remBits);
185
+ return (ipBuf[fullBytes] & mask) === (baseBuf[fullBytes] & mask);
186
+ } catch {
187
+ return false;
188
+ }
189
+ }
190
+
191
+ function expandIpv6(addr) {
192
+ const lower = String(addr).toLowerCase().replace(/^\[|\]$/g, "");
193
+ const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
194
+ if (mapped) {
195
+ const parts = mapped[1].split(".").map(Number);
196
+ const hi = ((parts[0] << 8) | parts[1]).toString(16);
197
+ const lo = ((parts[2] << 8) | parts[3]).toString(16);
198
+ return ["0", "0", "0", "0", "0", "ffff", hi, lo];
199
+ }
200
+ const [head, tail] = lower.split("::");
201
+ const headParts = head ? head.split(":") : [];
202
+ const tailParts = tail ? tail.split(":") : [];
203
+ const missing = 8 - (headParts.length + tailParts.length);
204
+ if (missing < 0) {
205
+ throw new Error("invalid ipv6");
206
+ }
207
+ const middle = Array(missing).fill("0");
208
+ const parts = [...headParts, ...middle, ...tailParts].map((p) => p || "0");
209
+ if (parts.length !== 8) {
210
+ throw new Error("invalid ipv6");
211
+ }
212
+ return parts;
213
+ }
214
+
215
+ function assertIpAllowedByWebhookCidr(ip, cidrClaim) {
216
+ const cidrs = parseCidrList(cidrClaim);
217
+ if (cidrs.length === 0 || cidrs.every(isUnrestrictedCidr)) {
218
+ return;
219
+ }
220
+ const allowed = cidrs.some((cidr) => isIpInCidr(ip, cidr));
221
+ if (!allowed) {
222
+ throw webhookUrlError(
223
+ "WEBHOOK_CIDR_DENIED",
224
+ "Resolved webhook address is outside the peer webhook_cidr allowlist"
225
+ );
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Resolve hostname once, reject blocked addresses, enforce webhook_cidr, and
231
+ * return a single pinned address for the request lifetime (DNS rebinding defense).
232
+ *
233
+ * @param {string|URL} url
234
+ * @param {string} [webhookCidr] JWT/peer `rodit_webhookcidr` / `webhook_cidr`
235
+ * @returns {Promise<{ url: URL, hostname: string, address: string, family: number }>}
236
+ */
237
+ async function resolveAndPinWebhookDestination(url, webhookCidr) {
238
+ const parsed = url instanceof URL ? url : validateOutboundWebhookUrl(url);
239
+ const hostname = String(parsed.hostname || "").replace(/^\[|\]$/g, "");
240
+
241
+ if (net.isIP(hostname)) {
242
+ if (isBlockedIpAddress(hostname)) {
243
+ throw webhookUrlError(
244
+ "WEBHOOK_URL_BLOCKED",
245
+ "Webhook URL must not target a private, loopback, or metadata host"
246
+ );
247
+ }
248
+ assertIpAllowedByWebhookCidr(hostname, webhookCidr);
249
+ return {
250
+ url: parsed,
251
+ hostname,
252
+ address: hostname,
253
+ family: net.isIP(hostname),
254
+ };
255
+ }
256
+
257
+ let records;
258
+ try {
259
+ records = await dns.lookup(hostname, { all: true, verbatim: true });
260
+ } catch {
261
+ throw webhookUrlError(
262
+ "WEBHOOK_URL_UNRESOLVABLE",
263
+ "Webhook URL hostname could not be resolved"
264
+ );
265
+ }
266
+ if (!records.length) {
267
+ throw webhookUrlError(
268
+ "WEBHOOK_URL_UNRESOLVABLE",
269
+ "Webhook URL hostname could not be resolved"
270
+ );
271
+ }
272
+
273
+ const candidates = records.filter((record) => !isBlockedIpAddress(record.address));
274
+ if (!candidates.length) {
275
+ throw webhookUrlError(
276
+ "WEBHOOK_URL_BLOCKED",
277
+ "Webhook URL must not resolve to a private, loopback, or metadata address"
278
+ );
279
+ }
280
+
281
+ const cidrs = parseCidrList(webhookCidr);
282
+ const restrictCidr = cidrs.length > 0 && !cidrs.every(isUnrestrictedCidr);
283
+ const allowed = restrictCidr
284
+ ? candidates.filter((record) => cidrs.some((cidr) => isIpInCidr(record.address, cidr)))
285
+ : candidates;
286
+
287
+ if (!allowed.length) {
288
+ throw webhookUrlError(
289
+ "WEBHOOK_CIDR_DENIED",
290
+ "Resolved webhook address is outside the peer webhook_cidr allowlist"
291
+ );
292
+ }
293
+
294
+ // Prefer IPv4 when available for broader destination compatibility.
295
+ const pinned = allowed.find((r) => r.family === 4) || allowed[0];
296
+ return {
297
+ url: parsed,
298
+ hostname,
299
+ address: pinned.address,
300
+ family: pinned.family,
301
+ };
302
+ }
303
+
304
+ /**
305
+ * undici Agent that always returns the pinned address for DNS lookups
306
+ * (keeps Host / SNI as the original hostname).
307
+ */
308
+ function createPinnedDispatcher({ address, family, skipTlsVerify = false }) {
309
+ return new Agent({
310
+ connect: {
311
+ rejectUnauthorized: !skipTlsVerify,
312
+ lookup: (hostname, options, callback) => {
313
+ if (options && options.all) {
314
+ callback(null, [{ address, family }]);
315
+ return;
316
+ }
317
+ callback(null, address, family);
318
+ },
319
+ },
320
+ });
321
+ }
322
+
23
323
  // ---------------------------------------------------------------------------
24
324
  // Webhook identity + session helpers
25
325
  // ---------------------------------------------------------------------------
@@ -915,12 +1215,52 @@ async function resolveOutboundWebhookSessionId(options = {}, req = null) {
915
1215
  .replace(/\/+$/, "");
916
1216
  const normalizedEndpoint = `/${String(endpoint || "/webhook").replace(/^\/+/, "")}`;
917
1217
  const formattedWebhookUrl = `https://${cleanWebhookUrl}${normalizedEndpoint}`;
1218
+
1219
+ // Outbound SSRF controls: reject userinfo / private / metadata destinations,
1220
+ // enforce peer webhook_cidr, and pin DNS for the request lifetime.
1221
+ // WEBHOOK_TLS_SKIP_VERIFY remains allowed for public self-signed hosts.
1222
+ let pinnedDestination;
1223
+ try {
1224
+ validateOutboundWebhookUrl(formattedWebhookUrl);
1225
+ pinnedDestination = await resolveAndPinWebhookDestination(
1226
+ formattedWebhookUrl,
1227
+ req.user.rodit_webhookcidr
1228
+ );
1229
+ } catch (ssrfError) {
1230
+ const duration = Date.now() - startTime;
1231
+ const errorCode = ssrfError.code || "WEBHOOK_URL_BLOCKED";
1232
+ logger.warnWithContext("Outbound webhook URL rejected by SSRF controls", {
1233
+ ...baseContext,
1234
+ duration,
1235
+ formattedWebhookUrl,
1236
+ webhookCidr: req.user.rodit_webhookcidr || null,
1237
+ errorCode,
1238
+ error: ssrfError.message
1239
+ });
1240
+ logger.metric &&
1241
+ logger.metric("webhook_delivery_failures_total", 1, {
1242
+ component: "WebhookHandler",
1243
+ reason: errorCode,
1244
+ event,
1245
+ });
1246
+ return {
1247
+ isValid: false,
1248
+ error: {
1249
+ code: errorCode,
1250
+ message: ssrfError.message,
1251
+ requestId,
1252
+ },
1253
+ };
1254
+ }
918
1255
 
919
1256
  logger.debugWithContext("Webhook URL details", {
920
1257
  ...baseContext,
921
1258
  rawWebhookUrl: webhookUrl,
922
1259
  endpoint,
923
- formattedWebhookUrl
1260
+ formattedWebhookUrl,
1261
+ pinnedAddress: pinnedDestination.address,
1262
+ pinnedFamily: pinnedDestination.family,
1263
+ webhookCidr: req.user.rodit_webhookcidr || null
924
1264
  });
925
1265
 
926
1266
  const timestamp = Date.now();
@@ -1186,7 +1526,8 @@ async function resolveOutboundWebhookSessionId(options = {}, req = null) {
1186
1526
  // Configure HTTPS agent to skip TLS verification if configured
1187
1527
  // This is necessary when webhook destinations use self-signed certificates
1188
1528
  // Since mutual authentication via digital signatures is already in place,
1189
- // skipping TLS verification is safe in this context
1529
+ // skipping TLS verification is safe in this context (public destinations only;
1530
+ // private/metadata hosts are rejected above).
1190
1531
  const skipTlsVerify = config.has('SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY')
1191
1532
  ? String(config.get('SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY')).toLowerCase() === 'true'
1192
1533
  : false;
@@ -1196,27 +1537,37 @@ async function resolveOutboundWebhookSessionId(options = {}, req = null) {
1196
1537
  headers: headers,
1197
1538
  body: payload,
1198
1539
  };
1199
-
1540
+
1541
+ // Always pin DNS via undici lookup so a rebinding race cannot retarget
1542
+ // the TCP connection after the SSRF checks above.
1543
+ const undiciAgent = createPinnedDispatcher({
1544
+ address: pinnedDestination.address,
1545
+ family: pinnedDestination.family,
1546
+ skipTlsVerify,
1547
+ });
1548
+ fetchOptions.dispatcher = undiciAgent;
1549
+
1200
1550
  if (skipTlsVerify) {
1201
- // Create custom undici Agent that accepts self-signed certificates
1202
- // Node.js fetch uses undici under the hood and requires 'dispatcher' option
1203
- const undiciAgent = new Agent({
1204
- connect: {
1205
- rejectUnauthorized: false
1206
- }
1207
- });
1208
- fetchOptions.dispatcher = undiciAgent;
1209
-
1210
1551
  logger.debugWithContext("Webhook TLS verification disabled", {
1211
1552
  ...baseContext,
1212
1553
  skipTlsVerify: true,
1213
- reason: "SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY=true"
1554
+ reason: "SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY=true",
1555
+ pinnedAddress: pinnedDestination.address
1214
1556
  });
1215
1557
  }
1216
1558
 
1217
1559
  // Send webhook request
1218
1560
  const fetchStartTime = Date.now();
1219
- const response = await fetch(formattedWebhookUrl, fetchOptions);
1561
+ let response;
1562
+ try {
1563
+ response = await fetch(formattedWebhookUrl, fetchOptions);
1564
+ } finally {
1565
+ try {
1566
+ await undiciAgent.close();
1567
+ } catch (_closeError) {
1568
+ // ignore dispatcher close errors
1569
+ }
1570
+ }
1220
1571
  const fetchDuration = Date.now() - fetchStartTime;
1221
1572
 
1222
1573
  // Log fetch duration metrics
@@ -1748,6 +2099,11 @@ module.exports = {
1748
2099
  implicitAccountToBase64urlKey,
1749
2100
  base64urlKeyToImplicitAccount,
1750
2101
 
2102
+ // Outbound SSRF / CIDR / DNS-pin helpers
2103
+ validateOutboundWebhookUrl,
2104
+ resolveAndPinWebhookDestination,
2105
+ createPinnedDispatcher,
2106
+
1751
2107
  // Added exports from eventhandler.js
1752
2108
  WebhookEventHandler,
1753
2109
  TestConfigUpdateHandler,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rodit/rodit-auth-be",
3
- "version": "9.13.0",
3
+ "version": "9.15.0",
4
4
  "description": "RODiT-based authentication system for Express.js applications",
5
5
  "main": "index.js",
6
6
  "exports": {
@@ -244,10 +244,6 @@ const FALLBACK_DEFAULTS = {
244
244
  // - true: allow relaxed validation checks
245
245
  // - false: enforce strict validation checks
246
246
  RELAXED_SESSION_VALIDATION: true,
247
- // Session middleware secret used for signing session data.
248
- // Options:
249
- // - any non-empty string (recommended: long, random secret on main)
250
- SESSION_SECRET: "HMAC-session-secret-is-not-set",
251
247
  // Webhook outbound TLS verification.
252
248
  // Options:
253
249
  // - true: skip TLS certificate verification (for controlled/self-signed setups)
@@ -398,16 +394,6 @@ const VALIDATION_RULES = {
398
394
  type: 'boolean',
399
395
  validate: () => null
400
396
  },
401
- 'SECURITY_OPTIONS.SESSION_SECRET': {
402
- required: false,
403
- type: 'string',
404
- validate: (value) => {
405
- if (!value || value.length === 0) {
406
- return 'SECURITY_OPTIONS.SESSION_SECRET cannot be empty when provided';
407
- }
408
- return null;
409
- }
410
- },
411
397
  'SECURITY_OPTIONS.FALLBACK_JWT_DURATION': {
412
398
  required: false,
413
399
  type: 'number',