@rodit/rodit-auth-be 9.11.20 → 9.13.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.
@@ -14,7 +14,8 @@ const nacl = require("tweetnacl");
14
14
  const {
15
15
  validate_jwt_token_be,
16
16
  generate_jwt_token,
17
- tokenService
17
+ validateFederatedLoginTarget,
18
+ normalizeUrlWithoutPort,
18
19
  } = require("../auth/tokenservice");
19
20
  // Import specific functions from authentication.js to avoid circular dependencies
20
21
  // Import specific functions from authentication.js to avoid circular dependencies
@@ -1879,6 +1880,7 @@ async function login_portal(config_own_rodit, port, options = {}) {
1879
1880
  * @param {Object} config_own_rodit - Configuration object containing own_rodit and private key
1880
1881
  * @param {Object} [options] - Optional settings
1881
1882
  * @param {string} [options.loginPath] - HTTP path (default /api/login)
1883
+ * @param {string} [options.apiEndpoint] - Federated API base URL to log into (defaults to own subjectuniqueidentifier_url)
1882
1884
  * @param {number} [options.timestamp] - Unix seconds used for signature generation (if omitted, fetched from peer /api/login/timestamp)
1883
1885
  * @param {string} [options.accountId] - Explicit NEAR account for outbound login when token id absent
1884
1886
  * @param {string} [options.timestampPath] - Timestamp endpoint path (default /api/login/timestamp)
@@ -1931,7 +1933,9 @@ async function login_portal(config_own_rodit, port, options = {}) {
1931
1933
  return { error: "Error 0111: Client configuration not initialized" };
1932
1934
  }
1933
1935
 
1934
- const apiendpoint = config_own_rodit.own_rodit?.metadata?.subjectuniqueidentifier_url;
1936
+ const apiendpoint =
1937
+ options.apiEndpoint ??
1938
+ config_own_rodit.own_rodit?.metadata?.subjectuniqueidentifier_url;
1935
1939
  const loginPath =
1936
1940
  options.loginPath ??
1937
1941
  config_own_rodit.login_rodit_path ??
@@ -1944,7 +1948,16 @@ async function login_portal(config_own_rodit, port, options = {}) {
1944
1948
  requestId,
1945
1949
  apiEndpoint: apiendpoint,
1946
1950
  loginUrl,
1947
- source: config_own_rodit.own_rodit?.metadata?.subjectuniqueidentifier_url ? "metadata" : "config",
1951
+ federatedLogin:
1952
+ normalizeUrlWithoutPort(apiendpoint) !==
1953
+ normalizeUrlWithoutPort(
1954
+ config_own_rodit.own_rodit?.metadata?.subjectuniqueidentifier_url
1955
+ ),
1956
+ source: options.apiEndpoint
1957
+ ? "options.apiEndpoint"
1958
+ : config_own_rodit.own_rodit?.metadata?.subjectuniqueidentifier_url
1959
+ ? "metadata"
1960
+ : "config",
1948
1961
  });
1949
1962
 
1950
1963
  const roditid = normalizeOptionalLoginString(own_rodit?.token_id);
@@ -2158,8 +2171,65 @@ async function login_portal(config_own_rodit, port, options = {}) {
2158
2171
  };
2159
2172
  }
2160
2173
 
2174
+ const federatedCheck = validateFederatedLoginTarget(
2175
+ validationResult.payload,
2176
+ apiendpoint,
2177
+ config_own_rodit.own_rodit?.metadata?.subjectuniqueidentifier_url
2178
+ );
2179
+
2180
+ if (!federatedCheck.ok) {
2181
+ const duration = Date.now() - startTime;
2182
+
2183
+ logger.error("Federated login issuer check failed", {
2184
+ component: "AuthenticationService",
2185
+ method,
2186
+ requestId,
2187
+ duration,
2188
+ errorCode: federatedCheck.errorCode,
2189
+ errorMessage: federatedCheck.errorMessage,
2190
+ intendedApiEndpoint: apiendpoint,
2191
+ jwtIss: validationResult.payload?.iss,
2192
+ jwtFederatedIssuer:
2193
+ validationResult.payload?.rodit_subjectuniqueidentifier_url,
2194
+ });
2195
+
2196
+ logger.metric("login_duration_ms", duration, {
2197
+ component: "AuthenticationService",
2198
+ success: false,
2199
+ error: federatedCheck.errorCode,
2200
+ });
2201
+ logger.metric("login_errors_total", 1, {
2202
+ component: "AuthenticationService",
2203
+ error: federatedCheck.errorCode,
2204
+ });
2205
+
2206
+ return {
2207
+ error:
2208
+ federatedCheck.errorMessage ||
2209
+ "Federated login issuer verification failed",
2210
+ errorCode: federatedCheck.errorCode,
2211
+ failureReason: federatedCheck.errorCode,
2212
+ requestId,
2213
+ };
2214
+ }
2215
+
2216
+ if (federatedCheck.federated) {
2217
+ logger.info("Federated login issuer verified", {
2218
+ component: "AuthenticationService",
2219
+ method,
2220
+ requestId,
2221
+ intendedApiEndpoint: apiendpoint,
2222
+ clientHomeIssuer: validationResult.payload?.iss,
2223
+ federatedIssuer:
2224
+ validationResult.payload?.rodit_subjectuniqueidentifier_url,
2225
+ });
2226
+ }
2227
+
2161
2228
  const peer_base64url_jwk_public_key = Buffer.from(peer_rodit.owner_id, "hex").toString("base64url");
2162
2229
  await stateManager.setPeerBase64urlJwkPublicKey(peer_base64url_jwk_public_key);
2230
+ // Inbound webhooks are verified against the key each webhook advertises
2231
+ // and authorized by binding to the session opened here, so there is no
2232
+ // per-peer key registry to populate.
2163
2233
 
2164
2234
  logger.debug("Peer public key set in state manager", {
2165
2235
  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.13.0",
4
4
  "description": "RODiT-based authentication system for Express.js applications",
5
5
  "main": "index.js",
6
6
  "exports": {