@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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  All notable changes to `@rodit/rodit-auth-be` are documented here.
4
4
 
5
+ ## [9.17.0] — 2026-10-06
6
+
7
+ ### Fixed
8
+
9
+ - **`nearorg_rpc_tokenfromroditid`:** a failed NEAR RPC call no longer looks like a
10
+ missing token. An empty `RODiT` is returned only when the `rodit_token` view
11
+ completes and decodes to JSON `null`. HTTP errors (including 402/403 credit
12
+ exhaustion), JSON-RPC `error` objects, WASM errors, and undecodable results
13
+ throw `NearRpcError` with the HTTP status and provider message. The function
14
+ catch rethrows that error instead of returning `new RODiT()`.
15
+
16
+ ## [9.16.2] — 2026-09-19
17
+
18
+ ### Fixed
19
+
20
+ - **`send_webhook` URL join:** append `endpoint` (e.g. `/hooks/wake`) only when
21
+ peer `webhook_url` / `rodit_webhookurl` has no path. A URL that already
22
+ includes a path (e.g. `host:7443/hooks/agent`) is used as-is, so paths are
23
+ no longer doubled.
24
+
25
+ ## [9.16.1] — 2026-09-18
26
+
27
+ ### Changed
28
+
29
+ - **Docs:** Clarify that peer `webhook_url` / `rodit_webhookurl` is a base
30
+ (`host` or `host:port` only). The SDK appends the endpoint path; including
31
+ `/hooks/wake` or `/hooks/agent` in the metadata doubles the path.
32
+
5
33
  ## [9.16.0] — 2026-09-14
6
34
 
7
35
  ### Removed
package/README.md CHANGED
@@ -4,7 +4,7 @@ Node.js / Express SDK for RODiT-based mutual authentication, authorization, sess
4
4
 
5
5
  **npm:** [https://www.npmjs.com/package/@rodit/rodit-auth-be](https://www.npmjs.com/package/@rodit/rodit-auth-be)
6
6
  **Monorepo:** [discernible-io/rodit-sdk](https://github.com/discernible-io/rodit-sdk) (this directory)
7
- **Version:** 9.15.0 · **License:** Proprietary · **Author:** Discernible IO
7
+ **Version:** 9.17.0 · **License:** Proprietary · **Author:** Discernible IO
8
8
 
9
9
  ## Production deployments
10
10
 
@@ -1197,7 +1197,7 @@ const jwtDuration = metadata.jwt_duration; // JWT expiration time
1197
1197
  const maxRequests = metadata.max_requests; // Rate limit
1198
1198
  const maxRqWindow = metadata.maxrq_window; // Rate limit window
1199
1199
  const apiEndpoint = metadata.subjectuniqueidentifier_url; // API URL
1200
- const webhookUrl = metadata.webhook_url; // Webhook endpoint
1200
+ const webhookUrl = metadata.webhook_url; // Webhook host[:port] (optional path)
1201
1201
  // Parse permissioned routes
1202
1202
  const permissionedRoutes = JSON.parse(metadata.permissioned_routes || '{}')
1203
1203
  // Use SDK config for application settings
@@ -1542,12 +1542,12 @@ Peer RODiT metadata:
1542
1542
 
1543
1543
  ```json
1544
1544
  {
1545
- "webhook_url": "hooks.example.com",
1545
+ "webhook_url": "hooks.example.com:7443",
1546
1546
  "webhook_cidr": "0.0.0.0/0"
1547
1547
  }
1548
1548
  ```
1549
1549
 
1550
- - `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.
1551
1551
  - `webhook_cidr` — allowlist for the **resolved** destination IP (`0.0.0.0/0` / empty = any public IP still subject to the SSRF blocklist)
1552
1552
 
1553
1553
  Outbound rejection codes (returned as `{ isValid: false, error: { code, message, requestId } }`):
@@ -2390,6 +2390,10 @@ const webhookHandler = roditClient.getWebhookHandler()
2390
2390
 
2391
2391
  Send a webhook notification to the peer URL in `req.user.rodit_webhookurl`.
2392
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
+
2393
2397
  Outbound delivery rejects private / loopback / metadata destinations (and
2394
2398
  resolved addresses), enforces peer JWT `rodit_webhookcidr` when set, and pins
2395
2399
  DNS for the request. `SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY` still applies
@@ -2485,7 +2489,7 @@ When you call `roditClient.getConfigOwnRodit()`, you get access to these metadat
2485
2489
  | `subjectuniqueidentifier_url` | string | Primary API service endpoint |
2486
2490
  | `userselected_dn` | string | User-selected display name |
2487
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) |
2488
- | `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 |
2489
2493
 
2490
2494
  ## Best Practices
2491
2495
 
package/index.js CHANGED
@@ -18,10 +18,12 @@ const {
18
18
  authenticate_logout,
19
19
  login_client,
20
20
  logout_client,
21
+ refresh_client,
21
22
  login_client_withnep413,
22
23
  login_portal,
23
24
  login_server,
24
- logout_server
25
+ logout_server,
26
+ createLoginTimestampChallenge,
25
27
  } = require('./lib/middleware/authenticationmw');
26
28
 
27
29
  const {
@@ -159,6 +161,17 @@ class RoditClient {
159
161
  return authenticate_logout;
160
162
  }
161
163
 
164
+ /**
165
+ * Optional refresh auth middleware (same tolerance as logout: signature-valid
166
+ * expired tokens allowed). Only relevant if the host mounts POST /api/refresh;
167
+ * default SERVER-INITIATED renewals do not require this route.
168
+ *
169
+ * @returns {Function} Refresh authentication middleware function
170
+ */
171
+ get authenticateForRefresh() {
172
+ return authenticate_logout;
173
+ }
174
+
162
175
  /**
163
176
  * Get permissions validation middleware (clean syntax)
164
177
  * @returns {Function} Permissions validation middleware function
@@ -1041,6 +1054,27 @@ class RoditClient {
1041
1054
  return await logout_client(req, res);
1042
1055
  }
1043
1056
 
1057
+ /**
1058
+ * Optional Express handler for client-initiated token refresh (POST /api/refresh).
1059
+ * Normally unnecessary: default SERVER-INITIATED mode renews credentials on
1060
+ * authenticate and returns New-Token. Mount only for idle-client refresh or
1061
+ * when SECURITY_OPTIONS.SERVERORCLIENT=CLIENT-INITIATED.
1062
+ *
1063
+ * @param {Object} req - Express request object
1064
+ * @param {Object} res - Express response object
1065
+ * @returns {Promise<*>} Express response
1066
+ */
1067
+ async refresh_client(req, res) {
1068
+ logger.debug('Processing Express token refresh request', {
1069
+ component: 'RoditClient',
1070
+ method: 'refresh_client',
1071
+ path: req.path,
1072
+ ip: req.ip
1073
+ });
1074
+
1075
+ return await refresh_client(req, res);
1076
+ }
1077
+
1044
1078
  /**
1045
1079
  * Login to a peer RODiT API using RODiT id (matches login_client / POST /api/login).
1046
1080
  *
@@ -1137,7 +1171,13 @@ class RoditClient {
1137
1171
  errorMessage += '\n→ [CLIENT REJECTED] The RODiT is missing required metadata. The RODiT may be corrupted or incomplete.';
1138
1172
  break;
1139
1173
  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.';
1174
+ 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.';
1175
+ break;
1176
+ case 'LOGIN_NONCE_REPLAY':
1177
+ errorMessage += '\n→ [CLIENT REJECTED] The login nonce was already used. Fetch a fresh challenge from GET /api/login/timestamp and sign again.';
1178
+ break;
1179
+ case 'INVALID_LOGIN_NONCE':
1180
+ errorMessage += '\n→ [CLIENT REJECTED] The login nonce is malformed. Use the nonce string from GET /api/login/timestamp (base64url or hex).';
1141
1181
  break;
1142
1182
  case 'RODIT_FAMILY_MISMATCH':
1143
1183
  errorMessage += '\n→ [CLIENT REJECTED] Your RODiT does not belong to the same family as the server. You may need a different RODiT.';
@@ -1831,19 +1871,71 @@ class RoditClient {
1831
1871
  }
1832
1872
 
1833
1873
  /**
1834
- * Refresh the authentication token by calling `login_server` (RODiT id flow, default `POST /api/login`).
1874
+ * Refresh the access credential. Prefers optional peer POST /api/refresh when
1875
+ * available; otherwise falls back to full `login_server`. Under default
1876
+ * SERVER-INITIATED deployments, routine renewal is handled by the server via
1877
+ * New-Token on authenticated calls — this method is for explicit refresh or
1878
+ * re-login after hard expiry.
1879
+ * @param {Object} [options]
1880
+ * @param {string} [options.refreshPath='/api/refresh'] - Refresh endpoint path
1881
+ * @param {boolean} [options.preferLogin=false] - Force re-login instead of refresh
1835
1882
  * @returns {Promise<string>} New token
1836
1883
  */
1837
- async refreshToken() {
1884
+ async refreshToken(options = {}) {
1885
+ const refreshPath = options.refreshPath || '/api/refresh';
1886
+ const preferLogin = options.preferLogin === true;
1887
+
1838
1888
  logger.debug('Refreshing authentication token', {
1839
1889
  component: 'RoditClient',
1840
- method: 'refreshToken'
1890
+ method: 'refreshToken',
1891
+ refreshPath,
1892
+ preferLogin
1841
1893
  });
1842
1894
 
1843
- await this.login_server();
1844
- const refreshedToken = await this.getSessionToken();
1895
+ if (!preferLogin && this.jwt_token) {
1896
+ try {
1897
+ const base =
1898
+ this.apiEndpoint ||
1899
+ config.get('API_DEFAULT_OPTIONS.API_ENDPOINT', '') ||
1900
+ '';
1901
+ if (base) {
1902
+ const url = `${String(base).replace(/\/$/, '')}${
1903
+ refreshPath.startsWith('/') ? refreshPath : `/${refreshPath}`
1904
+ }`;
1905
+ const refreshResponse = await fetch(url, {
1906
+ method: 'POST',
1907
+ headers: {
1908
+ Authorization: `Bearer ${this.jwt_token}`,
1909
+ 'Content-Type': 'application/json',
1910
+ },
1911
+ });
1912
+ if (refreshResponse.ok) {
1913
+ const body = await refreshResponse.json().catch(() => ({}));
1914
+ const headerToken =
1915
+ typeof refreshResponse.headers?.get === 'function'
1916
+ ? refreshResponse.headers.get('New-Token')
1917
+ : null;
1918
+ const newToken = body.token || body.jwt_token || headerToken;
1919
+ if (newToken) {
1920
+ this.jwt_token = newToken;
1921
+ if (typeof this.setSessionToken === 'function') {
1922
+ await this.setSessionToken(newToken);
1923
+ }
1924
+ return newToken;
1925
+ }
1926
+ }
1927
+ }
1928
+ } catch (refreshError) {
1929
+ logger.warn('Client-initiated refresh failed; falling back to login_server', {
1930
+ component: 'RoditClient',
1931
+ method: 'refreshToken',
1932
+ error: refreshError.message
1933
+ });
1934
+ }
1935
+ }
1845
1936
 
1846
- return refreshedToken;
1937
+ await this.login_server();
1938
+ return this.getSessionToken();
1847
1939
  }
1848
1940
 
1849
1941
  /**
@@ -1913,7 +2005,7 @@ class RoditClient {
1913
2005
  }
1914
2006
 
1915
2007
  // Import health check and retry functions
1916
- const { healthCheckRPC, resolveHealthyNearRpcUrl, fetchWithRetry } = require('./lib/blockchain/blockchainservice');
2008
+ const { healthCheckRPC, resolveHealthyNearRpcUrl, fetchWithRetry, NearRpcError } = require('./lib/blockchain/blockchainservice');
1917
2009
 
1918
2010
  // Export the RoditClient class and commonly used SDK components
1919
2011
  module.exports = {
@@ -1934,10 +2026,12 @@ module.exports = {
1934
2026
  authenticate_logout,
1935
2027
  login_client,
1936
2028
  logout_client,
2029
+ refresh_client,
1937
2030
  login_client_withnep413,
1938
2031
  login_portal,
1939
2032
  login_server,
1940
2033
  logout_server,
2034
+ createLoginTimestampChallenge,
1941
2035
  validate_jwt_token_be,
1942
2036
  generate_jwt_token,
1943
2037
  normalizeUrlWithoutPort,
@@ -1958,6 +2052,7 @@ module.exports = {
1958
2052
  VersionManager,
1959
2053
  // Blockchain service functions
1960
2054
  nearorg_rpc_timestamp: blockchainService.nearorg_rpc_timestamp,
2055
+ NearRpcError,
1961
2056
  // Startup validation and health check functions
1962
2057
  validateConfig: config.validate,
1963
2058
  healthCheckRPC,
@@ -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
 
@@ -1827,5 +1862,5 @@ module.exports = {
1827
1862
  verify_rodit_isamatch,
1828
1863
  verify_rodit_islive,
1829
1864
  verify_rodit_istrusted_issuingsmartcontract,
1830
- authenticate_webhook
1865
+ authenticate_webhook,
1831
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
+ };