@rodit/rodit-auth-be 9.15.0 → 9.16.2

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 CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  All notable changes to `@rodit/rodit-auth-be` are documented here.
4
4
 
5
+ ## [9.16.2] — 2026-09-19
6
+
7
+ ### Fixed
8
+
9
+ - **`send_webhook` URL join:** append `endpoint` (e.g. `/hooks/wake`) only when
10
+ peer `webhook_url` / `rodit_webhookurl` has no path. A URL that already
11
+ includes a path (e.g. `host:7443/hooks/agent`) is used as-is, so paths are
12
+ no longer doubled.
13
+
14
+ ## [9.16.1] — 2026-09-18
15
+
16
+ ### Changed
17
+
18
+ - **Docs:** Clarify that peer `webhook_url` / `rodit_webhookurl` is a base
19
+ (`host` or `host:port` only). The SDK appends the endpoint path; including
20
+ `/hooks/wake` or `/hooks/agent` in the metadata doubles the path.
21
+
22
+ ## [9.16.0] — 2026-09-14
23
+
24
+ ### Removed
25
+
26
+ - **`verify_rodit_isactive`** — DNS TXT revocation check
27
+ (`<token_id>.revoked.<domain>`). The function short-circuited with an
28
+ unconditional `return true` while debugging, so its body was unreachable and
29
+ no RODiT could be rejected as revoked. Removed along with its call sites in
30
+ `verify_peer_rodit` and `thorough_validate_jwt_token_be`, and the
31
+ `RODIT_REVOKED` / `SERVER_RODIT_REVOKED` failure codes they emitted.
32
+ No behavior change; the SDK now has no revocation check.
33
+ `verify_rodit_isactive_fe` in `@rodit/rodit-auth-fe` is unaffected.
34
+
5
35
  ## [9.15.0] — 2026-08-11
6
36
 
7
37
  ### Added
package/README.md CHANGED
@@ -1,10 +1,22 @@
1
- # RODiT Authentication SDK
1
+ # @rodit/rodit-auth-be
2
2
 
3
- A comprehensive Node.js SDK for implementing RODiT-based mutual authentication, authorization, self-configuration, and session management in Express.js applications.
3
+ Node.js / Express SDK for RODiT-based mutual authentication, authorization, session management, and webhooks.
4
4
 
5
- **Version:** 9.15.0
6
- **License:** Proprietary
7
- **Author:** Discernible IO
5
+ **npm:** [https://www.npmjs.com/package/@rodit/rodit-auth-be](https://www.npmjs.com/package/@rodit/rodit-auth-be)
6
+ **Monorepo:** [discernible-io/rodit-sdk](https://github.com/discernible-io/rodit-sdk) (this directory)
7
+ **Version:** 9.16.2 · **License:** Proprietary · **Author:** Discernible IO
8
+
9
+ ## Production deployments
10
+
11
+ This package is the server runtime behind RODiT-powered APIs, including:
12
+
13
+ | Deployment | Role |
14
+ |------------|------|
15
+ | [api.identyclaw.com](https://api.identyclaw.com) | IdentyClaw platform API ([source: api-idc](https://github.com/discernible-io/api-idc)) |
16
+ | [api.lastcradle.io](https://api.lastcradle.io) | Last Cradle family API |
17
+ | Custom / federated APIs | Same SDK; sibling APIs in one token family support [federated login](#federated-login-same-family-different-api-url) |
18
+
19
+ Browser and wallet clients for these APIs use the companion package [`@rodit/rodit-auth-fe`](https://www.npmjs.com/package/@rodit/rodit-auth-fe).
8
20
 
9
21
  **Login `POST` /api/login:** Use **`accountid`** (or **`roditid`**), **`timestamp`**, and **`base64url_signature`**. Sign UTF-8 bytes of `identifier + timestamp_iso`, and reject deprecated keys such as **`signature`** and **`account_id`**. See [CHANGELOG.md](./CHANGELOG.md).
10
22
 
@@ -1185,7 +1197,7 @@ const jwtDuration = metadata.jwt_duration; // JWT expiration time
1185
1197
  const maxRequests = metadata.max_requests; // Rate limit
1186
1198
  const maxRqWindow = metadata.maxrq_window; // Rate limit window
1187
1199
  const apiEndpoint = metadata.subjectuniqueidentifier_url; // API URL
1188
- const webhookUrl = metadata.webhook_url; // Webhook endpoint
1200
+ const webhookUrl = metadata.webhook_url; // Webhook host[:port] (optional path)
1189
1201
  // Parse permissioned routes
1190
1202
  const permissionedRoutes = JSON.parse(metadata.permissioned_routes || '{}')
1191
1203
  // Use SDK config for application settings
@@ -1530,12 +1542,12 @@ Peer RODiT metadata:
1530
1542
 
1531
1543
  ```json
1532
1544
  {
1533
- "webhook_url": "hooks.example.com",
1545
+ "webhook_url": "hooks.example.com:7443",
1534
1546
  "webhook_cidr": "0.0.0.0/0"
1535
1547
  }
1536
1548
  ```
1537
1549
 
1538
- - `webhook_url` — host (optional path); SDK POSTs `https://{webhook_url}{endpoint}`
1550
+ - `webhook_url` — `host` or `host:port` (scheme optional), optionally with a path. If **no path** is present, the SDK appends `endpoint` (e.g. `https://hooks.example.com:7443/hooks/wake`). If a path is already present (e.g. `…:7443/hooks/agent`), that path is used as-is and nothing is appended.
1539
1551
  - `webhook_cidr` — allowlist for the **resolved** destination IP (`0.0.0.0/0` / empty = any public IP still subject to the SSRF blocklist)
1540
1552
 
1541
1553
  Outbound rejection codes (returned as `{ isValid: false, error: { code, message, requestId } }`):
@@ -2378,6 +2390,10 @@ const webhookHandler = roditClient.getWebhookHandler()
2378
2390
 
2379
2391
  Send a webhook notification to the peer URL in `req.user.rodit_webhookurl`.
2380
2392
 
2393
+ `rodit_webhookurl` is typically a base (`host` or `host:port`). When it has
2394
+ no path, the SDK appends `options.endpoint` (default `/webhook`). When a path
2395
+ is already present, that path is used as-is (endpoint is not appended).
2396
+
2381
2397
  Outbound delivery rejects private / loopback / metadata destinations (and
2382
2398
  resolved addresses), enforces peer JWT `rodit_webhookcidr` when set, and pins
2383
2399
  DNS for the request. `SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY` still applies
@@ -2473,7 +2489,7 @@ When you call `roditClient.getConfigOwnRodit()`, you get access to these metadat
2473
2489
  | `subjectuniqueidentifier_url` | string | Primary API service endpoint |
2474
2490
  | `userselected_dn` | string | User-selected display name |
2475
2491
  | `webhook_cidr` | string | Allowlist CIDR(s) for outbound webhook resolved IPs (`0.0.0.0/0` = any public IP; private ranges are still blocked by SSRF controls) |
2476
- | `webhook_url` | string | Peer webhook host used as `rodit_webhookurl` in issued JWTs |
2492
+ | `webhook_url` | string | Peer webhook host (`host` or `host:port`, optional path). Used as `rodit_webhookurl` in issued JWTs; the SDK appends the endpoint only when no path is present |
2477
2493
 
2478
2494
  ## Best Practices
2479
2495
 
package/index.js CHANGED
@@ -21,7 +21,8 @@ const {
21
21
  login_client_withnep413,
22
22
  login_portal,
23
23
  login_server,
24
- logout_server
24
+ logout_server,
25
+ createLoginTimestampChallenge,
25
26
  } = require('./lib/middleware/authenticationmw');
26
27
 
27
28
  const {
@@ -1137,7 +1138,13 @@ class RoditClient {
1137
1138
  errorMessage += '\n→ [CLIENT REJECTED] The RODiT is missing required metadata. The RODiT may be corrupted or incomplete.';
1138
1139
  break;
1139
1140
  case 'LOGIN_BASE64URL_SIGNATURE_INVALID':
1140
- errorMessage += '\n→ [CLIENT REJECTED] The base64url login signature did not verify. Sign UTF-8 (roditid or accountid + canonical timestamp_iso) with the correct NEAR account private key; encoding must be base64url.';
1141
+ errorMessage += '\n→ [CLIENT REJECTED] The base64url login signature did not verify. Sign UTF-8 (roditid or accountid + canonical timestamp_iso[, + nonce when present]) with the correct NEAR account private key; encoding must be base64url.';
1142
+ break;
1143
+ case 'LOGIN_NONCE_REPLAY':
1144
+ errorMessage += '\n→ [CLIENT REJECTED] The login nonce was already used. Fetch a fresh challenge from GET /api/login/timestamp and sign again.';
1145
+ break;
1146
+ case 'INVALID_LOGIN_NONCE':
1147
+ errorMessage += '\n→ [CLIENT REJECTED] The login nonce is malformed. Use the nonce string from GET /api/login/timestamp (base64url or hex).';
1141
1148
  break;
1142
1149
  case 'RODIT_FAMILY_MISMATCH':
1143
1150
  errorMessage += '\n→ [CLIENT REJECTED] Your RODiT does not belong to the same family as the server. You may need a different RODiT.';
@@ -1145,9 +1152,6 @@ class RoditClient {
1145
1152
  case 'RODIT_NOT_LIVE':
1146
1153
  errorMessage += '\n→ [CLIENT REJECTED] Your RODiT is expired or not yet valid. Check the validity period.';
1147
1154
  break;
1148
- case 'RODIT_REVOKED':
1149
- errorMessage += '\n→ [CLIENT REJECTED] Your RODiT has been revoked and is no longer valid.';
1150
- break;
1151
1155
  case 'SMART_CONTRACT_NOT_TRUSTED':
1152
1156
  errorMessage += '\n→ [CLIENT REJECTED] The smart contract that issued your RODiT is not trusted by this server.';
1153
1157
  break;
@@ -1160,9 +1164,6 @@ class RoditClient {
1160
1164
  case 'SERVER_RODIT_NOT_LIVE':
1161
1165
  errorMessage += '\n→ [SERVER REJECTED] The server\'s RODiT is expired or not yet valid. Contact the server administrator.';
1162
1166
  break;
1163
- case 'SERVER_RODIT_REVOKED':
1164
- errorMessage += '\n→ [SERVER REJECTED] The server\'s RODiT has been revoked. Contact the server administrator.';
1165
- break;
1166
1167
  case 'SERVER_SMART_CONTRACT_NOT_TRUSTED':
1167
1168
  errorMessage += '\n→ [SERVER REJECTED] The server\'s issuing smart contract is not trusted by your client. Update your trust configuration.';
1168
1169
  break;
@@ -1944,6 +1945,7 @@ module.exports = {
1944
1945
  login_portal,
1945
1946
  login_server,
1946
1947
  logout_server,
1948
+ createLoginTimestampChallenge,
1947
1949
  validate_jwt_token_be,
1948
1950
  generate_jwt_token,
1949
1951
  normalizeUrlWithoutPort,
@@ -23,12 +23,17 @@ const {
23
23
  PayloadNEP413Schema,
24
24
  CONSTANTS,
25
25
  } = require("../blockchain/blockchainservice");
26
+ const {
27
+ buildLoginSigningMessageBytes,
28
+ tryConsumeLoginNonce,
29
+ } = require("./login-nonce");
26
30
 
27
31
  async function verify_rodit_ownership(
28
32
  peerroditid,
29
33
  peertimestamp,
30
34
  peerroditid_base64url_signature,
31
- peer_rodit
35
+ peer_rodit,
36
+ peerNonce = null
32
37
  ) {
33
38
  const requestId = ulid();
34
39
  const startTime = Date.now();
@@ -41,6 +46,7 @@ async function verify_rodit_ownership(
41
46
  requestId,
42
47
  peerRoditId: peerroditid,
43
48
  timestamp: peertimestamp,
49
+ hasNonce: !!peerNonce,
44
50
  }
45
51
  );
46
52
 
@@ -56,16 +62,21 @@ async function verify_rodit_ownership(
56
62
  // DO NOT DELETE THE FOLLOWING COMMENT
57
63
  /* Maybe for NEP413 compatibility, the following line added "NEAR" before peerroditid */
58
64
 
59
- // Match legacy implementation exactly
65
+ // Legacy: identifier + timestamp_iso. With nonce: identifier + timestamp_iso + nonce.
60
66
  const timeString = await unixTimeToDateString(peertimestamp);
61
- const roditidandtimestamp = new TextEncoder().encode(
62
- peerroditid + timeString
67
+ const roditidandtimestamp = buildLoginSigningMessageBytes(
68
+ peerroditid,
69
+ timeString,
70
+ peerNonce
63
71
  );
72
+ const combinedString = peerNonce
73
+ ? peerroditid + timeString + peerNonce
74
+ : peerroditid + timeString;
64
75
 
65
76
  logger.debugWithContext("Encoded roditid and timestamp", {
66
77
  ...baseContext,
67
78
  timeString,
68
- combinedString: peerroditid + timeString,
79
+ combinedString,
69
80
  bufferLength: roditidandtimestamp.length,
70
81
  bufferHex: Buffer.from(roditidandtimestamp).toString('hex'),
71
82
  });
@@ -128,7 +139,7 @@ async function verify_rodit_ownership(
128
139
  logger.debugWithContext("Verification inputs", {
129
140
  ...baseContext,
130
141
  messageLength: roditidandtimestamp.length,
131
- messageContent: peerroditid + timeString,
142
+ messageContent: combinedString,
132
143
  signatureLength: bytes_ed25519_signature.length,
133
144
  publicKeyLength: peer_bytes_ed25519_public_key?.length,
134
145
  messageHex: Buffer.from(roditidandtimestamp).toString('hex'),
@@ -795,12 +806,14 @@ async function verify_rodit_ownership(
795
806
  * @param {string} peerroditid - Identifier string used in verify_rodit_ownership (client-signed prefix)
796
807
  * @param {number} peertimestamp - Unix seconds
797
808
  * @param {string} peerroditid_base64url_signature - base64url Ed25519 signature
809
+ * @param {string|null} [peerNonce=null] - Optional login challenge nonce; when set, bound into signature and single-use
798
810
  */
799
811
  async function verify_peer_rodit(
800
812
  peer_rodit,
801
813
  peerroditid,
802
814
  peertimestamp,
803
- peerroditid_base64url_signature
815
+ peerroditid_base64url_signature,
816
+ peerNonce = null
804
817
  ) {
805
818
  const requestId = ulid();
806
819
  const startTime = Date.now();
@@ -813,6 +826,7 @@ async function verify_rodit_ownership(
813
826
  requestId,
814
827
  peerRoditId: peerroditid,
815
828
  timestamp: peertimestamp,
829
+ hasNonce: !!peerNonce,
816
830
  signatureLength: peerroditid_base64url_signature?.length,
817
831
  hasOwnRodit: !!config_own_rodit,
818
832
  ownRoditId: config_own_rodit?.token_id,
@@ -895,7 +909,8 @@ async function verify_rodit_ownership(
895
909
  peerroditid,
896
910
  peertimestamp,
897
911
  peerroditid_base64url_signature,
898
- peer_rodit
912
+ peer_rodit,
913
+ peerNonce
899
914
  );
900
915
  const ownershipDuration = Date.now() - ownershipStart;
901
916
 
@@ -910,12 +925,32 @@ async function verify_rodit_ownership(
910
925
  requestId,
911
926
  roditId: peerroditid,
912
927
  });
928
+ const nonceHint = peerNonce
929
+ ? " When a nonce is present, the signed payload is identifier + timestamp_iso + nonce from the same login challenge."
930
+ : "";
913
931
  return {
914
932
  peer_rodit,
915
933
  goodrodit: false,
916
934
  failureReason: "LOGIN_BASE64URL_SIGNATURE_INVALID",
917
935
  failureMessage:
918
- "Login base64url signature invalid: Ed25519 verification failed for the base64url_signature over UTF-8 (roditid or accountid) + canonical timestamp_iso from the login challenge (GET /api/login/timestamp). Wrong key, wrong payload, or wrong encoding (must be base64url, not standard base64)."
936
+ "Login base64url signature invalid: Ed25519 verification failed for the base64url_signature over UTF-8 (roditid or accountid) + canonical timestamp_iso from the login challenge (GET /api/login/timestamp). Wrong key, wrong payload, or wrong encoding (must be base64url, not standard base64)." +
937
+ nonceHint
938
+ };
939
+ }
940
+
941
+ if (peerNonce && !tryConsumeLoginNonce(peerroditid, peerNonce)) {
942
+ logger.warn("Login nonce replay rejected", {
943
+ component: "RoditAuth",
944
+ method: "verify_peer_rodit",
945
+ requestId,
946
+ roditId: peerroditid,
947
+ });
948
+ return {
949
+ peer_rodit,
950
+ goodrodit: false,
951
+ failureReason: "LOGIN_NONCE_REPLAY",
952
+ failureMessage:
953
+ "Login nonce already used: fetch a fresh challenge from GET /api/login/timestamp and sign again. Nonces are single-use when provided."
919
954
  };
920
955
  }
921
956
 
@@ -993,32 +1028,6 @@ async function verify_rodit_ownership(
993
1028
  };
994
1029
  }
995
1030
 
996
- const activeStart = Date.now();
997
- const isActive = await verify_rodit_isactive(
998
- peer_rodit.token_id,
999
- config_own_rodit.own_rodit.metadata.subjectuniqueidentifier_url
1000
- );
1001
- const activeDuration = Date.now() - activeStart;
1002
-
1003
- logger.debug("Active verification completed", {
1004
- requestId,
1005
- activeDuration,
1006
- isActive,
1007
- });
1008
-
1009
- if (!isActive) {
1010
- logger.warn("RODiT active verification failed", {
1011
- requestId,
1012
- roditId: peerroditid,
1013
- });
1014
- return {
1015
- peer_rodit,
1016
- goodrodit: false,
1017
- failureReason: "RODIT_REVOKED",
1018
- failureMessage: "RODiT has been revoked"
1019
- };
1020
- }
1021
-
1022
1031
  const trustedStart = Date.now();
1023
1032
  const isTrusted = await verify_rodit_istrusted_issuingsmartcontract(
1024
1033
  config_own_rodit.own_rodit.metadata.subjectuniqueidentifier_url
@@ -1241,119 +1250,6 @@ async function verify_rodit_ownership(
1241
1250
  }
1242
1251
  }
1243
1252
 
1244
- async function verify_rodit_isactive(tokenId, ownsubjectuniqueidentifier_url) {
1245
- const requestId = ulid();
1246
- const startTime = Date.now();
1247
-
1248
- // WHILE DEBUGGING TEMPORARY FIX DO NOT REMOVE THIS LINE EVER WITHOUT PERMISSION
1249
- return true;
1250
-
1251
- logger.debug("Checking RODiT activity status", {
1252
- component: "RoditAuth",
1253
- method: "verify_rodit_isactive",
1254
- requestId,
1255
- tokenId,
1256
- subjectUrl: ownsubjectuniqueidentifier_url,
1257
- });
1258
-
1259
- const domainandextensionRegex =
1260
- /(?:https?:\/\/)?(?:www\.)?([a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)/i;
1261
-
1262
- const match = ownsubjectuniqueidentifier_url.match(domainandextensionRegex);
1263
-
1264
- if (match) {
1265
- const domainandextension = match[1];
1266
- const revokingDnsEntry = `${tokenId}.revoked.${domainandextension}`;
1267
-
1268
- logger.debug("Checking DNS revocation entry", {
1269
- requestId,
1270
- domain: domainandextension,
1271
- revokingDnsEntry,
1272
- });
1273
-
1274
- try {
1275
- const dnsStart = Date.now();
1276
- const resolver = new Resolver();
1277
- await resolver.resolveTxt(revokingDnsEntry);
1278
- const dnsDuration = Date.now() - dnsStart;
1279
- const totalDuration = Date.now() - startTime;
1280
-
1281
- logger.info("RODiT revocation found", {
1282
- component: "AuthServices",
1283
- method: "verify_rodit_isactive",
1284
- requestId,
1285
- duration: totalDuration,
1286
- dnsDuration,
1287
- tokenId,
1288
- domain: domainandextension,
1289
- revokingDnsEntry,
1290
- isActive: false,
1291
- });
1292
-
1293
- // Add metrics for revoked tokens
1294
- logger.metric &&
1295
- logger.metric("rodit_revocation_checks", totalDuration, {
1296
- result: "revoked",
1297
- token_id: tokenId,
1298
- });
1299
-
1300
- return false;
1301
- } catch (error) {
1302
- // DNS error usually means no revocation entry found, which is good
1303
- const dnsDuration = Date.now() - dnsStart || 0;
1304
- const totalDuration = Date.now() - startTime;
1305
-
1306
- logger.debug("No revocation found for RODiT", {
1307
- requestId,
1308
- dnsDuration,
1309
- tokenId,
1310
- error: error.code,
1311
- });
1312
-
1313
- logger.info("RODiT is active", {
1314
- component: "AuthServices",
1315
- method: "verify_rodit_isactive",
1316
- requestId,
1317
- duration: totalDuration,
1318
- dnsDuration,
1319
- tokenId,
1320
- domain: domainandextension,
1321
- isActive: true,
1322
- });
1323
-
1324
- // Add metrics for active tokens
1325
- logger.metric &&
1326
- logger.metric("rodit_revocation_checks", totalDuration, {
1327
- result: "active",
1328
- token_id: tokenId,
1329
- });
1330
-
1331
- return true;
1332
- }
1333
- } else {
1334
- const duration = Date.now() - startTime;
1335
-
1336
- logger.warn("Unable to parse domain from URL", {
1337
- component: "AuthServices",
1338
- method: "verify_rodit_isactive",
1339
- requestId,
1340
- duration,
1341
- tokenId,
1342
- subjectUrl: ownsubjectuniqueidentifier_url,
1343
- });
1344
-
1345
- // Add metrics for parsing errors
1346
- logger.metric &&
1347
- logger.metric("rodit_revocation_checks", duration, {
1348
- result: "parse_error",
1349
- token_id: tokenId,
1350
- });
1351
-
1352
- // Default to allowing the token if domain parsing fails
1353
- return true;
1354
- }
1355
- }
1356
-
1357
1253
  async function verify_rodit_istrusted_issuingsmartcontract(
1358
1254
  ownsubjectuniqueidentifier_url
1359
1255
  ) {
@@ -1963,9 +1859,8 @@ module.exports = {
1963
1859
  resolve_peer_rodit_for_login,
1964
1860
  verify_peer_rodit,
1965
1861
  validateTimestamp,
1966
- verify_rodit_isactive,
1967
1862
  verify_rodit_isamatch,
1968
1863
  verify_rodit_islive,
1969
1864
  verify_rodit_istrusted_issuingsmartcontract,
1970
- authenticate_webhook
1865
+ authenticate_webhook,
1971
1866
  };
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Login challenge nonce helpers (optional additive field for POST /api/login).
3
+ * When a nonce is present it is bound into the signed payload and single-use.
4
+ * Copyright (c) 2026 Discernible IO. All rights reserved.
5
+ */
6
+
7
+ const crypto = require("crypto");
8
+ const config = require("../../services/configsdk");
9
+ const { unixTimeToDateString } = require("../../services/utils");
10
+
11
+ const LOGIN_NONCE_BYTES = 16;
12
+ const LOGIN_NONCE_MIN_LEN = 8;
13
+ const LOGIN_NONCE_MAX_LEN = 128;
14
+
15
+ /** @type {Map<string, { expiresAtMs: number, firstSeenAtMs: number }>} */
16
+ const loginNonceReplayCache = new Map();
17
+
18
+ /**
19
+ * Cryptographic login nonce for challenges (base64url, 128-bit).
20
+ * @returns {string}
21
+ */
22
+ function generateLoginNonce() {
23
+ return crypto.randomBytes(LOGIN_NONCE_BYTES).toString("base64url");
24
+ }
25
+
26
+ /**
27
+ * Normalize optional login nonce from a request/challenge body.
28
+ * Missing/empty → null (legacy timestamp-only login). Invalid → { error }.
29
+ *
30
+ * @param {*} rawNonce
31
+ * @returns {{ nonce: string|null, error?: string, errorCode?: string }}
32
+ */
33
+ function normalizeOptionalLoginNonce(rawNonce) {
34
+ if (rawNonce === undefined || rawNonce === null) {
35
+ return { nonce: null };
36
+ }
37
+ if (typeof rawNonce !== "string") {
38
+ return {
39
+ nonce: null,
40
+ errorCode: "INVALID_LOGIN_NONCE",
41
+ error: "Login nonce must be a string when provided",
42
+ };
43
+ }
44
+ const trimmed = rawNonce.trim();
45
+ if (trimmed.length === 0) {
46
+ return { nonce: null };
47
+ }
48
+ if (
49
+ trimmed.length < LOGIN_NONCE_MIN_LEN ||
50
+ trimmed.length > LOGIN_NONCE_MAX_LEN
51
+ ) {
52
+ return {
53
+ nonce: null,
54
+ errorCode: "INVALID_LOGIN_NONCE",
55
+ error: `Login nonce length must be between ${LOGIN_NONCE_MIN_LEN} and ${LOGIN_NONCE_MAX_LEN} characters`,
56
+ };
57
+ }
58
+ // Reject whitespace / control characters inside the token.
59
+ if (!/^[A-Za-z0-9_-]+$/.test(trimmed)) {
60
+ return {
61
+ nonce: null,
62
+ errorCode: "INVALID_LOGIN_NONCE",
63
+ error:
64
+ "Login nonce must be base64url or hex (letters, digits, '-', '_' only)",
65
+ };
66
+ }
67
+ return { nonce: trimmed };
68
+ }
69
+
70
+ /**
71
+ * Build UTF-8 bytes for login signature.
72
+ * Legacy: identifier + timestamp_iso
73
+ * With nonce: identifier + timestamp_iso + nonce
74
+ *
75
+ * @param {string} identifier
76
+ * @param {string} timeString - ISO timestamp string
77
+ * @param {string|null|undefined} nonce
78
+ * @returns {Uint8Array}
79
+ */
80
+ function buildLoginSigningMessageBytes(identifier, timeString, nonce) {
81
+ const payload =
82
+ nonce && String(nonce).length > 0
83
+ ? String(identifier) + String(timeString) + String(nonce)
84
+ : String(identifier) + String(timeString);
85
+ return new TextEncoder().encode(payload);
86
+ }
87
+
88
+ function getLoginNonceReplayTtlMs() {
89
+ const maxAgeSeconds = Number(
90
+ config.get("API_DEFAULT_OPTIONS.TIMESTAMP_MAX_AGE", 300)
91
+ );
92
+ const seconds =
93
+ Number.isFinite(maxAgeSeconds) && maxAgeSeconds > 0 ? maxAgeSeconds : 300;
94
+ return seconds * 1000;
95
+ }
96
+
97
+ function cleanupExpiredLoginNonceEntries(nowMs = Date.now()) {
98
+ for (const [key, entry] of loginNonceReplayCache.entries()) {
99
+ if (!entry || entry.expiresAtMs <= nowMs) {
100
+ loginNonceReplayCache.delete(key);
101
+ }
102
+ }
103
+ }
104
+
105
+ function buildLoginNonceReplayKey(nonce) {
106
+ return String(nonce);
107
+ }
108
+
109
+ /**
110
+ * Atomically consume a login nonce. Returns false if already used.
111
+ * Call only after the signature that binds the nonce has verified.
112
+ *
113
+ * @param {string} identifier - roditid or accountid (reserved for future scoped caches / logging)
114
+ * @param {string} nonce
115
+ * @returns {boolean} true if consumed (first use), false if replay
116
+ */
117
+ function tryConsumeLoginNonce(identifier, nonce) {
118
+ if (!nonce) {
119
+ return true;
120
+ }
121
+ const nowMs = Date.now();
122
+ cleanupExpiredLoginNonceEntries(nowMs);
123
+ const key = buildLoginNonceReplayKey(nonce);
124
+ if (loginNonceReplayCache.has(key)) {
125
+ return false;
126
+ }
127
+ loginNonceReplayCache.set(key, {
128
+ expiresAtMs: nowMs + getLoginNonceReplayTtlMs(),
129
+ firstSeenAtMs: nowMs,
130
+ identifier: identifier ? String(identifier) : undefined,
131
+ });
132
+ return true;
133
+ }
134
+
135
+ /**
136
+ * Host helper for GET /api/login/timestamp — issues timestamp + CSPRNG nonce.
137
+ * Clients that understand nonce will bind it into the signature; older clients ignore it.
138
+ *
139
+ * @returns {Promise<{ timestamp: number, timestamp_iso: string, nonce: string }>}
140
+ */
141
+ async function createLoginTimestampChallenge() {
142
+ const timestamp = Math.floor(Date.now() / 1000);
143
+ const timestamp_iso = await unixTimeToDateString(timestamp);
144
+ return {
145
+ timestamp,
146
+ timestamp_iso,
147
+ nonce: generateLoginNonce(),
148
+ };
149
+ }
150
+
151
+ /** Test/support: clear replay cache (does not affect production callers). */
152
+ function clearLoginNonceReplayCacheForTests() {
153
+ loginNonceReplayCache.clear();
154
+ }
155
+
156
+ module.exports = {
157
+ generateLoginNonce,
158
+ normalizeOptionalLoginNonce,
159
+ buildLoginSigningMessageBytes,
160
+ tryConsumeLoginNonce,
161
+ createLoginTimestampChallenge,
162
+ clearLoginNonceReplayCacheForTests,
163
+ buildLoginNonceReplayKey,
164
+ };
@@ -2085,7 +2085,7 @@ async function thorough_validate_jwt_token_be(token, requestId = ulid()) {
2085
2085
  // Starting verification with more detailed logging
2086
2086
  logger.debug("Starting verification checks", {
2087
2087
  requestId,
2088
- checks: ["match", "live", "active", "trusted"],
2088
+ checks: ["match", "live", "trusted"],
2089
2089
  serviceProviderId: config_own_rodit.own_rodit.metadata.serviceprovider_id,
2090
2090
  });
2091
2091
 
@@ -2093,7 +2093,6 @@ async function thorough_validate_jwt_token_be(token, requestId = ulid()) {
2093
2093
  const {
2094
2094
  verify_rodit_isamatch,
2095
2095
  verify_rodit_islive,
2096
- verify_rodit_isactive,
2097
2096
  verify_rodit_istrusted_issuingsmartcontract
2098
2097
  } = require("./authentication");
2099
2098
 
@@ -2188,49 +2187,6 @@ async function thorough_validate_jwt_token_be(token, requestId = ulid()) {
2188
2187
  };
2189
2188
  }
2190
2189
 
2191
- // Perform active verification
2192
- const activeStart = performance.now();
2193
- const isActive = await verify_rodit_isactive(
2194
- peer_rodit.token_id,
2195
- config_own_rodit.own_rodit.metadata.subjectuniqueidentifier_url
2196
- );
2197
- const activeDuration = performance.now() - activeStart;
2198
-
2199
- logger.debug("Active verification completed", {
2200
- requestId,
2201
- activeDuration,
2202
- isActive,
2203
- tokenId: peer_rodit.token_id,
2204
- url: config_own_rodit.own_rodit.metadata.subjectuniqueidentifier_url,
2205
- });
2206
-
2207
- if (!isActive) {
2208
- logger.warn("RODiT active verification failed", {
2209
- component: "JwtAuth",
2210
- method: "thorough_validate_jwt_token_be",
2211
- requestId,
2212
- duration: performance.now() - startTime,
2213
- tokenId: peer_rodit.token_id,
2214
- url: config_own_rodit.own_rodit.metadata.subjectuniqueidentifier_url,
2215
- });
2216
-
2217
- // Add metrics for failed active verification
2218
- logger.metric &&
2219
- logger.metric("jwt_thorough_validation", performance.now() - startTime, {
2220
- result: "active_failed",
2221
- token_jti: token.jti || "unknown",
2222
- peer_rodit_id: peer_rodit.token_id,
2223
- });
2224
-
2225
- return {
2226
- isValid: false,
2227
- notAfter: null,
2228
- error: "RODiT active verification failed",
2229
- errorCode: "SERVER_RODIT_REVOKED",
2230
- errorMessage: "Server's RODiT has been revoked"
2231
- };
2232
- }
2233
-
2234
2190
  // Perform trusted verification
2235
2191
  const trustedStart = performance.now();
2236
2192
  const isTrusted = await verify_rodit_istrusted_issuingsmartcontract(
@@ -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;
@@ -1624,6 +1680,16 @@ async function login_portal(config_own_rodit, port, options = {}) {
1624
1680
  requestId
1625
1681
  };
1626
1682
  }
1683
+ const nonceResult = normalizeOptionalLoginNonce(options.nonce);
1684
+ if (nonceResult.errorCode) {
1685
+ return {
1686
+ error: nonceResult.error,
1687
+ errorCode: nonceResult.errorCode,
1688
+ failureReason: nonceResult.errorCode,
1689
+ requestId
1690
+ };
1691
+ }
1692
+ const nonce = nonceResult.nonce;
1627
1693
 
1628
1694
  const hasRoditId = roditid.length > 0;
1629
1695
  const hasAccountId = accountid.length > 0;
@@ -1638,8 +1704,10 @@ async function login_portal(config_own_rodit, port, options = {}) {
1638
1704
 
1639
1705
  const timeString = await unixTimeToDateString(timestamp);
1640
1706
  const signatureIdentifier = hasRoditId ? roditid : accountid;
1641
- const signatureIdentifierandtimestamp = new TextEncoder().encode(
1642
- signatureIdentifier + timeString
1707
+ const signatureIdentifierandtimestamp = buildLoginSigningMessageBytes(
1708
+ signatureIdentifier,
1709
+ timeString,
1710
+ nonce
1643
1711
  );
1644
1712
 
1645
1713
  logger.debug("Generating authentication signature", {
@@ -1649,6 +1717,7 @@ async function login_portal(config_own_rodit, port, options = {}) {
1649
1717
  roditId: roditid,
1650
1718
  accountId: accountid,
1651
1719
  timestamp,
1720
+ hasNonce: !!nonce,
1652
1721
  });
1653
1722
 
1654
1723
  // Create signature
@@ -1674,17 +1743,21 @@ async function login_portal(config_own_rodit, port, options = {}) {
1674
1743
  });
1675
1744
 
1676
1745
  try {
1746
+ const requestBody = {
1747
+ ...(hasRoditId ? { roditid } : {}),
1748
+ ...(hasAccountId ? { accountid } : {}),
1749
+ timestamp,
1750
+ roditid_base64url_signature,
1751
+ };
1752
+ if (nonce) {
1753
+ requestBody.nonce = nonce;
1754
+ }
1677
1755
  const response = await fetch(fetchUrl, {
1678
1756
  method: "POST",
1679
1757
  headers: {
1680
1758
  "Content-Type": "application/json",
1681
1759
  },
1682
- body: JSON.stringify({
1683
- ...(hasRoditId ? { roditid } : {}),
1684
- ...(hasAccountId ? { accountid } : {}),
1685
- timestamp,
1686
- roditid_base64url_signature,
1687
- }),
1760
+ body: JSON.stringify(requestBody),
1688
1761
  });
1689
1762
 
1690
1763
  if (!response.ok) {
@@ -1972,8 +2045,12 @@ async function login_portal(config_own_rodit, port, options = {}) {
1972
2045
  });
1973
2046
 
1974
2047
  const roditid = normalizeOptionalLoginString(own_rodit?.token_id);
1975
- const { timestamp, error: timestampError, errorCode: timestampErrorCode } =
1976
- await resolveServerLoginTimestamp(apiendpoint, options);
2048
+ const {
2049
+ timestamp,
2050
+ nonce,
2051
+ error: timestampError,
2052
+ errorCode: timestampErrorCode,
2053
+ } = await resolveServerLoginTimestamp(apiendpoint, options);
1977
2054
  const accountid = normalizeOptionalServerAccountId(options.accountId);
1978
2055
 
1979
2056
  if (timestamp === null) {
@@ -2005,13 +2082,16 @@ async function login_portal(config_own_rodit, port, options = {}) {
2005
2082
  roditId: roditid,
2006
2083
  accountId: accountid,
2007
2084
  timestamp,
2085
+ hasNonce: !!nonce,
2008
2086
  });
2009
2087
 
2010
2088
  const timeString = await unixTimeToDateString(timestamp);
2011
2089
 
2012
2090
  const signatureIdentifier = hasRoditId ? roditid : accountid;
2013
- const signatureIdentifierandtimestamp = new TextEncoder().encode(
2014
- signatureIdentifier + timeString
2091
+ const signatureIdentifierandtimestamp = buildLoginSigningMessageBytes(
2092
+ signatureIdentifier,
2093
+ timeString,
2094
+ nonce
2015
2095
  );
2016
2096
 
2017
2097
  logger.debug("Generating signature", {
@@ -2020,6 +2100,7 @@ async function login_portal(config_own_rodit, port, options = {}) {
2020
2100
  requestId,
2021
2101
  hasPrivateKey: !!config_own_rodit.own_rodit_bytes_private_key,
2022
2102
  signatureIdentifier,
2103
+ hasNonce: !!nonce,
2023
2104
  });
2024
2105
 
2025
2106
  const own_rodit_bytes_signature = nacl.sign.detached(
@@ -2044,6 +2125,10 @@ async function login_portal(config_own_rodit, port, options = {}) {
2044
2125
  requestBody.accountid = accountid;
2045
2126
  }
2046
2127
 
2128
+ if (nonce) {
2129
+ requestBody.nonce = nonce;
2130
+ }
2131
+
2047
2132
  logger.debug("Sending login request", {
2048
2133
  component: "AuthenticationService",
2049
2134
  method,
@@ -2051,6 +2136,7 @@ async function login_portal(config_own_rodit, port, options = {}) {
2051
2136
  roditid,
2052
2137
  accountId: accountid,
2053
2138
  timestamp,
2139
+ hasNonce: !!nonce,
2054
2140
  signatureLength: roditid_base64url_signature?.length,
2055
2141
  apiEndpoint: loginUrl,
2056
2142
  });
@@ -2379,4 +2465,14 @@ async function logout_server(jwt_token) {
2379
2465
 
2380
2466
 
2381
2467
  // 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};
2468
+ module.exports = {
2469
+ authenticate_apicall,
2470
+ authenticate_logout,
2471
+ login_server,
2472
+ login_portal,
2473
+ login_client,
2474
+ login_client_withnep413,
2475
+ logout_client,
2476
+ logout_server,
2477
+ createLoginTimestampChallenge,
2478
+ };
@@ -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.15.0",
3
+ "version": "9.16.2",
4
4
  "description": "RODiT-based authentication system for Express.js applications",
5
5
  "main": "index.js",
6
6
  "exports": {
@@ -46,8 +46,13 @@
46
46
  },
47
47
  "repository": {
48
48
  "type": "git",
49
- "url": "https://github.com/discernible-io/rodit-auth-be.git"
49
+ "url": "git+https://github.com/discernible-io/rodit-sdk.git",
50
+ "directory": "rodit-auth-be"
50
51
  },
52
+ "bugs": {
53
+ "url": "https://github.com/discernible-io/rodit-sdk/issues"
54
+ },
55
+ "homepage": "https://github.com/discernible-io/rodit-sdk/tree/main/rodit-auth-be#readme",
51
56
  "publishConfig": {
52
57
  "access": "public"
53
58
  },