@rodit/rodit-auth-be 9.11.20 → 9.12.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,82 @@
2
2
 
3
3
  All notable changes to `@rodit/rodit-auth-be` are documented here.
4
4
 
5
+ ## [9.12.0] — 2026-07-02
6
+
7
+ ### Fixed
8
+
9
+ - **Multi-peer inbound webhook verification.** Inbound webhook signatures were
10
+ verified against `stateManager.getPeerBase64urlJwkPublicKey()`, a single
11
+ mutable "current peer" slot. When an agent was connected to several peers the
12
+ slot was clobbered by the most recent peer, so webhooks from other senders
13
+ (including the central API) failed with a false `401`
14
+ `WEBHOOK_SIGNATURE_INVALID` / `PEER_KEY_UNAVAILABLE`.
15
+
16
+ ### Added
17
+
18
+ - **Self-identifying webhooks.** The signer's Ed25519 public key now travels
19
+ with each webhook, so verification no longer depends on a shared "current
20
+ peer" slot and is correct when connected to many peers at once:
21
+ - `send_webhook` advertises the signer identity via `X-Rodit-Public-Key`,
22
+ `X-Rodit-Implicit-Account` (hex of the signing key), and `X-Rodit-Token-Id`.
23
+ - `webhookHandler.extractWebhookSignerKey(headers)` reads that key directly
24
+ (implicit account is authoritative on its own; a raw advertised key must
25
+ agree with it). Exported from the package root as `extractWebhookSignerKey`.
26
+ - **Signer ↔ session authorization gate.** A valid signature only proves the
27
+ sender holds the advertised private key; it does not prove that identity is a
28
+ peer we established a session with. `createWebhookAuthenticationMiddleware`
29
+ now enforces, after the signature verifies, that the (signed) `session_id`
30
+ maps to a live session whose `ownerId` equals the verified signer's implicit
31
+ account. New rejections: `401 WEBHOOK_SESSION_REQUIRED`,
32
+ `401 WEBHOOK_SESSION_INVALID`, `403 WEBHOOK_SIGNER_SESSION_MISMATCH`
33
+ (bypassed only under `NODE_ENV=test` or
34
+ `SECURITY_OPTIONS.BYPASS_WEBHOOK_VERIFICATION`). The validated binding is
35
+ exposed as `req.webhook_session`, `req.webhook_session_id`, and
36
+ `req.webhook_signer_implicit_account`.
37
+ - **Webhook ↔ session correlation.** `send_webhook` stamps the originating
38
+ session id into the **signed** payload (`session_id`) so receivers can link an
39
+ inbound webhook to the session opened at login:
40
+ - `extractWebhookSessionId({ headers, rawPayload, parsedBody })` returns the
41
+ session id (signed payload authoritative, `X-Rodit-Session-Id` header as
42
+ fallback). Exported from the package root and from `webhookHandler`.
43
+ - `createPublicKeyMiddleware` sets `req.webhook_session_id` for downstream
44
+ handlers.
45
+ - The session id is resolved without requiring a request context: it falls
46
+ back to **session storage** (the SessionManager singleton, in-memory by
47
+ default) via `findSessionsByRoditId(options.sessionRoditId)`, so a JWT issuer
48
+ (`login_client`) can stamp the shared session id for out-of-band webhooks.
49
+ - `RoditClient.sendWebhook` / `sendWebhookToEndpoint` / `sendWakeHook` /
50
+ `sendAgentHook` accept an `options` argument (`sessionId`, `sessionRoditId`)
51
+ and default `sessionId` to the client's own `this.sessionId` (set at
52
+ `login_server`), so a peer that initiated the login stamps its session
53
+ automatically.
54
+ - **Client-side session recording (symmetric session store).** The
55
+ `SessionManager` facility used by servers to track client sessions is now also
56
+ used by clients to record the sessions they open:
57
+ - `SessionManager.recordSession({ id, roditId, ... })` stores an
58
+ externally-issued session under its own id (idempotent), tagged
59
+ `origin: "client"`. `createSession` now tags issued sessions
60
+ `origin: "server"`.
61
+ - `SessionManager.hasSession(id)` quietly reports whether an id is a known,
62
+ live session (no warn logs, no lastAccessedAt bump); `forgetSession(id)`
63
+ removes one.
64
+ - `RoditClient.login_server` records each opened session keyed by the server's
65
+ roditId, so a client connected to many servers can cross-reference an inbound
66
+ webhook's `session_id` to a live session (multi-peer safe, unlike the single
67
+ `this.sessionId` slot). `clearSession` forgets it.
68
+ - `RoditClient.isKnownSession(id)` cross-references a webhook's session id
69
+ against the client's open sessions.
70
+ - Issuer-side webhook session lookup only considers `origin !== "client"`
71
+ sessions, so mutual peers don't cross-stamp.
72
+
73
+ ### Changed
74
+
75
+ - **`createPublicKeyMiddleware`** extracts the verification key from the webhook
76
+ itself (`extractWebhookSignerKey`) instead of the mutable
77
+ `stateManager.getPeerBase64urlJwkPublicKey()` slot, and attaches
78
+ `req.webhook_session_id`. Authentication (key from the webhook) and
79
+ authorization (binding to a session) are now cleanly separated.
80
+
5
81
  ## [8.0.0] — 2026-05-02
6
82
 
7
83
  ### Breaking
package/index.js CHANGED
@@ -61,6 +61,10 @@ function sessionFieldsFromJwt(token) {
61
61
  sessionId: payload.session_id,
62
62
  expiresAt: payload.session_exp,
63
63
  createdAt: payload.session_iat,
64
+ // Identity of the peer (server) that issued this JWT, so the client can
65
+ // record the session keyed by the server it opened it with.
66
+ peerRoditId: payload.rodit_id,
67
+ peerOwnerId: payload.rodit_owner,
64
68
  };
65
69
  } catch (_error) {
66
70
  return {};
@@ -281,32 +285,53 @@ class RoditClient {
281
285
  * @param {Object} [req] - Express request (for deriving peer webhook URL and headers)
282
286
  * @returns {Promise<Object>} Webhook result
283
287
  */
284
- async sendWebhookToEndpoint(data, endpoint, req) {
288
+ async sendWebhookToEndpoint(data, endpoint, req, options = {}) {
285
289
  if (webhookHandler.send_webhook) {
286
- return webhookHandler.send_webhook(data, req, { endpoint });
290
+ return webhookHandler.send_webhook(data, req, this._buildWebhookOptions(endpoint, options));
287
291
  }
288
292
  throw new Error('Webhook functionality not available');
289
293
  }
290
294
 
295
+ /**
296
+ * Build the options passed to the underlying send_webhook. Defaults the
297
+ * session id to this client's own session (set at login_server) so a peer that
298
+ * initiated the login stamps the shared session id automatically. Callers can
299
+ * override with options.sessionId or resolve from storage via
300
+ * options.sessionRoditId.
301
+ * @private
302
+ * @param {string} endpoint
303
+ * @param {Object} [options]
304
+ * @returns {Object}
305
+ */
306
+ _buildWebhookOptions(endpoint, options = {}) {
307
+ const merged = { ...options, endpoint };
308
+ if (merged.sessionId == null && this.sessionId) {
309
+ merged.sessionId = this.sessionId;
310
+ }
311
+ return merged;
312
+ }
313
+
291
314
  /**
292
315
  * Send webhook (backward compatibility alias)
293
316
  * @param {Object} data - Webhook payload object
294
317
  * @param {Object} [req] - Express request (for deriving peer webhook URL and headers)
318
+ * @param {Object} [options] - Webhook options (sessionId, sessionRoditId)
295
319
  * @returns {Promise<Object>} Webhook result
296
320
  */
297
- async send_webhook(data, req) {
298
- return this.sendWebhook(data, req);
321
+ async send_webhook(data, req, options = {}) {
322
+ return this.sendWebhook(data, req, options);
299
323
  }
300
324
 
301
325
  /**
302
326
  * Send webhook to default /webhook endpoint
303
327
  * @param {Object} data - Webhook payload object
304
328
  * @param {Object} [req] - Express request (optional)
329
+ * @param {Object} [options] - Webhook options (sessionId, sessionRoditId)
305
330
  * @returns {Promise<Object>} Webhook result
306
331
  */
307
- async sendWebhook(data, req) {
332
+ async sendWebhook(data, req, options = {}) {
308
333
  if (webhookHandler.send_webhook) {
309
- return webhookHandler.send_webhook(data, req, { endpoint: '/webhook' });
334
+ return webhookHandler.send_webhook(data, req, this._buildWebhookOptions('/webhook', options));
310
335
  }
311
336
  throw new Error('Webhook functionality not available');
312
337
  }
@@ -316,10 +341,11 @@ class RoditClient {
316
341
  * Trigger immediate heartbeat (enqueues system event for main session)
317
342
  * @param {Object} data - Webhook payload
318
343
  * @param {Object} [req] - Express request
344
+ * @param {Object} [options] - Webhook options (sessionId, sessionRoditId)
319
345
  * @returns {Promise<Object>} Webhook result
320
346
  */
321
- async sendWakeHook(data, req) {
322
- return this.sendWebhookToEndpoint(data, '/hooks/wake', req);
347
+ async sendWakeHook(data, req, options = {}) {
348
+ return this.sendWebhookToEndpoint(data, '/hooks/wake', req, options);
323
349
  }
324
350
 
325
351
  /**
@@ -327,10 +353,11 @@ class RoditClient {
327
353
  * Run isolated agent task with optional reply to messaging channels
328
354
  * @param {Object} data - Webhook payload
329
355
  * @param {Object} [req] - Express request
356
+ * @param {Object} [options] - Webhook options (sessionId, sessionRoditId)
330
357
  * @returns {Promise<Object>} Webhook result
331
358
  */
332
- async sendAgentHook(data, req) {
333
- return this.sendWebhookToEndpoint(data, '/hooks/agent', req);
359
+ async sendAgentHook(data, req, options = {}) {
360
+ return this.sendWebhookToEndpoint(data, '/hooks/agent', req, options);
334
361
  }
335
362
 
336
363
  /**
@@ -880,15 +907,32 @@ class RoditClient {
880
907
  sessionId: this.sessionData?.id
881
908
  });
882
909
 
910
+ // Forget the recorded session so it no longer cross-references webhooks.
911
+ if (this.sessionId) {
912
+ Promise.resolve(sessionManager.forgetSession(this.sessionId)).catch(() => {});
913
+ }
914
+
883
915
  // Clear session data
884
916
  this.sessionData = null;
885
917
  this.jwt_token = null;
918
+ this.sessionId = null;
886
919
 
887
920
  // Also clear JWT token from stateManager
888
921
  this.stateManager.setJwtToken(null);
889
922
 
890
923
  return true;
891
924
  }
925
+
926
+ /**
927
+ * Check whether a session id refers to a live session this client currently
928
+ * holds open. Use this to cross-reference the session_id carried by an inbound
929
+ * webhook against the sessions opened at login.
930
+ * @param {string} sessionId
931
+ * @returns {Promise<boolean>}
932
+ */
933
+ async isKnownSession(sessionId) {
934
+ return sessionManager.hasSession(sessionId);
935
+ }
892
936
 
893
937
 
894
938
  /**
@@ -1124,14 +1168,42 @@ class RoditClient {
1124
1168
  const fromJwt = sessionFieldsFromJwt(loginResult.jwt_token);
1125
1169
  const nowSec = Math.floor(Date.now() / 1000);
1126
1170
  this.sessionId = fromJwt.sessionId || ulid();
1171
+ const sessionCreatedAt = fromJwt.createdAt ?? nowSec;
1172
+ const sessionExpiresAt =
1173
+ fromJwt.expiresAt ?? nowSec + config.getDefaultJwtDurationSeconds();
1127
1174
  this.setSessionData({
1128
1175
  id: this.sessionId,
1129
- createdAt: fromJwt.createdAt ?? nowSec,
1130
- expiresAt:
1131
- fromJwt.expiresAt ??
1132
- nowSec + config.getDefaultJwtDurationSeconds(),
1176
+ createdAt: sessionCreatedAt,
1177
+ expiresAt: sessionExpiresAt,
1133
1178
  status: 'active'
1134
1179
  });
1180
+
1181
+ // Record the opened session in the shared SessionManager, keyed by the
1182
+ // server we logged into. This mirrors how a server stores sessions for
1183
+ // its client peers, so a client connected to many servers can
1184
+ // cross-reference an inbound webhook's session_id to a live session
1185
+ // (multi-peer safe, unlike the single this.sessionId slot).
1186
+ try {
1187
+ const serverRoditId =
1188
+ fromJwt.peerRoditId || config_own_rodit?.own_rodit?.token_id || null;
1189
+ if (serverRoditId) {
1190
+ await sessionManager.recordSession({
1191
+ id: this.sessionId,
1192
+ roditId: serverRoditId,
1193
+ ownerId: fromJwt.peerOwnerId,
1194
+ createdAt: sessionCreatedAt,
1195
+ expiresAt: sessionExpiresAt,
1196
+ status: 'active'
1197
+ });
1198
+ }
1199
+ } catch (recordErr) {
1200
+ logger.debug('Unable to record client session for webhook correlation', {
1201
+ component: 'RoditClient',
1202
+ method: 'login_server',
1203
+ requestId,
1204
+ error: recordErr.message
1205
+ });
1206
+ }
1135
1207
  }
1136
1208
 
1137
1209
  const duration = Date.now() - startTime;
@@ -1859,6 +1931,10 @@ module.exports = {
1859
1931
  generate_jwt_token,
1860
1932
  validatepermissions,
1861
1933
  webhookHandler,
1934
+ // Webhook identity + session helpers (key travels with the webhook; trust is
1935
+ // established by binding the verified signer to a session opened at login)
1936
+ extractWebhookSignerKey: webhookHandler.extractWebhookSignerKey,
1937
+ extractWebhookSessionId: webhookHandler.extractWebhookSessionId,
1862
1938
  versioningMiddleware,
1863
1939
  loggingmw,
1864
1940
  ratelimitmw,
@@ -532,7 +532,10 @@ class SessionManager {
532
532
  const sessionId = this._generateSessionId(sessionData.roditId);
533
533
  const now = Math.floor(Date.now() / 1000);
534
534
 
535
- // Create the session object
535
+ // Create the session object.
536
+ // origin distinguishes sessions this peer *issued* as a server ('server')
537
+ // from sessions a client peer *opened* and recorded ('client', see
538
+ // recordSession). Both live in the same store.
536
539
  const session = {
537
540
  id: sessionId,
538
541
  roditId: sessionData.roditId,
@@ -541,6 +544,7 @@ class SessionManager {
541
544
  expiresAt: sessionData.expiresAt,
542
545
  lastAccessedAt: now,
543
546
  status: 'active',
547
+ origin: sessionData.origin || 'server',
544
548
  metadata: sessionData.metadata || {},
545
549
  };
546
550
 
@@ -588,6 +592,125 @@ class SessionManager {
588
592
  }
589
593
 
590
594
 
595
+ /**
596
+ * Record an externally-issued session under its own id.
597
+ *
598
+ * Unlike createSession (which mints a new id when this peer, acting as a
599
+ * server, issues a JWT), recordSession stores a session using the id supplied
600
+ * by the issuer. It lets a *client* peer track the sessions it holds open with
601
+ * each server — the session_id embedded in every JWT it receives — using the
602
+ * same storage facility the server uses for its client peers. This makes
603
+ * multi-peer session tracking robust and lets an inbound webhook's session_id
604
+ * be cross-referenced to a live session (see hasSession).
605
+ *
606
+ * Idempotent upsert: recording an existing id refreshes it.
607
+ *
608
+ * @param {Object} sessionData
609
+ * @param {string} sessionData.id Issuer-provided session id
610
+ * @param {string} sessionData.roditId Peer (server) roditId this session is with
611
+ * @param {string} [sessionData.ownerId]
612
+ * @param {number} [sessionData.createdAt]
613
+ * @param {number} [sessionData.expiresAt]
614
+ * @param {string} [sessionData.status]
615
+ * @returns {Promise<Object|null>} The stored session, or null on error
616
+ */
617
+ async recordSession(sessionData) {
618
+ const requestId = ulid();
619
+ const baseContext = createLogContext("SessionManager", "recordSession", {
620
+ requestId,
621
+ sessionId: sessionData?.id
622
+ });
623
+
624
+ try {
625
+ if (!sessionData || !sessionData.id) {
626
+ throw new Error("recordSession requires sessionData.id");
627
+ }
628
+ if (!sessionData.roditId) {
629
+ throw new Error("recordSession requires sessionData.roditId");
630
+ }
631
+
632
+ const now = Math.floor(Date.now() / 1000);
633
+ const session = {
634
+ id: sessionData.id,
635
+ roditId: sessionData.roditId,
636
+ ownerId: sessionData.ownerId,
637
+ createdAt: sessionData.createdAt || now,
638
+ expiresAt: sessionData.expiresAt,
639
+ lastAccessedAt: now,
640
+ status: sessionData.status || 'active',
641
+ origin: 'client',
642
+ metadata: sessionData.metadata || {},
643
+ };
644
+
645
+ await this.storage.set(session.id, session);
646
+
647
+ logger.debugWithContext("Recorded client-side session", {
648
+ ...baseContext,
649
+ roditId: session.roditId,
650
+ expiresAt: session.expiresAt,
651
+ origin: session.origin
652
+ });
653
+
654
+ return session;
655
+ } catch (error) {
656
+ logger.warnWithContext("Failed to record client-side session", {
657
+ ...baseContext,
658
+ error: error.message
659
+ });
660
+ return null;
661
+ }
662
+ }
663
+
664
+ /**
665
+ * Quietly test whether a session id refers to a known, live (active,
666
+ * non-expired) session. Unlike getSession this does not log warnings or bump
667
+ * lastAccessedAt, so it is safe to call on every inbound webhook to
668
+ * cross-reference the carried session_id.
669
+ *
670
+ * @param {string} sessionId
671
+ * @returns {Promise<boolean>}
672
+ */
673
+ async hasSession(sessionId) {
674
+ if (!sessionId) return false;
675
+ try {
676
+ const session = await this.storage.get(sessionId);
677
+ if (!session) return false;
678
+ const now = Math.floor(Date.now() / 1000);
679
+ if (session.expiresAt && session.expiresAt <= now) return false;
680
+ return session.status ? session.status === 'active' : true;
681
+ } catch (error) {
682
+ logger.debugWithContext("hasSession lookup failed", {
683
+ component: "SessionManager",
684
+ method: "hasSession",
685
+ sessionId,
686
+ error: error.message
687
+ });
688
+ return false;
689
+ }
690
+ }
691
+
692
+ /**
693
+ * Remove a session from storage (e.g. when a client peer logs out). Best
694
+ * effort; missing sessions are treated as already gone.
695
+ *
696
+ * @param {string} sessionId
697
+ * @returns {Promise<boolean>}
698
+ */
699
+ async forgetSession(sessionId) {
700
+ if (!sessionId) return false;
701
+ try {
702
+ return await this.storage.delete(sessionId);
703
+ } catch (error) {
704
+ logger.debugWithContext("forgetSession failed", {
705
+ component: "SessionManager",
706
+ method: "forgetSession",
707
+ sessionId,
708
+ error: error.message
709
+ });
710
+ return false;
711
+ }
712
+ }
713
+
591
714
  async getSession(sessionId) {
592
715
  const requestId = ulid();
593
716
  const startTime = Date.now();
@@ -1083,13 +1206,9 @@ class SessionManager {
1083
1206
 
1084
1207
 
1085
1208
  async findSessionsByRoditId(roditId) {
1086
- const requestId = ulid();
1087
- const startTime = Date.now();
1088
- const baseContext = createLogContext("SessionManager", "findSessionsByRoditId", { requestId, roditId });
1089
-
1090
-
1209
+ const baseContext = createLogContext("SessionManager", "findSessionsByRoditId", { roditId });
1210
+
1091
1211
  try {
1092
- let result = [];
1093
1212
  // Get sessions from storage
1094
1213
  let allSessions = [];
1095
1214
  if (typeof this.storage.getAll === 'function') {
@@ -1101,16 +1220,12 @@ class SessionManager {
1101
1220
  if (s) allSessions.push(s);
1102
1221
  }
1103
1222
  }
1104
- result = allSessions.filter(s => s && s.roditId === roditId);
1105
-
1106
- const duration = Date.now() - startTime;
1107
-
1108
-
1109
- return result;
1223
+ return allSessions.filter(s => s && s.roditId === roditId);
1110
1224
  } catch (error) {
1111
- const duration = Date.now() - startTime;
1112
-
1113
-
1225
+ logger.debugWithContext("findSessionsByRoditId lookup failed", {
1226
+ ...baseContext,
1227
+ error: error.message
1228
+ });
1114
1229
  return [];
1115
1230
  }
1116
1231
  }
@@ -2160,6 +2160,9 @@ async function login_portal(config_own_rodit, port, options = {}) {
2160
2160
 
2161
2161
  const peer_base64url_jwk_public_key = Buffer.from(peer_rodit.owner_id, "hex").toString("base64url");
2162
2162
  await stateManager.setPeerBase64urlJwkPublicKey(peer_base64url_jwk_public_key);
2163
+ // Inbound webhooks are verified against the key each webhook advertises
2164
+ // and authorized by binding to the session opened here, so there is no
2165
+ // per-peer key registry to populate.
2163
2166
 
2164
2167
  logger.debug("Peer public key set in state manager", {
2165
2168
  component: "AuthenticationService",
@@ -17,8 +17,145 @@ const { ulid } = require("ulid");
17
17
  const { sendError } = require("../../services/error-response");
18
18
  const nacl = require("tweetnacl");
19
19
  const stateManager = require("../blockchain/statemanager");
20
+ const { sessionManager } = require("../auth/sessionmanager");
20
21
  const { authenticate_webhook } = require("../auth/authentication");
21
22
 
23
+ // ---------------------------------------------------------------------------
24
+ // Webhook identity + session helpers
25
+ // ---------------------------------------------------------------------------
26
+ // A webhook is a one-way POST whose only proof of origin is an Ed25519
27
+ // signature. The signer's public key travels WITH the webhook: the implicit
28
+ // account (hex of the key) and/or the base64url key are advertised in headers.
29
+ // Verification uses that key; authorization then binds it to a live session
30
+ // opened at login (see createWebhookAuthenticationMiddleware). There is nothing
31
+ // to "resolve" from local state, so these are plain extraction helpers.
32
+
33
+ const IMPLICIT_ACCOUNT_RE = /^[0-9a-f]{64}$/;
34
+
35
+ function normalizeString(value) {
36
+ return typeof value === "string" ? value.trim() : "";
37
+ }
38
+
39
+ function isImplicitAccount(tokenId) {
40
+ return IMPLICIT_ACCOUNT_RE.test(normalizeString(tokenId).toLowerCase());
41
+ }
42
+
43
+ function implicitAccountToBase64urlKey(tokenId) {
44
+ const hex = normalizeString(tokenId).toLowerCase();
45
+ if (!IMPLICIT_ACCOUNT_RE.test(hex)) return null;
46
+ return Buffer.from(hex, "hex").toString("base64url");
47
+ }
48
+
49
+ function base64urlKeyToImplicitAccount(base64urlKey) {
50
+ const key = normalizeString(base64urlKey);
51
+ if (!key) return null;
52
+ try {
53
+ const hex = Buffer.from(key, "base64url").toString("hex");
54
+ return IMPLICIT_ACCOUNT_RE.test(hex) ? hex : null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ function headerValue(headers, name) {
61
+ if (!headers) return "";
62
+ const raw = headers[name] ?? headers[name.toLowerCase()];
63
+ if (typeof raw === "string") return raw.trim();
64
+ if (Array.isArray(raw) && raw.length > 0) return String(raw[0]).trim();
65
+ return "";
66
+ }
67
+
68
+ function parseWebhookPayload(rawPayload, parsedBody) {
69
+ if (parsedBody && typeof parsedBody === "object") return parsedBody;
70
+ if (typeof rawPayload === "string" && rawPayload.trim()) {
71
+ try {
72
+ return JSON.parse(rawPayload);
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+ return null;
78
+ }
79
+
80
+ function sessionIdFromPayload(payload) {
81
+ if (!payload || typeof payload !== "object") return "";
82
+ const nested =
83
+ payload.data && typeof payload.data === "object" && !Array.isArray(payload.data)
84
+ ? payload.data
85
+ : null;
86
+ return (
87
+ normalizeString(payload.session_id) ||
88
+ (nested && normalizeString(nested.session_id)) ||
89
+ ""
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Extract the session id an inbound webhook is correlated with.
95
+ *
96
+ * The value carried in the SIGNED payload is authoritative because it is covered
97
+ * by the Ed25519 signature (payload + timestamp). The x-rodit-session-id header
98
+ * is only a pre-parse convenience mirror and is used as a fallback. Callers that
99
+ * need a trusted value should read this only after the signature has verified.
100
+ *
101
+ * @param {Object} params
102
+ * @param {Object} [params.headers] Incoming request headers.
103
+ * @param {string} [params.rawPayload] Raw request body (string).
104
+ * @param {Object} [params.parsedBody] Pre-parsed body, if available.
105
+ * @returns {string} The session id, or "" when none is present.
106
+ */
107
+ function extractWebhookSessionId(params = {}) {
108
+ const { headers, rawPayload, parsedBody } = params;
109
+ const payload = parseWebhookPayload(rawPayload, parsedBody);
110
+ const fromPayload = sessionIdFromPayload(payload);
111
+ if (fromPayload) return fromPayload;
112
+ return headerValue(headers, "x-rodit-session-id");
113
+ }
114
+
115
+ /**
116
+ * Extract the Ed25519 public key an inbound webhook must be verified against,
117
+ * taken directly from the identity the webhook advertises. There is no local
118
+ * lookup: the key IS the identifier (the implicit account is the hex of the
119
+ * key). Authorization — proving that key belongs to a peer we logged in with —
120
+ * is enforced separately by binding to a live session.
121
+ *
122
+ * The signer key is carried in two mutually exclusive encodings of the same
123
+ * identity: `X-Rodit-Implicit-Account` (hex, authoritative and preferred) and
124
+ * `X-Rodit-Public-Key` (base64url, used only when the implicit account is
125
+ * absent). Both are emitted by `send_webhook`. The chosen encoding is reported
126
+ * in `source` so the decision is observable in logs.
127
+ *
128
+ * @param {Object} [headers] Incoming request headers.
129
+ * @returns {{ key: string|null, source: string, implicitAccount: string }}
130
+ */
131
+ function extractWebhookSignerKey(headers) {
132
+ const advertisedKey = headerValue(headers, "x-rodit-public-key");
133
+ const implicit = headerValue(headers, "x-rodit-implicit-account");
134
+
135
+ if (isImplicitAccount(implicit)) {
136
+ const derived = implicitAccountToBase64urlKey(implicit);
137
+ // When the sender also advertises the raw key it must agree with the
138
+ // implicit account; a mismatch means a buggy or spoofing sender.
139
+ if (advertisedKey) {
140
+ const advertisedImplicit = base64urlKeyToImplicitAccount(advertisedKey);
141
+ if (advertisedImplicit && advertisedImplicit !== implicit.toLowerCase()) {
142
+ return { key: null, source: "implicit_mismatch", implicitAccount: implicit.toLowerCase() };
143
+ }
144
+ }
145
+ return { key: derived, source: "implicit_account", implicitAccount: implicit.toLowerCase() };
146
+ }
147
+
148
+ // Only the raw key was advertised: its identity is derived from the key.
149
+ if (advertisedKey) {
150
+ const advertisedImplicit = base64urlKeyToImplicitAccount(advertisedKey);
151
+ if (advertisedImplicit) {
152
+ return { key: advertisedKey, source: "advertised_key", implicitAccount: advertisedImplicit };
153
+ }
154
+ }
155
+
156
+ return { key: null, source: "unresolved", implicitAccount: "" };
157
+ }
158
+
22
159
  /**
23
160
  * Create a raw body parser middleware specifically for webhook endpoints
24
161
  * This preserves the raw body for signature verification
@@ -78,14 +215,15 @@ function createWebhookProcessingMiddleware() {
78
215
  }
79
216
 
80
217
  /**
81
- * Create middleware to attach the server's public key to the request
82
- * @param {Object} stateManager - State manager instance
218
+ * Create middleware that extracts the signer's public key from the inbound
219
+ * webhook and attaches it to the request for signature verification.
83
220
  * @returns {Function} Express middleware
84
221
  */
85
- function createPublicKeyMiddleware(stateManager) {
222
+ function createPublicKeyMiddleware() {
86
223
  return async (req, res, next) => {
87
224
  const requestId = crypto.randomUUID();
88
225
  const logContext = {
226
+ component: "WebhookHandler",
89
227
  requestId,
90
228
  apiEndpoint: req.path,
91
229
  method: req.method,
@@ -95,17 +233,38 @@ function createPublicKeyMiddleware(stateManager) {
95
233
  };
96
234
 
97
235
  try {
98
- // Check if this is a test environment where we should bypass signature verification
99
- // Get the peer public key from the state manager
100
- const peerBase64urlJwkPublicKey = stateManager.getPeerBase64urlJwkPublicKey();
101
-
102
- // If the peer public key is not available and we're not in test mode, return an error
236
+ // The signer's public key travels with the webhook itself (implicit
237
+ // account = hex of the key). Extract it directly; each webhook is
238
+ // self-identifying, so reception is correct even when connected to many
239
+ // peers. Trust that the key belongs to a known peer is established later
240
+ // by binding to a session (createWebhookAuthenticationMiddleware).
241
+ const resolution = extractWebhookSignerKey(req.headers);
242
+ const peerBase64urlJwkPublicKey = resolution.key;
243
+
244
+ // Correlate to the originating session (authoritative once the signature
245
+ // verifies downstream, since it is carried in the signed payload). Exposed
246
+ // on the request so handlers can link the webhook to a known session.
247
+ req.webhook_session_id = extractWebhookSessionId({
248
+ headers: req.headers,
249
+ rawPayload: req.rawBody,
250
+ parsedBody: req.body,
251
+ });
252
+
253
+ if (peerBase64urlJwkPublicKey) {
254
+ logger.debugWithContext("Extracted webhook signer key", {
255
+ ...logContext,
256
+ source: resolution.source,
257
+ implicitAccount: resolution.implicitAccount
258
+ });
259
+ }
260
+
261
+ // If the public key is not available and we're not in test mode, return an error
103
262
  if (!peerBase64urlJwkPublicKey) {
104
- logger.warnWithContext("Peer public key not available in state manager", logContext);
263
+ logger.warnWithContext("Webhook signer public key not present in request", logContext);
105
264
 
106
265
  // On main, we need the key
107
266
  if (isStrictEnvironment()) {
108
- logger.errorWithContext("Peer public key not available in main environment", logContext);
267
+ logger.errorWithContext("Webhook signer public key not present in main environment", logContext);
109
268
  return sendError(res, {
110
269
  statusCode: 500,
111
270
  requestId,
@@ -115,15 +274,15 @@ function createPublicKeyMiddleware(stateManager) {
115
274
  }
116
275
 
117
276
  // In development or test, we'll continue without the key and skip verification
118
- logger.infoWithContext("Continuing without peer public key in non-main environment", {
277
+ logger.infoWithContext("Continuing without webhook signer key in non-main environment", {
119
278
  ...logContext,
120
279
  environment: getNodeEnv()
121
280
  });
122
281
  }
123
282
 
124
283
  if (peerBase64urlJwkPublicKey) {
125
- // Log that we're using the peer public key
126
- logger.infoWithContext("Using peer public key from state manager", {
284
+ // Log that we're using the signer key advertised by the webhook
285
+ logger.infoWithContext("Using webhook signer public key from request", {
127
286
  ...logContext,
128
287
  keyFormat: "JWK",
129
288
  keyFound: true
@@ -188,6 +347,7 @@ function createWebhookAuthenticationMiddleware() {
188
347
  return async (req, res, next) => {
189
348
  const requestId = crypto.randomUUID();
190
349
  const logContext = {
350
+ component: "WebhookHandler",
191
351
  requestId,
192
352
  apiEndpoint: req.path,
193
353
  method: req.method,
@@ -306,6 +466,110 @@ function createWebhookAuthenticationMiddleware() {
306
466
  });
307
467
  }
308
468
 
469
+ // Authorization gate (signer <-> session binding).
470
+ //
471
+ // A valid signature only proves the sender holds the private key for the
472
+ // identity it advertised; it does NOT prove that identity is a peer we
473
+ // established a session with. Bind the verified signer to a live session
474
+ // opened at login: the session_id carried in the (now signature-verified)
475
+ // payload must map to an active session whose ownerId equals the signer's
476
+ // implicit account (hex of the public key the signature verified against).
477
+ // This rejects a made-up key that is internally consistent but unrelated
478
+ // to any peer we logged in with.
479
+ const bypassSessionBinding =
480
+ String(config.get('NODE_ENV', 'development')).toLowerCase() === 'test' ||
481
+ config.get('SECURITY_OPTIONS.BYPASS_WEBHOOK_VERIFICATION', false) === true;
482
+
483
+ if (!bypassSessionBinding) {
484
+ const sessionId =
485
+ req.webhook_session_id ||
486
+ extractWebhookSessionId({
487
+ headers: req.headers,
488
+ rawPayload: req.rawBody,
489
+ parsedBody: req.body,
490
+ });
491
+
492
+ if (!sessionId) {
493
+ logger.warnWithContext("Webhook rejected: not associated with a session", {
494
+ ...logContext,
495
+ result: 'failure',
496
+ reason: 'missing_session_id',
497
+ });
498
+ return sendError(res, {
499
+ statusCode: 401,
500
+ requestId,
501
+ code: 'WEBHOOK_SESSION_REQUIRED',
502
+ message: "Webhook is not associated with a session",
503
+ });
504
+ }
505
+
506
+ // Derive the verified signer's implicit account from the exact public
507
+ // key the signature was checked against.
508
+ let signerImplicitAccount = '';
509
+ try {
510
+ signerImplicitAccount = Buffer.from(publicKeyBase64url, 'base64url')
511
+ .toString('hex')
512
+ .toLowerCase();
513
+ } catch (implicitError) {
514
+ signerImplicitAccount = '';
515
+ }
516
+
517
+ if (!sessionManager || typeof sessionManager.getSession !== 'function') {
518
+ logger.errorWithContext("Session manager unavailable for webhook binding check", logContext);
519
+ return sendError(res, {
520
+ statusCode: 500,
521
+ requestId,
522
+ code: "SERVER_CONFIG_ERROR",
523
+ message: "Server configuration error",
524
+ });
525
+ }
526
+
527
+ const session = await sessionManager.getSession(sessionId);
528
+ const nowSec = Math.floor(Date.now() / 1000);
529
+ const sessionLive =
530
+ !!session &&
531
+ (session.status ? session.status === 'active' : true) &&
532
+ (!session.expiresAt || session.expiresAt > nowSec);
533
+
534
+ if (!sessionLive) {
535
+ logger.warnWithContext("Webhook rejected: session unknown or expired", {
536
+ ...logContext,
537
+ result: 'failure',
538
+ reason: 'session_not_live',
539
+ sessionId,
540
+ });
541
+ return sendError(res, {
542
+ statusCode: 401,
543
+ requestId,
544
+ code: 'WEBHOOK_SESSION_INVALID',
545
+ message: "Webhook session is unknown or expired",
546
+ });
547
+ }
548
+
549
+ const sessionOwner = String(session.ownerId || '').toLowerCase();
550
+ if (!sessionOwner || !signerImplicitAccount || sessionOwner !== signerImplicitAccount) {
551
+ logger.warnWithContext("Webhook rejected: signer does not match session owner", {
552
+ ...logContext,
553
+ result: 'failure',
554
+ reason: 'signer_session_mismatch',
555
+ sessionId,
556
+ signerImplicitAccount,
557
+ sessionOwner,
558
+ });
559
+ return sendError(res, {
560
+ statusCode: 403,
561
+ requestId,
562
+ code: 'WEBHOOK_SIGNER_SESSION_MISMATCH',
563
+ message: "Webhook signer is not the peer bound to this session",
564
+ });
565
+ }
566
+
567
+ // Expose the validated binding for downstream handlers.
568
+ req.webhook_session = session;
569
+ req.webhook_session_id = sessionId;
570
+ req.webhook_signer_implicit_account = signerImplicitAccount;
571
+ }
572
+
309
573
  logger.infoWithContext("Webhook authenticated successfully", {
310
574
  ...logContext,
311
575
  authDuration: authResult.duration,
@@ -401,15 +665,18 @@ function processWebhookEvent(req, logContext = {}) {
401
665
  }
402
666
 
403
667
  /**
404
- * Create a complete webhook handler for Express
405
- * @param {Object} stateManager - State manager instance
406
- * @param {Object} configuration - Configuration configuration
668
+ * Create a complete webhook handler for Express.
669
+ *
670
+ * The middleware is self-contained: the signer key is extracted from each
671
+ * inbound webhook and the shared state manager / session manager are imported
672
+ * directly, so no constructor arguments are required.
673
+ *
407
674
  * @returns {Object} Webhook handler with middleware and utilities
408
675
  */
409
- function createWebhookHandler(stateManager, configuration = {}) {
676
+ function createWebhookHandler() {
410
677
  const rawBodyParser = createRawBodyParser();
411
678
  const webhookProcessingMiddleware = createWebhookProcessingMiddleware();
412
- const publicKeyMiddleware = createPublicKeyMiddleware(stateManager);
679
+ const publicKeyMiddleware = createPublicKeyMiddleware();
413
680
  const authenticationMiddleware = createWebhookAuthenticationMiddleware();
414
681
 
415
682
  return {
@@ -461,6 +728,65 @@ function createWebhookHandler(stateManager, configuration = {}) {
461
728
  };
462
729
  }
463
730
 
731
+ /**
732
+ * Resolve the session id to stamp into an outbound webhook so the receiver can
733
+ * correlate it with the session opened at login.
734
+ *
735
+ * Resolution order:
736
+ * 1. options.sessionId — explicit override (most reliable for multi-peer).
737
+ * 2. req.user.session_id — the authenticated peer JWT context (set by the auth
738
+ * middleware when replying to a request).
739
+ * 3. req.session_id — session id attached directly to the request.
740
+ * 4. Session storage — the JWT issuer (login_client) keeps a session per peer
741
+ * keyed by that peer's roditId. Given the recipient's identity
742
+ * (options.sessionRoditId / options.peerTokenId) we resolve the shared
743
+ * session id from the SessionManager without needing any request context.
744
+ *
745
+ * @param {Object} options
746
+ * @param {Object} req
747
+ * @returns {Promise<string>} The resolved session id, or "" if none.
748
+ */
749
+ async function resolveOutboundWebhookSessionId(options = {}, req = null) {
750
+ const explicit =
751
+ (options && typeof options.sessionId === "string" && options.sessionId.trim()) ||
752
+ (req && req.user && typeof req.user.session_id === "string" && req.user.session_id.trim()) ||
753
+ (req && typeof req.session_id === "string" && req.session_id.trim()) ||
754
+ "";
755
+ if (explicit) return explicit;
756
+
757
+ const roditId =
758
+ (options && typeof options.sessionRoditId === "string" && options.sessionRoditId.trim()) ||
759
+ (options && typeof options.peerTokenId === "string" && options.peerTokenId.trim()) ||
760
+ "";
761
+ if (!roditId || !sessionManager || typeof sessionManager.findSessionsByRoditId !== "function") {
762
+ return "";
763
+ }
764
+
765
+ try {
766
+ const sessions = await sessionManager.findSessionsByRoditId(roditId);
767
+ if (!Array.isArray(sessions) || sessions.length === 0) return "";
768
+ const nowSec = Math.floor(Date.now() / 1000);
769
+ const usable = sessions
770
+ .filter((s) => s && s.id)
771
+ // Stamp the session the recipient holds *as a client*, i.e. one this peer
772
+ // issued as a server. Exclude sessions this peer merely recorded as a
773
+ // client (origin === "client") so mutual peers don't cross-stamp.
774
+ .filter((s) => (s.origin ? s.origin !== "client" : true))
775
+ .filter((s) => (s.status ? s.status === "active" : true))
776
+ .filter((s) => !s.expiresAt || s.expiresAt > nowSec)
777
+ .sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
778
+ return usable.length > 0 ? String(usable[0].id) : "";
779
+ } catch (sessionLookupError) {
780
+ logger.debugWithContext("Session storage lookup for webhook failed", {
781
+ component: "WebhookHandler",
782
+ method: "send_webhook",
783
+ roditId,
784
+ error: sessionLookupError.message
785
+ });
786
+ return "";
787
+ }
788
+ }
789
+
464
790
  /**
465
791
  * Send a webhook notification with comprehensive logging
466
792
  *
@@ -468,6 +794,8 @@ function createWebhookHandler(stateManager, configuration = {}) {
468
794
  * @param {Object} req - Express request object (optional)
469
795
  * @param {Object} options - Options object (optional)
470
796
  * @param {string} options.endpoint - Target endpoint path (e.g., '/webhook', '/hooks/wake', '/hooks/agent'). Defaults to '/webhook'
797
+ * @param {string} [options.sessionId] - Explicit session id to correlate this webhook with
798
+ * @param {string} [options.sessionRoditId] - Recipient peer roditId; used to resolve the session id from session storage
471
799
  * @returns {Promise<Object>} Webhook delivery result with requestId
472
800
  */
473
801
  async function send_webhook(data, req = null, options = {}) {
@@ -632,6 +960,12 @@ function createWebhookHandler(stateManager, configuration = {}) {
632
960
  };
633
961
  }
634
962
 
963
+ // Correlate this webhook with an existing session when one is known.
964
+ // Resolved from the explicit option, the authenticated request context,
965
+ // or session storage (by recipient roditId). Included in the SIGNED
966
+ // payload below so the receiver can trust and match it.
967
+ const webhookSessionId = await resolveOutboundWebhookSessionId(options, req);
968
+
635
969
  // Create the payload object
636
970
  const payloadObj = {
637
971
  event,
@@ -639,6 +973,9 @@ function createWebhookHandler(stateManager, configuration = {}) {
639
973
  isError,
640
974
  requestId,
641
975
  };
976
+ if (webhookSessionId) {
977
+ payloadObj.session_id = webhookSessionId;
978
+ }
642
979
 
643
980
  // Create the payload with consistent JSON formatting
644
981
  // Sort keys to ensure canonical representation regardless of object creation order
@@ -760,15 +1097,41 @@ function createWebhookHandler(stateManager, configuration = {}) {
760
1097
  signatureHex: signature_hex_ofpayload
761
1098
  });
762
1099
 
763
- // Prepare headers for the webhook request
764
- // Only include webhook-specific authentication headers (digital signature)
765
- // No API bearer tokens - webhook security relies on cryptographic signatures
766
- const headers = {
767
- "Content-Type": "application/json",
768
- "X-Signature": signature_hex_ofpayload,
769
- "X-Timestamp": timestamp.toString(),
770
- "X-Request-ID": requestId
771
- };
1100
+ // Prepare headers for the webhook request
1101
+ // Only include webhook-specific authentication headers (digital signature)
1102
+ // No API bearer tokens - webhook security relies on cryptographic signatures
1103
+ const headers = {
1104
+ "Content-Type": "application/json",
1105
+ "X-Signature": signature_hex_ofpayload,
1106
+ "X-Timestamp": timestamp.toString(),
1107
+ "X-Request-ID": requestId
1108
+ };
1109
+
1110
+ // Advertise the signer identity so multi-peer receivers can resolve the
1111
+ // correct verification key deterministically instead of relying on a
1112
+ // single mutable "current peer" slot. The implicit account is the hex of
1113
+ // the signing Ed25519 public key and is authoritative on its own.
1114
+ try {
1115
+ if (publicKey) {
1116
+ headers["X-Rodit-Public-Key"] = publicKey;
1117
+ headers["X-Rodit-Implicit-Account"] = Buffer.from(publicKey, "base64url").toString("hex");
1118
+ }
1119
+ const ownTokenId = config_own_rodit?.own_rodit?.token_id;
1120
+ if (ownTokenId) {
1121
+ headers["X-Rodit-Token-Id"] = String(ownTokenId);
1122
+ }
1123
+ } catch (identityHeaderError) {
1124
+ logger.debugWithContext("Unable to attach signer identity headers", {
1125
+ ...baseContext,
1126
+ error: identityHeaderError.message
1127
+ });
1128
+ }
1129
+
1130
+ // Mirror the (signed) session id into a header for pre-parse correlation.
1131
+ // The signed payload value remains authoritative; this is convenience only.
1132
+ if (webhookSessionId) {
1133
+ headers["X-Rodit-Session-Id"] = webhookSessionId;
1134
+ }
772
1135
 
773
1136
  // Log the exact headers being sent
774
1137
  logger.debugWithContext("Webhook request headers", {
@@ -1377,7 +1740,14 @@ module.exports = {
1377
1740
  processWebhookEvent,
1378
1741
  createWebhookHandler,
1379
1742
  send_webhook,
1380
-
1743
+
1744
+ // Webhook identity + session helpers
1745
+ extractWebhookSignerKey,
1746
+ extractWebhookSessionId,
1747
+ isImplicitAccount,
1748
+ implicitAccountToBase64urlKey,
1749
+ base64urlKeyToImplicitAccount,
1750
+
1381
1751
  // Added exports from eventhandler.js
1382
1752
  WebhookEventHandler,
1383
1753
  TestConfigUpdateHandler,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rodit/rodit-auth-be",
3
- "version": "9.11.20",
3
+ "version": "9.12.0",
4
4
  "description": "RODiT-based authentication system for Express.js applications",
5
5
  "main": "index.js",
6
6
  "exports": {