@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/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, { endpoint });
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, { endpoint: '/webhook' });
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: { loginPath: lsoptions.loginPath }
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: fromJwt.createdAt ?? nowSec,
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 requestId = ulid();
1087
- const startTime = Date.now();
1088
- const baseContext = createLogContext("SessionManager", "findSessionsByRoditId", { requestId, roditId });
1089
-
1090
-
1209
+ const baseContext = createLogContext("SessionManager", "findSessionsByRoditId", { roditId });
1210
+
1091
1211
  try {
1092
- let result = [];
1093
1212
  // Get sessions from storage
1094
1213
  let allSessions = [];
1095
1214
  if (typeof this.storage.getAll === 'function') {
@@ -1101,16 +1220,12 @@ class SessionManager {
1101
1220
  if (s) allSessions.push(s);
1102
1221
  }
1103
1222
  }
1104
- result = allSessions.filter(s => s && s.roditId === roditId);
1105
-
1106
- const duration = Date.now() - startTime;
1107
-
1108
-
1109
- return result;
1223
+ return allSessions.filter(s => s && s.roditId === roditId);
1110
1224
  } catch (error) {
1111
- const duration = Date.now() - startTime;
1112
-
1113
-
1225
+ logger.debugWithContext("findSessionsByRoditId lookup failed", {
1226
+ ...baseContext,
1227
+ error: error.message
1228
+ });
1114
1229
  return [];
1115
1230
  }
1116
1231
  }
@@ -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
- normalizedExpectedIssuer,
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
- rawIssuerMatch: payload.iss === rodit.metadata.subjectuniqueidentifier_url,
1593
- normalizedIssuerMatch: normalizedTokenIssuer === normalizedExpectedIssuer
1670
+ isFederatedJwt,
1594
1671
  });
1595
-
1596
- // Compare normalized URLs instead of raw URLs
1597
- if (normalizedTokenIssuer !== normalizedExpectedIssuer) {
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
- normalizedExpectedIssuer,
1720
+ normalizedOwnSubjectUrl,
1606
1721
  roditId: rodit.token_id,
1607
- // Check for common URL variations that might cause mismatch
1608
- issuerHasTrailingSlash: payload.iss?.endsWith('/'),
1609
- expectedHasTrailingSlash: rodit.metadata.subjectuniqueidentifier_url?.endsWith('/'),
1610
- issuerHasProtocol: payload.iss?.startsWith('http'),
1611
- expectedHasProtocol: rodit.metadata.subjectuniqueidentifier_url?.startsWith('http')
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
  };