@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 +76 -0
- package/index.js +90 -14
- package/lib/auth/sessionmanager.js +131 -16
- package/lib/middleware/authenticationmw.js +3 -0
- package/lib/middleware/webhookhandlermw.js +398 -28
- package/package.json +1 -1
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,
|
|
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,
|
|
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:
|
|
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
|
|
1087
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
82
|
-
*
|
|
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(
|
|
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
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
//
|
|
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("
|
|
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("
|
|
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
|
|
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
|
|
126
|
-
logger.infoWithContext("Using
|
|
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
|
-
*
|
|
406
|
-
*
|
|
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(
|
|
676
|
+
function createWebhookHandler() {
|
|
410
677
|
const rawBodyParser = createRawBodyParser();
|
|
411
678
|
const webhookProcessingMiddleware = createWebhookProcessingMiddleware();
|
|
412
|
-
const publicKeyMiddleware = createPublicKeyMiddleware(
|
|
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
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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,
|