@rodit/rodit-auth-be 9.12.0 → 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');
@@ -1033,6 +1037,7 @@ class RoditClient {
1033
1037
  *
1034
1038
  * @param {Object} [lsoptions] - Optional settings
1035
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)
1036
1041
  * @returns {Promise<Object>} Login result with token
1037
1042
  */
1038
1043
  async login_server(lsoptions = {}) {
@@ -1043,7 +1048,10 @@ class RoditClient {
1043
1048
  component: 'RoditClient',
1044
1049
  method: 'login_server',
1045
1050
  requestId,
1046
- lsoptions: { loginPath: lsoptions.loginPath }
1051
+ lsoptions: {
1052
+ loginPath: lsoptions.loginPath,
1053
+ apiEndpoint: lsoptions.apiEndpoint,
1054
+ }
1047
1055
  });
1048
1056
 
1049
1057
  try {
@@ -1929,6 +1937,10 @@ module.exports = {
1929
1937
  logout_server,
1930
1938
  validate_jwt_token_be,
1931
1939
  generate_jwt_token,
1940
+ normalizeUrlWithoutPort,
1941
+ isNonEmptyUrlClaim,
1942
+ isFederatedRoditLogin,
1943
+ validateFederatedLoginTarget,
1932
1944
  validatepermissions,
1933
1945
  webhookHandler,
1934
1946
  // Webhook identity + session helpers (key travels with the webhook; trust is
@@ -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
  };
@@ -14,7 +14,8 @@ const nacl = require("tweetnacl");
14
14
  const {
15
15
  validate_jwt_token_be,
16
16
  generate_jwt_token,
17
- tokenService
17
+ validateFederatedLoginTarget,
18
+ normalizeUrlWithoutPort,
18
19
  } = require("../auth/tokenservice");
19
20
  // Import specific functions from authentication.js to avoid circular dependencies
20
21
  // Import specific functions from authentication.js to avoid circular dependencies
@@ -1879,6 +1880,7 @@ async function login_portal(config_own_rodit, port, options = {}) {
1879
1880
  * @param {Object} config_own_rodit - Configuration object containing own_rodit and private key
1880
1881
  * @param {Object} [options] - Optional settings
1881
1882
  * @param {string} [options.loginPath] - HTTP path (default /api/login)
1883
+ * @param {string} [options.apiEndpoint] - Federated API base URL to log into (defaults to own subjectuniqueidentifier_url)
1882
1884
  * @param {number} [options.timestamp] - Unix seconds used for signature generation (if omitted, fetched from peer /api/login/timestamp)
1883
1885
  * @param {string} [options.accountId] - Explicit NEAR account for outbound login when token id absent
1884
1886
  * @param {string} [options.timestampPath] - Timestamp endpoint path (default /api/login/timestamp)
@@ -1931,7 +1933,9 @@ async function login_portal(config_own_rodit, port, options = {}) {
1931
1933
  return { error: "Error 0111: Client configuration not initialized" };
1932
1934
  }
1933
1935
 
1934
- const apiendpoint = config_own_rodit.own_rodit?.metadata?.subjectuniqueidentifier_url;
1936
+ const apiendpoint =
1937
+ options.apiEndpoint ??
1938
+ config_own_rodit.own_rodit?.metadata?.subjectuniqueidentifier_url;
1935
1939
  const loginPath =
1936
1940
  options.loginPath ??
1937
1941
  config_own_rodit.login_rodit_path ??
@@ -1944,7 +1948,16 @@ async function login_portal(config_own_rodit, port, options = {}) {
1944
1948
  requestId,
1945
1949
  apiEndpoint: apiendpoint,
1946
1950
  loginUrl,
1947
- source: config_own_rodit.own_rodit?.metadata?.subjectuniqueidentifier_url ? "metadata" : "config",
1951
+ federatedLogin:
1952
+ normalizeUrlWithoutPort(apiendpoint) !==
1953
+ normalizeUrlWithoutPort(
1954
+ config_own_rodit.own_rodit?.metadata?.subjectuniqueidentifier_url
1955
+ ),
1956
+ source: options.apiEndpoint
1957
+ ? "options.apiEndpoint"
1958
+ : config_own_rodit.own_rodit?.metadata?.subjectuniqueidentifier_url
1959
+ ? "metadata"
1960
+ : "config",
1948
1961
  });
1949
1962
 
1950
1963
  const roditid = normalizeOptionalLoginString(own_rodit?.token_id);
@@ -2158,6 +2171,60 @@ 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);
2163
2230
  // Inbound webhooks are verified against the key each webhook advertises
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rodit/rodit-auth-be",
3
- "version": "9.12.0",
3
+ "version": "9.13.0",
4
4
  "description": "RODiT-based authentication system for Express.js applications",
5
5
  "main": "index.js",
6
6
  "exports": {