@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.
- package/CHANGELOG.md +100 -0
- package/README.md +1712 -2324
- package/index.js +104 -16
- package/lib/auth/sessionmanager.js +131 -16
- package/lib/auth/tokenservice.js +153 -33
- package/lib/middleware/authenticationmw.js +73 -3
- package/lib/middleware/webhookhandlermw.js +398 -28
- package/package.json +1 -1
|
@@ -14,7 +14,8 @@ const nacl = require("tweetnacl");
|
|
|
14
14
|
const {
|
|
15
15
|
validate_jwt_token_be,
|
|
16
16
|
generate_jwt_token,
|
|
17
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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,
|