@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
package/index.js
CHANGED
|
@@ -26,7 +26,11 @@ const {
|
|
|
26
26
|
|
|
27
27
|
const {
|
|
28
28
|
validate_jwt_token_be,
|
|
29
|
-
generate_jwt_token
|
|
29
|
+
generate_jwt_token,
|
|
30
|
+
normalizeUrlWithoutPort,
|
|
31
|
+
isNonEmptyUrlClaim,
|
|
32
|
+
isFederatedRoditLogin,
|
|
33
|
+
validateFederatedLoginTarget,
|
|
30
34
|
} = require('./lib/auth/tokenservice');
|
|
31
35
|
|
|
32
36
|
const validatepermissions = require('./lib/middleware/validatepermissionsmw');
|
|
@@ -61,6 +65,10 @@ function sessionFieldsFromJwt(token) {
|
|
|
61
65
|
sessionId: payload.session_id,
|
|
62
66
|
expiresAt: payload.session_exp,
|
|
63
67
|
createdAt: payload.session_iat,
|
|
68
|
+
// Identity of the peer (server) that issued this JWT, so the client can
|
|
69
|
+
// record the session keyed by the server it opened it with.
|
|
70
|
+
peerRoditId: payload.rodit_id,
|
|
71
|
+
peerOwnerId: payload.rodit_owner,
|
|
64
72
|
};
|
|
65
73
|
} catch (_error) {
|
|
66
74
|
return {};
|
|
@@ -281,32 +289,53 @@ class RoditClient {
|
|
|
281
289
|
* @param {Object} [req] - Express request (for deriving peer webhook URL and headers)
|
|
282
290
|
* @returns {Promise<Object>} Webhook result
|
|
283
291
|
*/
|
|
284
|
-
async sendWebhookToEndpoint(data, endpoint, req) {
|
|
292
|
+
async sendWebhookToEndpoint(data, endpoint, req, options = {}) {
|
|
285
293
|
if (webhookHandler.send_webhook) {
|
|
286
|
-
return webhookHandler.send_webhook(data, req,
|
|
294
|
+
return webhookHandler.send_webhook(data, req, this._buildWebhookOptions(endpoint, options));
|
|
287
295
|
}
|
|
288
296
|
throw new Error('Webhook functionality not available');
|
|
289
297
|
}
|
|
290
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Build the options passed to the underlying send_webhook. Defaults the
|
|
301
|
+
* session id to this client's own session (set at login_server) so a peer that
|
|
302
|
+
* initiated the login stamps the shared session id automatically. Callers can
|
|
303
|
+
* override with options.sessionId or resolve from storage via
|
|
304
|
+
* options.sessionRoditId.
|
|
305
|
+
* @private
|
|
306
|
+
* @param {string} endpoint
|
|
307
|
+
* @param {Object} [options]
|
|
308
|
+
* @returns {Object}
|
|
309
|
+
*/
|
|
310
|
+
_buildWebhookOptions(endpoint, options = {}) {
|
|
311
|
+
const merged = { ...options, endpoint };
|
|
312
|
+
if (merged.sessionId == null && this.sessionId) {
|
|
313
|
+
merged.sessionId = this.sessionId;
|
|
314
|
+
}
|
|
315
|
+
return merged;
|
|
316
|
+
}
|
|
317
|
+
|
|
291
318
|
/**
|
|
292
319
|
* Send webhook (backward compatibility alias)
|
|
293
320
|
* @param {Object} data - Webhook payload object
|
|
294
321
|
* @param {Object} [req] - Express request (for deriving peer webhook URL and headers)
|
|
322
|
+
* @param {Object} [options] - Webhook options (sessionId, sessionRoditId)
|
|
295
323
|
* @returns {Promise<Object>} Webhook result
|
|
296
324
|
*/
|
|
297
|
-
async send_webhook(data, req) {
|
|
298
|
-
return this.sendWebhook(data, req);
|
|
325
|
+
async send_webhook(data, req, options = {}) {
|
|
326
|
+
return this.sendWebhook(data, req, options);
|
|
299
327
|
}
|
|
300
328
|
|
|
301
329
|
/**
|
|
302
330
|
* Send webhook to default /webhook endpoint
|
|
303
331
|
* @param {Object} data - Webhook payload object
|
|
304
332
|
* @param {Object} [req] - Express request (optional)
|
|
333
|
+
* @param {Object} [options] - Webhook options (sessionId, sessionRoditId)
|
|
305
334
|
* @returns {Promise<Object>} Webhook result
|
|
306
335
|
*/
|
|
307
|
-
async sendWebhook(data, req) {
|
|
336
|
+
async sendWebhook(data, req, options = {}) {
|
|
308
337
|
if (webhookHandler.send_webhook) {
|
|
309
|
-
return webhookHandler.send_webhook(data, req,
|
|
338
|
+
return webhookHandler.send_webhook(data, req, this._buildWebhookOptions('/webhook', options));
|
|
310
339
|
}
|
|
311
340
|
throw new Error('Webhook functionality not available');
|
|
312
341
|
}
|
|
@@ -316,10 +345,11 @@ class RoditClient {
|
|
|
316
345
|
* Trigger immediate heartbeat (enqueues system event for main session)
|
|
317
346
|
* @param {Object} data - Webhook payload
|
|
318
347
|
* @param {Object} [req] - Express request
|
|
348
|
+
* @param {Object} [options] - Webhook options (sessionId, sessionRoditId)
|
|
319
349
|
* @returns {Promise<Object>} Webhook result
|
|
320
350
|
*/
|
|
321
|
-
async sendWakeHook(data, req) {
|
|
322
|
-
return this.sendWebhookToEndpoint(data, '/hooks/wake', req);
|
|
351
|
+
async sendWakeHook(data, req, options = {}) {
|
|
352
|
+
return this.sendWebhookToEndpoint(data, '/hooks/wake', req, options);
|
|
323
353
|
}
|
|
324
354
|
|
|
325
355
|
/**
|
|
@@ -327,10 +357,11 @@ class RoditClient {
|
|
|
327
357
|
* Run isolated agent task with optional reply to messaging channels
|
|
328
358
|
* @param {Object} data - Webhook payload
|
|
329
359
|
* @param {Object} [req] - Express request
|
|
360
|
+
* @param {Object} [options] - Webhook options (sessionId, sessionRoditId)
|
|
330
361
|
* @returns {Promise<Object>} Webhook result
|
|
331
362
|
*/
|
|
332
|
-
async sendAgentHook(data, req) {
|
|
333
|
-
return this.sendWebhookToEndpoint(data, '/hooks/agent', req);
|
|
363
|
+
async sendAgentHook(data, req, options = {}) {
|
|
364
|
+
return this.sendWebhookToEndpoint(data, '/hooks/agent', req, options);
|
|
334
365
|
}
|
|
335
366
|
|
|
336
367
|
/**
|
|
@@ -880,15 +911,32 @@ class RoditClient {
|
|
|
880
911
|
sessionId: this.sessionData?.id
|
|
881
912
|
});
|
|
882
913
|
|
|
914
|
+
// Forget the recorded session so it no longer cross-references webhooks.
|
|
915
|
+
if (this.sessionId) {
|
|
916
|
+
Promise.resolve(sessionManager.forgetSession(this.sessionId)).catch(() => {});
|
|
917
|
+
}
|
|
918
|
+
|
|
883
919
|
// Clear session data
|
|
884
920
|
this.sessionData = null;
|
|
885
921
|
this.jwt_token = null;
|
|
922
|
+
this.sessionId = null;
|
|
886
923
|
|
|
887
924
|
// Also clear JWT token from stateManager
|
|
888
925
|
this.stateManager.setJwtToken(null);
|
|
889
926
|
|
|
890
927
|
return true;
|
|
891
928
|
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Check whether a session id refers to a live session this client currently
|
|
932
|
+
* holds open. Use this to cross-reference the session_id carried by an inbound
|
|
933
|
+
* webhook against the sessions opened at login.
|
|
934
|
+
* @param {string} sessionId
|
|
935
|
+
* @returns {Promise<boolean>}
|
|
936
|
+
*/
|
|
937
|
+
async isKnownSession(sessionId) {
|
|
938
|
+
return sessionManager.hasSession(sessionId);
|
|
939
|
+
}
|
|
892
940
|
|
|
893
941
|
|
|
894
942
|
/**
|
|
@@ -989,6 +1037,7 @@ class RoditClient {
|
|
|
989
1037
|
*
|
|
990
1038
|
* @param {Object} [lsoptions] - Optional settings
|
|
991
1039
|
* @param {string} [lsoptions.loginPath] - Login path (default /api/login)
|
|
1040
|
+
* @param {string} [lsoptions.apiEndpoint] - Federated API URL to authenticate against (MITM-checked via JWT rodit_subjectuniqueidentifier_url)
|
|
992
1041
|
* @returns {Promise<Object>} Login result with token
|
|
993
1042
|
*/
|
|
994
1043
|
async login_server(lsoptions = {}) {
|
|
@@ -999,7 +1048,10 @@ class RoditClient {
|
|
|
999
1048
|
component: 'RoditClient',
|
|
1000
1049
|
method: 'login_server',
|
|
1001
1050
|
requestId,
|
|
1002
|
-
lsoptions: {
|
|
1051
|
+
lsoptions: {
|
|
1052
|
+
loginPath: lsoptions.loginPath,
|
|
1053
|
+
apiEndpoint: lsoptions.apiEndpoint,
|
|
1054
|
+
}
|
|
1003
1055
|
});
|
|
1004
1056
|
|
|
1005
1057
|
try {
|
|
@@ -1124,14 +1176,42 @@ class RoditClient {
|
|
|
1124
1176
|
const fromJwt = sessionFieldsFromJwt(loginResult.jwt_token);
|
|
1125
1177
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
1126
1178
|
this.sessionId = fromJwt.sessionId || ulid();
|
|
1179
|
+
const sessionCreatedAt = fromJwt.createdAt ?? nowSec;
|
|
1180
|
+
const sessionExpiresAt =
|
|
1181
|
+
fromJwt.expiresAt ?? nowSec + config.getDefaultJwtDurationSeconds();
|
|
1127
1182
|
this.setSessionData({
|
|
1128
1183
|
id: this.sessionId,
|
|
1129
|
-
createdAt:
|
|
1130
|
-
expiresAt:
|
|
1131
|
-
fromJwt.expiresAt ??
|
|
1132
|
-
nowSec + config.getDefaultJwtDurationSeconds(),
|
|
1184
|
+
createdAt: sessionCreatedAt,
|
|
1185
|
+
expiresAt: sessionExpiresAt,
|
|
1133
1186
|
status: 'active'
|
|
1134
1187
|
});
|
|
1188
|
+
|
|
1189
|
+
// Record the opened session in the shared SessionManager, keyed by the
|
|
1190
|
+
// server we logged into. This mirrors how a server stores sessions for
|
|
1191
|
+
// its client peers, so a client connected to many servers can
|
|
1192
|
+
// cross-reference an inbound webhook's session_id to a live session
|
|
1193
|
+
// (multi-peer safe, unlike the single this.sessionId slot).
|
|
1194
|
+
try {
|
|
1195
|
+
const serverRoditId =
|
|
1196
|
+
fromJwt.peerRoditId || config_own_rodit?.own_rodit?.token_id || null;
|
|
1197
|
+
if (serverRoditId) {
|
|
1198
|
+
await sessionManager.recordSession({
|
|
1199
|
+
id: this.sessionId,
|
|
1200
|
+
roditId: serverRoditId,
|
|
1201
|
+
ownerId: fromJwt.peerOwnerId,
|
|
1202
|
+
createdAt: sessionCreatedAt,
|
|
1203
|
+
expiresAt: sessionExpiresAt,
|
|
1204
|
+
status: 'active'
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
} catch (recordErr) {
|
|
1208
|
+
logger.debug('Unable to record client session for webhook correlation', {
|
|
1209
|
+
component: 'RoditClient',
|
|
1210
|
+
method: 'login_server',
|
|
1211
|
+
requestId,
|
|
1212
|
+
error: recordErr.message
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1135
1215
|
}
|
|
1136
1216
|
|
|
1137
1217
|
const duration = Date.now() - startTime;
|
|
@@ -1857,8 +1937,16 @@ module.exports = {
|
|
|
1857
1937
|
logout_server,
|
|
1858
1938
|
validate_jwt_token_be,
|
|
1859
1939
|
generate_jwt_token,
|
|
1940
|
+
normalizeUrlWithoutPort,
|
|
1941
|
+
isNonEmptyUrlClaim,
|
|
1942
|
+
isFederatedRoditLogin,
|
|
1943
|
+
validateFederatedLoginTarget,
|
|
1860
1944
|
validatepermissions,
|
|
1861
1945
|
webhookHandler,
|
|
1946
|
+
// Webhook identity + session helpers (key travels with the webhook; trust is
|
|
1947
|
+
// established by binding the verified signer to a session opened at login)
|
|
1948
|
+
extractWebhookSignerKey: webhookHandler.extractWebhookSignerKey,
|
|
1949
|
+
extractWebhookSessionId: webhookHandler.extractWebhookSessionId,
|
|
1862
1950
|
versioningMiddleware,
|
|
1863
1951
|
loggingmw,
|
|
1864
1952
|
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
|
}
|
package/lib/auth/tokenservice.js
CHANGED
|
@@ -70,6 +70,85 @@ function extractLoginPeerRoditIdFromSub(sub) {
|
|
|
70
70
|
return subParts.length > 1 ? subParts[1] : "";
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
function normalizeUrlWithoutPort(url) {
|
|
74
|
+
if (!url) return "";
|
|
75
|
+
try {
|
|
76
|
+
const parsedUrl = new URL(url);
|
|
77
|
+
parsedUrl.port = "";
|
|
78
|
+
return parsedUrl.toString();
|
|
79
|
+
} catch (_e) {
|
|
80
|
+
return String(url);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isNonEmptyUrlClaim(value) {
|
|
85
|
+
return typeof value === "string" && value.trim() !== "";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** True when login peer and issuing server declare different API URLs (federated login). */
|
|
89
|
+
function isFederatedRoditLogin(peer_rodit, own_rodit) {
|
|
90
|
+
const peerUrl = peer_rodit?.metadata?.subjectuniqueidentifier_url;
|
|
91
|
+
const ownUrl = own_rodit?.metadata?.subjectuniqueidentifier_url;
|
|
92
|
+
if (!peerUrl || !ownUrl) return false;
|
|
93
|
+
return normalizeUrlWithoutPort(peerUrl) !== normalizeUrlWithoutPort(ownUrl);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Client MITM check after JWT crypto validation.
|
|
98
|
+
* Federated attempt = intendedApiEndpoint differs from client home URL.
|
|
99
|
+
*
|
|
100
|
+
* @param {Object} payload - Verified JWT payload
|
|
101
|
+
* @param {string} intendedApiEndpoint - URL the client connected to for login
|
|
102
|
+
* @param {string} clientHomeSubjectUrl - Client RODiT subjectuniqueidentifier_url
|
|
103
|
+
* @returns {{ ok: boolean, federated?: boolean, errorCode?: string, errorMessage?: string }}
|
|
104
|
+
*/
|
|
105
|
+
function validateFederatedLoginTarget(
|
|
106
|
+
payload,
|
|
107
|
+
intendedApiEndpoint,
|
|
108
|
+
clientHomeSubjectUrl
|
|
109
|
+
) {
|
|
110
|
+
const normalizedIntended = normalizeUrlWithoutPort(intendedApiEndpoint);
|
|
111
|
+
const normalizedClientHome = normalizeUrlWithoutPort(clientHomeSubjectUrl);
|
|
112
|
+
const isFederatedAttempt = normalizedClientHome !== normalizedIntended;
|
|
113
|
+
|
|
114
|
+
if (!isFederatedAttempt) {
|
|
115
|
+
return { ok: true, federated: false };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const federatedIssuerUrl = payload?.rodit_subjectuniqueidentifier_url;
|
|
119
|
+
if (!isNonEmptyUrlClaim(federatedIssuerUrl)) {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
federated: true,
|
|
123
|
+
errorCode: "FEDERATED_ISSUER_MISSING",
|
|
124
|
+
errorMessage:
|
|
125
|
+
"Federated login JWT has null/empty rodit_subjectuniqueidentifier_url",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (normalizeUrlWithoutPort(federatedIssuerUrl) !== normalizedIntended) {
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
federated: true,
|
|
133
|
+
errorCode: "FEDERATED_ISSUER_MISMATCH",
|
|
134
|
+
errorMessage:
|
|
135
|
+
"rodit_subjectuniqueidentifier_url does not match login apiEndpoint",
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (normalizeUrlWithoutPort(payload?.iss) !== normalizedClientHome) {
|
|
140
|
+
return {
|
|
141
|
+
ok: false,
|
|
142
|
+
federated: true,
|
|
143
|
+
errorCode: "FEDERATED_ISSUER_MISMATCH",
|
|
144
|
+
errorMessage:
|
|
145
|
+
"JWT iss does not match client home subjectuniqueidentifier_url",
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { ok: true, federated: true };
|
|
150
|
+
}
|
|
151
|
+
|
|
73
152
|
function parseRoditJwtDurationSeconds(metadata) {
|
|
74
153
|
const parsed = parseInt(metadata?.jwt_duration, 10);
|
|
75
154
|
if (Number.isFinite(parsed) && parsed > 0) {
|
|
@@ -750,6 +829,7 @@ function resolveCredentialExpirationUnix(now, sessionExpiration, own_rodit) {
|
|
|
750
829
|
|
|
751
830
|
const jwtSignStart = Date.now();
|
|
752
831
|
const { SignJWT } = await getJose();
|
|
832
|
+
const federatedLogin = isFederatedRoditLogin(peer_rodit, own_rodit);
|
|
753
833
|
const token = await new SignJWT({
|
|
754
834
|
iss: peer_rodit.metadata.subjectuniqueidentifier_url,
|
|
755
835
|
sub:
|
|
@@ -776,6 +856,10 @@ function resolveCredentialExpirationUnix(now, sessionExpiration, own_rodit) {
|
|
|
776
856
|
rodit_allowedcidr: peer_rodit.metadata.allowed_cidr,
|
|
777
857
|
rodit_allowediso3166list: peer_rodit.metadata.allowed_iso3166list,
|
|
778
858
|
rodit_webhookurl: peer_rodit.metadata.webhook_url,
|
|
859
|
+
// Always present (null when same-API); EdDSA covers the claim like any other
|
|
860
|
+
rodit_subjectuniqueidentifier_url: federatedLogin
|
|
861
|
+
? own_rodit.metadata.subjectuniqueidentifier_url
|
|
862
|
+
: null,
|
|
779
863
|
config_iso639: null,
|
|
780
864
|
config_iso3166: null,
|
|
781
865
|
config_iso15924: null,
|
|
@@ -802,6 +886,7 @@ function resolveCredentialExpirationUnix(now, sessionExpiration, own_rodit) {
|
|
|
802
886
|
jwtId,
|
|
803
887
|
sessionId: session_id,
|
|
804
888
|
sessionStatus: session_status,
|
|
889
|
+
federatedLogin,
|
|
805
890
|
tokenValidFor: tokenExpiration - now,
|
|
806
891
|
sessionValidFor: sessionExpiration - now
|
|
807
892
|
});
|
|
@@ -1060,6 +1145,11 @@ function resolveCredentialExpirationUnix(now, sessionExpiration, own_rodit) {
|
|
|
1060
1145
|
rodit_webhookcidr: token.rodit_webhookcidr,
|
|
1061
1146
|
rodit_allowedcidr: token.rodit_allowedcidr,
|
|
1062
1147
|
rodit_webhookurl: token.rodit_webhookurl,
|
|
1148
|
+
rodit_subjectuniqueidentifier_url: isNonEmptyUrlClaim(
|
|
1149
|
+
token.rodit_subjectuniqueidentifier_url
|
|
1150
|
+
)
|
|
1151
|
+
? token.rodit_subjectuniqueidentifier_url
|
|
1152
|
+
: null,
|
|
1063
1153
|
config_iso639: null,
|
|
1064
1154
|
config_iso3166: null,
|
|
1065
1155
|
config_iso15924: null,
|
|
@@ -1555,46 +1645,71 @@ function resolveCredentialExpirationUnix(now, sessionExpiration, own_rodit) {
|
|
|
1555
1645
|
throw new Error("Error 006: Token is not yet valid");
|
|
1556
1646
|
}
|
|
1557
1647
|
|
|
1558
|
-
// Function to normalize URL by removing port
|
|
1559
|
-
const normalizeUrlWithoutPort = (url) => {
|
|
1560
|
-
if (!url) return '';
|
|
1561
|
-
try {
|
|
1562
|
-
// Use URL constructor to parse the URL
|
|
1563
|
-
const parsedUrl = new URL(url);
|
|
1564
|
-
// Remove the port
|
|
1565
|
-
parsedUrl.port = '';
|
|
1566
|
-
// Return the normalized URL as a string
|
|
1567
|
-
return parsedUrl.toString();
|
|
1568
|
-
} catch (e) {
|
|
1569
|
-
// If URL parsing fails, return the original URL
|
|
1570
|
-
return url;
|
|
1571
|
-
}
|
|
1572
|
-
};
|
|
1573
|
-
|
|
1574
|
-
// Normalize both URLs for comparison
|
|
1575
|
-
const normalizedTokenIssuer = normalizeUrlWithoutPort(payload.iss);
|
|
1576
|
-
const normalizedExpectedIssuer = normalizeUrlWithoutPort(rodit.metadata.subjectuniqueidentifier_url);
|
|
1577
|
-
|
|
1578
1648
|
// Issuer check with enhanced logging
|
|
1649
|
+
const federatedIssuerUrl = payload.rodit_subjectuniqueidentifier_url;
|
|
1650
|
+
const isFederatedJwt = isNonEmptyUrlClaim(federatedIssuerUrl);
|
|
1651
|
+
const normalizedTokenIssuer = normalizeUrlWithoutPort(payload.iss);
|
|
1652
|
+
const normalizedOwnSubjectUrl = normalizeUrlWithoutPort(
|
|
1653
|
+
rodit.metadata.subjectuniqueidentifier_url
|
|
1654
|
+
);
|
|
1655
|
+
|
|
1579
1656
|
logger.debug("Detailed issuer validation information", {
|
|
1580
1657
|
component: "JwtAuth",
|
|
1581
1658
|
method: "validate_jwt_token_be",
|
|
1582
1659
|
requestId,
|
|
1583
1660
|
tokenIssuer: payload.iss,
|
|
1584
1661
|
expectedIssuer: rodit.metadata.subjectuniqueidentifier_url,
|
|
1662
|
+
federatedIssuerUrl,
|
|
1585
1663
|
normalizedTokenIssuer,
|
|
1586
|
-
|
|
1664
|
+
normalizedOwnSubjectUrl,
|
|
1587
1665
|
roditId: rodit.token_id,
|
|
1588
1666
|
roditOwnerId: rodit.owner_id,
|
|
1589
1667
|
hasMetadata: !!rodit.metadata,
|
|
1590
1668
|
metadataKeys: rodit.metadata ? Object.keys(rodit.metadata) : [],
|
|
1591
1669
|
payloadKeys: Object.keys(payload),
|
|
1592
|
-
|
|
1593
|
-
normalizedIssuerMatch: normalizedTokenIssuer === normalizedExpectedIssuer
|
|
1670
|
+
isFederatedJwt,
|
|
1594
1671
|
});
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1672
|
+
|
|
1673
|
+
if (isFederatedJwt) {
|
|
1674
|
+
// 1) signed federated API URL must be THIS server
|
|
1675
|
+
if (
|
|
1676
|
+
normalizeUrlWithoutPort(federatedIssuerUrl) !== normalizedOwnSubjectUrl
|
|
1677
|
+
) {
|
|
1678
|
+
logger.warn("Token validation failed - Invalid federated issuer URL", {
|
|
1679
|
+
component: "JwtAuth",
|
|
1680
|
+
method: "validate_jwt_token_be",
|
|
1681
|
+
requestId,
|
|
1682
|
+
federatedIssuerUrl,
|
|
1683
|
+
expectedFederatedIssuer: rodit.metadata.subjectuniqueidentifier_url,
|
|
1684
|
+
});
|
|
1685
|
+
throw new Error("Error 005: Invalid federated issuer");
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
// 2) iss must be the login client's home API (from on-chain RODiT via sub)
|
|
1689
|
+
const loginClientRoditId = extractLoginPeerRoditIdFromSub(payload.sub);
|
|
1690
|
+
const loginClientRodit = loginClientRoditId
|
|
1691
|
+
? await nearorg_rpc_tokenfromroditid(loginClientRoditId)
|
|
1692
|
+
: null;
|
|
1693
|
+
const expectedClientIssuer =
|
|
1694
|
+
loginClientRodit?.metadata?.subjectuniqueidentifier_url;
|
|
1695
|
+
|
|
1696
|
+
if (
|
|
1697
|
+
!expectedClientIssuer ||
|
|
1698
|
+
normalizedTokenIssuer !==
|
|
1699
|
+
normalizeUrlWithoutPort(expectedClientIssuer)
|
|
1700
|
+
) {
|
|
1701
|
+
logger.warn("Token validation failed - Invalid issuer for federated JWT", {
|
|
1702
|
+
component: "JwtAuth",
|
|
1703
|
+
method: "validate_jwt_token_be",
|
|
1704
|
+
requestId,
|
|
1705
|
+
tokenIssuer: payload.iss,
|
|
1706
|
+
expectedClientIssuer,
|
|
1707
|
+
loginClientRoditId,
|
|
1708
|
+
});
|
|
1709
|
+
throw new Error("Error 005: Invalid issuer");
|
|
1710
|
+
}
|
|
1711
|
+
} else if (normalizedTokenIssuer !== normalizedOwnSubjectUrl) {
|
|
1712
|
+
// same-API / 9.12 tokens: iss must equal receiving server URL
|
|
1598
1713
|
logger.warn("Token validation failed - Invalid issuer", {
|
|
1599
1714
|
component: "JwtAuth",
|
|
1600
1715
|
method: "validate_jwt_token_be",
|
|
@@ -1602,15 +1717,16 @@ function resolveCredentialExpirationUnix(now, sessionExpiration, own_rodit) {
|
|
|
1602
1717
|
tokenIssuer: payload.iss,
|
|
1603
1718
|
expectedIssuer: rodit.metadata.subjectuniqueidentifier_url,
|
|
1604
1719
|
normalizedTokenIssuer,
|
|
1605
|
-
|
|
1720
|
+
normalizedOwnSubjectUrl,
|
|
1606
1721
|
roditId: rodit.token_id,
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
issuerHasProtocol: payload.iss?.startsWith(
|
|
1611
|
-
expectedHasProtocol:
|
|
1722
|
+
issuerHasTrailingSlash: payload.iss?.endsWith("/"),
|
|
1723
|
+
expectedHasTrailingSlash:
|
|
1724
|
+
rodit.metadata.subjectuniqueidentifier_url?.endsWith("/"),
|
|
1725
|
+
issuerHasProtocol: payload.iss?.startsWith("http"),
|
|
1726
|
+
expectedHasProtocol:
|
|
1727
|
+
rodit.metadata.subjectuniqueidentifier_url?.startsWith("http"),
|
|
1612
1728
|
});
|
|
1613
|
-
|
|
1729
|
+
|
|
1614
1730
|
throw new Error("Error 005: Invalid issuer");
|
|
1615
1731
|
}
|
|
1616
1732
|
|
|
@@ -2590,4 +2706,8 @@ module.exports = {
|
|
|
2590
2706
|
parseRoditJwtDurationSeconds,
|
|
2591
2707
|
resolveSessionExpirationUnix,
|
|
2592
2708
|
resolveCredentialExpirationUnix,
|
|
2709
|
+
normalizeUrlWithoutPort,
|
|
2710
|
+
isNonEmptyUrlClaim,
|
|
2711
|
+
isFederatedRoditLogin,
|
|
2712
|
+
validateFederatedLoginTarget,
|
|
2593
2713
|
};
|