@rodit/rodit-auth-be 9.16.0 → 9.17.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.
@@ -24,6 +24,11 @@ const {
24
24
  verify_peer_rodit,
25
25
  verify_rodit_ownership_withnep413
26
26
  } = require("../auth/authentication");
27
+ const {
28
+ normalizeOptionalLoginNonce,
29
+ buildLoginSigningMessageBytes,
30
+ createLoginTimestampChallenge,
31
+ } = require("../auth/login-nonce");
27
32
  const {
28
33
  nearorg_rpc_tokenfromroditid
29
34
  } = require("../blockchain/blockchainservice");
@@ -163,7 +168,16 @@ function buildLoginUrl(apiendpoint, loginPath = "/api/login") {
163
168
  async function resolveServerLoginTimestamp(apiendpoint, options = {}) {
164
169
  const explicit = parseRequiredServerLoginTimestamp(options.timestamp);
165
170
  if (explicit !== null) {
166
- return { timestamp: explicit };
171
+ const nonceResult = normalizeOptionalLoginNonce(options.nonce);
172
+ if (nonceResult.errorCode) {
173
+ return {
174
+ timestamp: null,
175
+ nonce: null,
176
+ errorCode: nonceResult.errorCode,
177
+ error: nonceResult.error,
178
+ };
179
+ }
180
+ return { timestamp: explicit, nonce: nonceResult.nonce };
167
181
  }
168
182
 
169
183
  const timestampPath =
@@ -183,6 +197,7 @@ async function resolveServerLoginTimestamp(apiendpoint, options = {}) {
183
197
  if (!response.ok) {
184
198
  return {
185
199
  timestamp: null,
200
+ nonce: null,
186
201
  errorCode: "LOGIN_TIMESTAMP_FETCH_FAILED",
187
202
  error: `Failed to fetch login timestamp challenge: HTTP ${response.status}`,
188
203
  };
@@ -193,15 +208,29 @@ async function resolveServerLoginTimestamp(apiendpoint, options = {}) {
193
208
  if (parsed === null) {
194
209
  return {
195
210
  timestamp: null,
211
+ nonce: null,
196
212
  errorCode: "INVALID_LOGIN_TIMESTAMP",
197
213
  error: "Login timestamp challenge response is missing a valid timestamp",
198
214
  };
199
215
  }
200
216
 
201
- return { timestamp: parsed };
217
+ const nonceResult = normalizeOptionalLoginNonce(
218
+ options.nonce !== undefined ? options.nonce : data?.nonce
219
+ );
220
+ if (nonceResult.errorCode) {
221
+ return {
222
+ timestamp: null,
223
+ nonce: null,
224
+ errorCode: nonceResult.errorCode,
225
+ error: nonceResult.error,
226
+ };
227
+ }
228
+
229
+ return { timestamp: parsed, nonce: nonceResult.nonce };
202
230
  } catch (error) {
203
231
  return {
204
232
  timestamp: null,
233
+ nonce: null,
205
234
  errorCode: "LOGIN_TIMESTAMP_FETCH_FAILED",
206
235
  error: `Failed to fetch login timestamp challenge: ${error.message}`,
207
236
  };
@@ -302,6 +331,30 @@ async function login_client(req, res) {
302
331
  const hasAccountId = accountid.length > 0;
303
332
  const peer_timestamp = parseRequiredLoginTimestamp(body.timestamp);
304
333
  const base64url_signature = extractLoginBase64UrlSignature(body);
334
+ const nonceResult = normalizeOptionalLoginNonce(body.nonce);
335
+ if (nonceResult.errorCode) {
336
+ const duration = Date.now() - startTime;
337
+ logger.metric("login_attempt_duration_ms", duration, {
338
+ component: "RoditAuth",
339
+ success: false,
340
+ result: "failure",
341
+ reason: "invalid_login_nonce",
342
+ error: nonceResult.errorCode,
343
+ });
344
+ logger.metric("failed_login_attempts_total", 1, {
345
+ component: "RoditAuth",
346
+ result: "failure",
347
+ reason: nonceResult.errorCode,
348
+ });
349
+ return respondLoginValidationFailure(res, {
350
+ silenceLoginFailures,
351
+ statusCode: 400,
352
+ requestId,
353
+ code: nonceResult.errorCode,
354
+ message: nonceResult.error,
355
+ });
356
+ }
357
+ const peer_nonce = nonceResult.nonce;
305
358
 
306
359
  logger.infoWithContext("Login request identifiers (sanitized)", {
307
360
  ...baseContext,
@@ -309,6 +362,7 @@ async function login_client(req, res) {
309
362
  accountid: accountid || undefined,
310
363
  login_mode: hasRoditId ? "roditid" : hasAccountId ? "accountid" : "none",
311
364
  timestamp: peer_timestamp,
365
+ has_nonce: !!peer_nonce,
312
366
  has_base64url_signature: base64url_signature.length > 0,
313
367
  });
314
368
 
@@ -449,6 +503,7 @@ async function login_client(req, res) {
449
503
  hasRoditId: roditid.length > 0,
450
504
  hasAccountId: accountid.length > 0,
451
505
  hasTimestamp: peer_timestamp !== undefined && peer_timestamp !== null,
506
+ hasNonce: !!peer_nonce,
452
507
  has_base64url_signature: base64url_signature.length > 0,
453
508
  });
454
509
 
@@ -503,7 +558,8 @@ async function login_client(req, res) {
503
558
  await resolve_peer_rodit_for_login(roditid, accountid),
504
559
  peer_roditid,
505
560
  peer_timestamp,
506
- base64url_signature
561
+ base64url_signature,
562
+ peer_nonce
507
563
  );
508
564
 
509
565
  const { peer_rodit, goodrodit: isRoditValid, failureReason, failureMessage } = result;
@@ -1287,6 +1343,191 @@ async function login_client(req, res) {
1287
1343
  }
1288
1344
  }
1289
1345
 
1346
+ /**
1347
+ * Optional client-initiated access-credential refresh.
1348
+ * Not required for default SERVER-INITIATED deployments: the server renews
1349
+ * access JWTs on authenticated API calls and returns them via New-Token.
1350
+ * Mount POST /api/refresh only for idle-client timers or CLIENT-INITIATED mode.
1351
+ *
1352
+ * @param {Object} req - Express request object
1353
+ * @param {Object} res - Express response object
1354
+ * @returns {Object} Response object
1355
+ */
1356
+ async function refresh_client(req, res) {
1357
+ const requestId = ulid();
1358
+ const startTime = Date.now();
1359
+ const baseContext = createLogContext(
1360
+ "AuthenticationService",
1361
+ "refresh_client",
1362
+ {
1363
+ requestId,
1364
+ path: req.path,
1365
+ method: req.method,
1366
+ ip: req.ip,
1367
+ }
1368
+ );
1369
+
1370
+ logger.infoWithContext("Token refresh request received", {
1371
+ ...baseContext,
1372
+ userAgent: req.get("User-Agent"),
1373
+ });
1374
+
1375
+ try {
1376
+ verifySessionManager();
1377
+
1378
+ const jwt_token =
1379
+ req.headers.authorization &&
1380
+ req.headers.authorization.startsWith("Bearer ")
1381
+ ? req.headers.authorization.substring(7)
1382
+ : null;
1383
+
1384
+ if (!jwt_token) {
1385
+ logger.metric &&
1386
+ logger.metric("token_refresh_attempts", 1, {
1387
+ component: "AuthenticationService",
1388
+ result: "no_jwt_token",
1389
+ });
1390
+ return sendError(res, {
1391
+ statusCode: 401,
1392
+ requestId,
1393
+ code: "MISSING_TOKEN",
1394
+ message: "No authentication jwt_token provided",
1395
+ });
1396
+ }
1397
+
1398
+ const isTokenInvalid = await sessionManager.isTokenInvalidated(jwt_token);
1399
+ if (isTokenInvalid) {
1400
+ const invalidationInfo =
1401
+ await sessionManager.getTokenInvalidationInfo(jwt_token);
1402
+ logger.metric &&
1403
+ logger.metric("token_refresh_attempts", 1, {
1404
+ component: "AuthenticationService",
1405
+ result: "invalidated_token",
1406
+ });
1407
+ return sendError(res, {
1408
+ statusCode: 401,
1409
+ requestId,
1410
+ code: "INVALIDATED_TOKEN",
1411
+ message: "Token has been invalidated",
1412
+ details: {
1413
+ reason: invalidationInfo?.reason || "session_inactive",
1414
+ invalidatedAt: invalidationInfo?.timestamp,
1415
+ },
1416
+ });
1417
+ }
1418
+
1419
+ const config_own_rodit = await stateManager.getConfigOwnRodit();
1420
+ if (!config_own_rodit || !config_own_rodit.own_rodit) {
1421
+ return sendError(res, {
1422
+ statusCode: 500,
1423
+ requestId,
1424
+ code: "SERVER_CONFIG_ERROR",
1425
+ message: "Server configuration not initialized",
1426
+ });
1427
+ }
1428
+
1429
+ let validationResult;
1430
+ try {
1431
+ validationResult = await validate_jwt_token_be(
1432
+ jwt_token,
1433
+ config_own_rodit.own_rodit,
1434
+ {
1435
+ allowExpiredToken: true,
1436
+ forceCredentialRenewal: true,
1437
+ }
1438
+ );
1439
+ } catch (validationError) {
1440
+ const message = validationError.message || "Token refresh failed";
1441
+ const sessionClosed =
1442
+ /Error 012/i.test(message) ||
1443
+ /Session is not active/i.test(message) ||
1444
+ validationError.renewalReason === "session_closed";
1445
+ logger.metric &&
1446
+ logger.metric("token_refresh_attempts", 1, {
1447
+ component: "AuthenticationService",
1448
+ result: sessionClosed ? "session_closed" : "validation_failed",
1449
+ });
1450
+ return sendError(res, {
1451
+ statusCode: sessionClosed ? 401 : 403,
1452
+ requestId,
1453
+ code: sessionClosed
1454
+ ? "INVALIDATED_TOKEN"
1455
+ : validationError.code || "REFRESH_FAILED",
1456
+ message,
1457
+ });
1458
+ }
1459
+
1460
+ const newToken = validationResult?.newToken;
1461
+ if (!newToken) {
1462
+ logger.metric &&
1463
+ logger.metric("token_refresh_attempts", 1, {
1464
+ component: "AuthenticationService",
1465
+ result: "no_new_token",
1466
+ });
1467
+ return sendError(res, {
1468
+ statusCode: 403,
1469
+ requestId,
1470
+ code: "REFRESH_FAILED",
1471
+ message: "Token refresh did not produce a new credential",
1472
+ });
1473
+ }
1474
+
1475
+ if (typeof res.setHeader === "function") {
1476
+ res.setHeader("New-Token", newToken);
1477
+ } else if (typeof res.set === "function") {
1478
+ res.set("New-Token", newToken);
1479
+ }
1480
+
1481
+ const duration = Date.now() - startTime;
1482
+ logger.infoWithContext("Token refresh completed", {
1483
+ ...baseContext,
1484
+ duration,
1485
+ success: true,
1486
+ });
1487
+ logger.metric &&
1488
+ logger.metric("token_refresh_attempts", 1, {
1489
+ component: "AuthenticationService",
1490
+ result: "success",
1491
+ });
1492
+ logger.metric &&
1493
+ logger.metric("token_refresh_duration_ms", duration, {
1494
+ component: "AuthenticationService",
1495
+ success: true,
1496
+ });
1497
+
1498
+ return res.json({
1499
+ token: newToken,
1500
+ jwt_token: newToken,
1501
+ message: "Token refreshed",
1502
+ requestId,
1503
+ });
1504
+ } catch (error) {
1505
+ const duration = Date.now() - startTime;
1506
+ logErrorWithMetrics(
1507
+ "Token refresh process failed",
1508
+ {
1509
+ ...baseContext,
1510
+ duration,
1511
+ },
1512
+ error,
1513
+ "refresh_error",
1514
+ { error_type: "general_refresh_error" }
1515
+ );
1516
+ logger.metric &&
1517
+ logger.metric("token_refresh_attempts", 1, {
1518
+ component: "AuthenticationService",
1519
+ result: "error",
1520
+ });
1521
+ return sendError(res, {
1522
+ statusCode: 500,
1523
+ requestId,
1524
+ code: "REFRESH_ERROR",
1525
+ message: "Internal server error during token refresh",
1526
+ details: !isStrictEnvironment() ? { error: error.message } : undefined,
1527
+ });
1528
+ }
1529
+ }
1530
+
1290
1531
  /**
1291
1532
  * Handle client login with NEP-413 standard
1292
1533
  *
@@ -1624,6 +1865,16 @@ async function login_portal(config_own_rodit, port, options = {}) {
1624
1865
  requestId
1625
1866
  };
1626
1867
  }
1868
+ const nonceResult = normalizeOptionalLoginNonce(options.nonce);
1869
+ if (nonceResult.errorCode) {
1870
+ return {
1871
+ error: nonceResult.error,
1872
+ errorCode: nonceResult.errorCode,
1873
+ failureReason: nonceResult.errorCode,
1874
+ requestId
1875
+ };
1876
+ }
1877
+ const nonce = nonceResult.nonce;
1627
1878
 
1628
1879
  const hasRoditId = roditid.length > 0;
1629
1880
  const hasAccountId = accountid.length > 0;
@@ -1638,8 +1889,10 @@ async function login_portal(config_own_rodit, port, options = {}) {
1638
1889
 
1639
1890
  const timeString = await unixTimeToDateString(timestamp);
1640
1891
  const signatureIdentifier = hasRoditId ? roditid : accountid;
1641
- const signatureIdentifierandtimestamp = new TextEncoder().encode(
1642
- signatureIdentifier + timeString
1892
+ const signatureIdentifierandtimestamp = buildLoginSigningMessageBytes(
1893
+ signatureIdentifier,
1894
+ timeString,
1895
+ nonce
1643
1896
  );
1644
1897
 
1645
1898
  logger.debug("Generating authentication signature", {
@@ -1649,6 +1902,7 @@ async function login_portal(config_own_rodit, port, options = {}) {
1649
1902
  roditId: roditid,
1650
1903
  accountId: accountid,
1651
1904
  timestamp,
1905
+ hasNonce: !!nonce,
1652
1906
  });
1653
1907
 
1654
1908
  // Create signature
@@ -1674,17 +1928,21 @@ async function login_portal(config_own_rodit, port, options = {}) {
1674
1928
  });
1675
1929
 
1676
1930
  try {
1931
+ const requestBody = {
1932
+ ...(hasRoditId ? { roditid } : {}),
1933
+ ...(hasAccountId ? { accountid } : {}),
1934
+ timestamp,
1935
+ roditid_base64url_signature,
1936
+ };
1937
+ if (nonce) {
1938
+ requestBody.nonce = nonce;
1939
+ }
1677
1940
  const response = await fetch(fetchUrl, {
1678
1941
  method: "POST",
1679
1942
  headers: {
1680
1943
  "Content-Type": "application/json",
1681
1944
  },
1682
- body: JSON.stringify({
1683
- ...(hasRoditId ? { roditid } : {}),
1684
- ...(hasAccountId ? { accountid } : {}),
1685
- timestamp,
1686
- roditid_base64url_signature,
1687
- }),
1945
+ body: JSON.stringify(requestBody),
1688
1946
  });
1689
1947
 
1690
1948
  if (!response.ok) {
@@ -1972,8 +2230,12 @@ async function login_portal(config_own_rodit, port, options = {}) {
1972
2230
  });
1973
2231
 
1974
2232
  const roditid = normalizeOptionalLoginString(own_rodit?.token_id);
1975
- const { timestamp, error: timestampError, errorCode: timestampErrorCode } =
1976
- await resolveServerLoginTimestamp(apiendpoint, options);
2233
+ const {
2234
+ timestamp,
2235
+ nonce,
2236
+ error: timestampError,
2237
+ errorCode: timestampErrorCode,
2238
+ } = await resolveServerLoginTimestamp(apiendpoint, options);
1977
2239
  const accountid = normalizeOptionalServerAccountId(options.accountId);
1978
2240
 
1979
2241
  if (timestamp === null) {
@@ -2005,13 +2267,16 @@ async function login_portal(config_own_rodit, port, options = {}) {
2005
2267
  roditId: roditid,
2006
2268
  accountId: accountid,
2007
2269
  timestamp,
2270
+ hasNonce: !!nonce,
2008
2271
  });
2009
2272
 
2010
2273
  const timeString = await unixTimeToDateString(timestamp);
2011
2274
 
2012
2275
  const signatureIdentifier = hasRoditId ? roditid : accountid;
2013
- const signatureIdentifierandtimestamp = new TextEncoder().encode(
2014
- signatureIdentifier + timeString
2276
+ const signatureIdentifierandtimestamp = buildLoginSigningMessageBytes(
2277
+ signatureIdentifier,
2278
+ timeString,
2279
+ nonce
2015
2280
  );
2016
2281
 
2017
2282
  logger.debug("Generating signature", {
@@ -2020,6 +2285,7 @@ async function login_portal(config_own_rodit, port, options = {}) {
2020
2285
  requestId,
2021
2286
  hasPrivateKey: !!config_own_rodit.own_rodit_bytes_private_key,
2022
2287
  signatureIdentifier,
2288
+ hasNonce: !!nonce,
2023
2289
  });
2024
2290
 
2025
2291
  const own_rodit_bytes_signature = nacl.sign.detached(
@@ -2044,6 +2310,10 @@ async function login_portal(config_own_rodit, port, options = {}) {
2044
2310
  requestBody.accountid = accountid;
2045
2311
  }
2046
2312
 
2313
+ if (nonce) {
2314
+ requestBody.nonce = nonce;
2315
+ }
2316
+
2047
2317
  logger.debug("Sending login request", {
2048
2318
  component: "AuthenticationService",
2049
2319
  method,
@@ -2051,6 +2321,7 @@ async function login_portal(config_own_rodit, port, options = {}) {
2051
2321
  roditid,
2052
2322
  accountId: accountid,
2053
2323
  timestamp,
2324
+ hasNonce: !!nonce,
2054
2325
  signatureLength: roditid_base64url_signature?.length,
2055
2326
  apiEndpoint: loginUrl,
2056
2327
  });
@@ -2379,4 +2650,15 @@ async function logout_server(jwt_token) {
2379
2650
 
2380
2651
 
2381
2652
  // Export the class directly (will be instantiated in rodit.js)
2382
- module.exports = {authenticate_apicall,authenticate_logout,login_server,login_portal,login_client,login_client_withnep413,logout_client,logout_server};
2653
+ module.exports = {
2654
+ authenticate_apicall,
2655
+ authenticate_logout,
2656
+ login_server,
2657
+ login_portal,
2658
+ login_client,
2659
+ login_client_withnep413,
2660
+ logout_client,
2661
+ logout_server,
2662
+ refresh_client,
2663
+ createLoginTimestampChallenge,
2664
+ };
@@ -121,6 +121,27 @@ function validateOutboundWebhookUrl(raw) {
121
121
  return parsed;
122
122
  }
123
123
 
124
+ /**
125
+ * Build the absolute outbound webhook URL.
126
+ * If `webhookUrl` is host-only (`host` or `host:port`), append `endpoint`.
127
+ * If it already includes a path, use that path as-is (do not append).
128
+ *
129
+ * @param {string} webhookUrl Peer `rodit_webhookurl` / `webhook_url` (scheme optional)
130
+ * @param {string} [endpoint='/webhook'] Path to append when none is present
131
+ * @returns {string} Absolute https URL
132
+ */
133
+ function formatOutboundWebhookTargetUrl(webhookUrl, endpoint = "/webhook") {
134
+ const cleanWebhookUrl = String(webhookUrl || "")
135
+ .replace(/^(https?:\/\/)/i, "")
136
+ .replace(/\/+$/, "");
137
+ const hasPath = cleanWebhookUrl.includes("/");
138
+ if (hasPath) {
139
+ return `https://${cleanWebhookUrl}`;
140
+ }
141
+ const normalizedEndpoint = `/${String(endpoint || "/webhook").replace(/^\/+/, "")}`;
142
+ return `https://${cleanWebhookUrl}${normalizedEndpoint}`;
143
+ }
144
+
124
145
  function ipv4ToInt(ip) {
125
146
  return ip.split(".").reduce((acc, oct) => ((acc << 8) + Number(oct)) >>> 0, 0) >>> 0;
126
147
  }
@@ -1208,13 +1229,9 @@ async function resolveOutboundWebhookSessionId(options = {}, req = null) {
1208
1229
  endpoint
1209
1230
  });
1210
1231
 
1211
- // Normalize base URL and endpoint so we always produce exactly one slash
1212
- // between host and path (e.g. https://host/hooks/wake).
1213
- const cleanWebhookUrl = webhookUrl
1214
- .replace(/^(https?:\/\/)/, "")
1215
- .replace(/\/+$/, "");
1216
- const normalizedEndpoint = `/${String(endpoint || "/webhook").replace(/^\/+/, "")}`;
1217
- const formattedWebhookUrl = `https://${cleanWebhookUrl}${normalizedEndpoint}`;
1232
+ // Append endpoint only when webhook_url has no path
1233
+ // (e.g. host:7443 → …/hooks/wake; host:7443/hooks/agent stays as-is).
1234
+ const formattedWebhookUrl = formatOutboundWebhookTargetUrl(webhookUrl, endpoint);
1218
1235
 
1219
1236
  // Outbound SSRF controls: reject userinfo / private / metadata destinations,
1220
1237
  // enforce peer webhook_cidr, and pin DNS for the request lifetime.
@@ -2101,6 +2118,7 @@ module.exports = {
2101
2118
 
2102
2119
  // Outbound SSRF / CIDR / DNS-pin helpers
2103
2120
  validateOutboundWebhookUrl,
2121
+ formatOutboundWebhookTargetUrl,
2104
2122
  resolveAndPinWebhookDestination,
2105
2123
  createPinnedDispatcher,
2106
2124
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rodit/rodit-auth-be",
3
- "version": "9.16.0",
3
+ "version": "9.17.0",
4
4
  "description": "RODiT-based authentication system for Express.js applications",
5
5
  "main": "index.js",
6
6
  "exports": {
@@ -229,10 +229,12 @@ const FALLBACK_DEFAULTS = {
229
229
  LAPSED_LIFETIME_PROPORTION_4RENEWAL_ELIGIBILITY: "0.80",
230
230
  THRESHOLD_VALIDATION_TYPE: "0.10",
231
231
  DURATIONRAMP: "0.85",
232
- // RODiT flow initiator behavior.
232
+ // Token renewal initiator (default: server handles renewals).
233
233
  // Options:
234
- // - "SERVER-INITIATED": server starts the flow
235
- // - "CLIENT-INITIATED": client starts the flow
234
+ // - "SERVER-INITIATED" (default): renew access JWT during authenticate and
235
+ // return New-Token. Clients normally do not need POST /api/refresh.
236
+ // - "CLIENT-INITIATED" (optional/advanced): skip piggyback renewal; client
237
+ // must call the optional POST /api/refresh endpoint.
236
238
  SERVERORCLIENT: "SERVER-INITIATED",
237
239
  // Login error behavior.
238
240
  // Options: