@rodit/rodit-auth-be 9.16.2 → 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,17 @@
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
+
5
16
  ## [9.16.2] — 2026-09-19
6
17
 
7
18
  ### Fixed
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.16.2 · **License:** Proprietary · **Author:** Discernible IO
7
+ **Version:** 9.17.0 · **License:** Proprietary · **Author:** Discernible IO
8
8
 
9
9
  ## Production deployments
10
10
 
package/index.js CHANGED
@@ -18,6 +18,7 @@ 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,
@@ -160,6 +161,17 @@ class RoditClient {
160
161
  return authenticate_logout;
161
162
  }
162
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
+
163
175
  /**
164
176
  * Get permissions validation middleware (clean syntax)
165
177
  * @returns {Function} Permissions validation middleware function
@@ -1042,6 +1054,27 @@ class RoditClient {
1042
1054
  return await logout_client(req, res);
1043
1055
  }
1044
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
+
1045
1078
  /**
1046
1079
  * Login to a peer RODiT API using RODiT id (matches login_client / POST /api/login).
1047
1080
  *
@@ -1838,19 +1871,71 @@ class RoditClient {
1838
1871
  }
1839
1872
 
1840
1873
  /**
1841
- * 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
1842
1882
  * @returns {Promise<string>} New token
1843
1883
  */
1844
- async refreshToken() {
1884
+ async refreshToken(options = {}) {
1885
+ const refreshPath = options.refreshPath || '/api/refresh';
1886
+ const preferLogin = options.preferLogin === true;
1887
+
1845
1888
  logger.debug('Refreshing authentication token', {
1846
1889
  component: 'RoditClient',
1847
- method: 'refreshToken'
1890
+ method: 'refreshToken',
1891
+ refreshPath,
1892
+ preferLogin
1848
1893
  });
1849
1894
 
1850
- await this.login_server();
1851
- 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
+ }
1852
1936
 
1853
- return refreshedToken;
1937
+ await this.login_server();
1938
+ return this.getSessionToken();
1854
1939
  }
1855
1940
 
1856
1941
  /**
@@ -1920,7 +2005,7 @@ class RoditClient {
1920
2005
  }
1921
2006
 
1922
2007
  // Import health check and retry functions
1923
- const { healthCheckRPC, resolveHealthyNearRpcUrl, fetchWithRetry } = require('./lib/blockchain/blockchainservice');
2008
+ const { healthCheckRPC, resolveHealthyNearRpcUrl, fetchWithRetry, NearRpcError } = require('./lib/blockchain/blockchainservice');
1924
2009
 
1925
2010
  // Export the RoditClient class and commonly used SDK components
1926
2011
  module.exports = {
@@ -1941,6 +2026,7 @@ module.exports = {
1941
2026
  authenticate_logout,
1942
2027
  login_client,
1943
2028
  logout_client,
2029
+ refresh_client,
1944
2030
  login_client_withnep413,
1945
2031
  login_portal,
1946
2032
  login_server,
@@ -1966,6 +2052,7 @@ module.exports = {
1966
2052
  VersionManager,
1967
2053
  // Blockchain service functions
1968
2054
  nearorg_rpc_timestamp: blockchainService.nearorg_rpc_timestamp,
2055
+ NearRpcError,
1969
2056
  // Startup validation and health check functions
1970
2057
  validateConfig: config.validate,
1971
2058
  healthCheckRPC,
@@ -977,9 +977,15 @@ function resolveCredentialExpirationUnix(now, sessionExpiration, own_rodit) {
977
977
  tokenJti: token.jti,
978
978
  });
979
979
 
980
- throw new Error("Session inactive or closed");
980
+ const closedError = new Error("Session inactive or closed");
981
+ closedError.code = "SESSION_CLOSED";
982
+ closedError.renewalReason = "session_closed";
983
+ throw closedError;
981
984
  }
982
985
  } catch (sessionError) {
986
+ if (sessionError.renewalReason === "session_closed" || sessionError.code === "SESSION_CLOSED") {
987
+ throw sessionError;
988
+ }
983
989
  logger.error("Session check failed", {
984
990
  component: "JwtAuth",
985
991
  method: "generate_jwt_token_fromtoken",
@@ -988,7 +994,10 @@ function resolveCredentialExpirationUnix(now, sessionExpiration, own_rodit) {
988
994
  error: sessionError.message,
989
995
  });
990
996
  // Fail closed for session validation errors during renewal.
991
- throw new Error(`Session check failed: ${sessionError.message}`);
997
+ const checkFailed = new Error(`Session check failed: ${sessionError.message}`);
998
+ checkFailed.code = "SESSION_CLOSED";
999
+ checkFailed.renewalReason = "session_closed";
1000
+ throw checkFailed;
992
1001
  }
993
1002
  }
994
1003
 
@@ -1795,45 +1804,92 @@ function resolveCredentialExpirationUnix(now, sessionExpiration, own_rodit) {
1795
1804
  };
1796
1805
 
1797
1806
  let newToken = null;
1798
- if (!options.allowExpiredToken) {
1799
- // Check if token needs renewal or is expired
1807
+ const serverOrClient = String(
1808
+ config.get("SECURITY_OPTIONS.SERVERORCLIENT", "SERVER-INITIATED")
1809
+ ).toUpperCase();
1810
+ const clientInitiatedRenewal = serverOrClient === "CLIENT-INITIATED";
1811
+ const forceCredentialRenewal = options.forceCredentialRenewal === true;
1812
+
1813
+ if (forceCredentialRenewal) {
1800
1814
  const renewalResult = await checkandrenew_jwt_token(
1801
1815
  payload,
1802
1816
  Math.floor(Date.now() / 1000),
1803
1817
  requestId,
1804
- isExpired
1818
+ true
1805
1819
  );
1806
1820
  newToken = renewalResult.newToken;
1807
-
1808
- if (isExpired && !newToken) {
1809
- const renewalNow = Math.floor(Date.now() / 1000);
1810
- const sessionExpUnix =
1811
- payload.session_exp != null ? Number(payload.session_exp) : null;
1812
- const sessionStillActive =
1813
- Number.isFinite(sessionExpUnix) && sessionExpUnix > renewalNow;
1814
-
1815
- if (sessionStillActive) {
1816
- logger.warn(
1817
- "Credential expired and renewal failed but session still active; allowing request",
1818
- {
1821
+ if (renewalResult.reason === "session_closed") {
1822
+ throw new Error("Error 012: Session is not active");
1823
+ }
1824
+ if (!newToken) {
1825
+ const renewFail = new Error(
1826
+ "Error 007: Token has expired and renewal failed"
1827
+ );
1828
+ renewFail.code = "RENEWAL_FAILED";
1829
+ renewFail.renewalReason = renewalResult.reason || "renewal_failed";
1830
+ throw renewFail;
1831
+ }
1832
+ } else if (!options.allowExpiredToken) {
1833
+ if (clientInitiatedRenewal && !isExpired) {
1834
+ // CLIENT-INITIATED: skip piggyback renewal; client must call /api/refresh.
1835
+ newToken = null;
1836
+ } else if (clientInitiatedRenewal && isExpired) {
1837
+ logger.error(
1838
+ "Token expired under CLIENT-INITIATED mode; client must refresh explicitly",
1839
+ {
1840
+ component: "JwtAuth",
1841
+ method: "validate_jwt_token_be",
1842
+ requestId,
1843
+ jti: payload.jti,
1844
+ }
1845
+ );
1846
+ throw new Error("Error 007: Token has expired and renewal failed");
1847
+ } else {
1848
+ // SERVER-INITIATED: renew on authenticate when eligible or expired
1849
+ const renewalResult = await checkandrenew_jwt_token(
1850
+ payload,
1851
+ Math.floor(Date.now() / 1000),
1852
+ requestId,
1853
+ isExpired
1854
+ );
1855
+ newToken = renewalResult.newToken;
1856
+
1857
+ if (renewalResult.reason === "session_closed") {
1858
+ throw new Error("Error 012: Session is not active");
1859
+ }
1860
+
1861
+ if (isExpired && !newToken) {
1862
+ const renewalNow = Math.floor(Date.now() / 1000);
1863
+ const sessionExpUnix =
1864
+ payload.session_exp != null ? Number(payload.session_exp) : null;
1865
+ const sessionStillActive =
1866
+ Number.isFinite(sessionExpUnix) && sessionExpUnix > renewalNow;
1867
+
1868
+ if (sessionStillActive) {
1869
+ logger.warn(
1870
+ "Credential expired and renewal failed but session still active; allowing request",
1871
+ {
1872
+ component: "JwtAuth",
1873
+ method: "validate_jwt_token_be",
1874
+ requestId,
1875
+ jti: payload.jti,
1876
+ sessionExp: sessionExpUnix,
1877
+ now: renewalNow,
1878
+ renewalReason: renewalResult.reason || "renewal_failed",
1879
+ }
1880
+ );
1881
+ } else {
1882
+ logger.error("Token expired and renewal failed", {
1819
1883
  component: "JwtAuth",
1820
1884
  method: "validate_jwt_token_be",
1821
1885
  requestId,
1822
1886
  jti: payload.jti,
1823
1887
  sessionExp: sessionExpUnix,
1824
- now: renewalNow,
1825
- }
1826
- );
1827
- } else {
1828
- logger.error("Token expired and renewal failed", {
1829
- component: "JwtAuth",
1830
- method: "validate_jwt_token_be",
1831
- requestId,
1832
- jti: payload.jti,
1833
- sessionExp: sessionExpUnix,
1834
- });
1888
+ renewalReason: renewalResult.reason || "renewal_failed",
1889
+ });
1835
1890
 
1836
- throw new Error("Error 007: Token has expired and renewal failed");
1891
+ throw new Error("Error 007: Token has expired and renewal failed");
1892
+ }
1837
1893
  }
1838
1894
  }
1839
1895
  } else if (isExpired) {
@@ -2472,7 +2528,7 @@ async function thorough_validate_jwt_token_be(token, requestId = ulid()) {
2472
2528
  session_status: payload.session_status || "unknown",
2473
2529
  seconds_until_eligibility: secondsUntilEligibility,
2474
2530
  });
2475
- return { newToken: null };
2531
+ return { newToken: null, reason: null };
2476
2532
  }
2477
2533
 
2478
2534
  // Token needs renewal
@@ -2602,6 +2658,7 @@ async function thorough_validate_jwt_token_be(token, requestId = ulid()) {
2602
2658
 
2603
2659
  return {
2604
2660
  newToken,
2661
+ reason: null,
2605
2662
  logInfo: {
2606
2663
  newDuration: newduration,
2607
2664
  reason: shouldDoFullVerification
@@ -2618,15 +2675,35 @@ async function thorough_validate_jwt_token_be(token, requestId = ulid()) {
2618
2675
  };
2619
2676
  }
2620
2677
  } catch (error) {
2678
+ const sessionClosed =
2679
+ error.renewalReason === "session_closed" ||
2680
+ error.code === "SESSION_CLOSED" ||
2681
+ /session inactive or closed/i.test(error.message || "");
2682
+
2621
2683
  logger.error("Token renewal failed", {
2622
2684
  component: "TokenRenewalService",
2623
2685
  method: "checkandrenew_jwt_token",
2624
2686
  requestId,
2625
2687
  error: error.message,
2688
+ reason: sessionClosed ? "session_closed" : "renewal_failed",
2689
+ });
2690
+
2691
+ const totalDuration = Date.now() - startTime;
2692
+ logger.metric("token_renewal_check_duration_ms", totalDuration, {
2693
+ component: "TokenRenewalService",
2694
+ renewalNeeded: true,
2695
+ success: false,
2696
+ session_status: payload.session_status || "unknown",
2697
+ reason: sessionClosed ? "session_closed" : "renewal_failed",
2626
2698
  });
2699
+
2700
+ return {
2701
+ newToken: null,
2702
+ reason: sessionClosed ? "session_closed" : "renewal_failed",
2703
+ };
2627
2704
  }
2628
2705
 
2629
- // If we reach here, renewal wasn't successful
2706
+ // If we reach here, renewal wasn't successful (verification returned invalid)
2630
2707
  const totalDuration = Date.now() - startTime;
2631
2708
  logger.debug("Token renewal not performed", {
2632
2709
  component: "TokenRenewalService",
@@ -2641,9 +2718,10 @@ async function thorough_validate_jwt_token_be(token, requestId = ulid()) {
2641
2718
  renewalNeeded: true,
2642
2719
  success: false,
2643
2720
  session_status: payload.session_status || "unknown",
2721
+ reason: "verification_failed",
2644
2722
  });
2645
2723
 
2646
- return { newToken: null };
2724
+ return { newToken: null, reason: "verification_failed" };
2647
2725
  }
2648
2726
 
2649
2727
 
@@ -82,6 +82,96 @@ class RODiT {
82
82
  }
83
83
  }
84
84
 
85
+ /**
86
+ * A NEAR RPC query failed before a completed view result was available.
87
+ * `status` is the HTTP status. `message` includes that status and the
88
+ * provider text (including credit or quota notices). `creditFailure` is set
89
+ * for HTTP 402 and for messages that say the provider is out of credits.
90
+ *
91
+ * A completed `rodit_token` view whose decoded value is JSON null is not this
92
+ * error: that path returns an empty RODiT.
93
+ */
94
+ class NearRpcError extends Error {
95
+ /**
96
+ * @param {string} message
97
+ * @param {{ status?: number|null, providerMessage?: string, creditFailure?: boolean }} [details]
98
+ */
99
+ constructor(message, details = {}) {
100
+ super(message);
101
+ this.name = "NearRpcError";
102
+ this.status = details.status == null ? null : details.status;
103
+ this.providerMessage = details.providerMessage || "";
104
+ this.creditFailure = details.creditFailure === true;
105
+ }
106
+ }
107
+
108
+ function clipRpcText(value, max = 500) {
109
+ const text = String(value ?? "").replace(/\s+/g, " ").trim();
110
+ if (text.length <= max) return text;
111
+ return `${text.slice(0, max)}...`;
112
+ }
113
+
114
+ function describeRpcError(errorField) {
115
+ if (errorField == null) return "";
116
+ if (typeof errorField === "string") return clipRpcText(errorField);
117
+ if (typeof errorField === "object") {
118
+ const message = errorField.message || errorField.name || "";
119
+ const code = errorField.code != null ? ` (${errorField.code})` : "";
120
+ let dataText = "";
121
+ if (errorField.data != null) {
122
+ dataText = typeof errorField.data === "string"
123
+ ? errorField.data
124
+ : JSON.stringify(errorField.data);
125
+ }
126
+ const combined = [message ? `${message}${code}` : "", dataText].filter(Boolean).join(": ");
127
+ return clipRpcText(combined || JSON.stringify(errorField));
128
+ }
129
+ return clipRpcText(errorField);
130
+ }
131
+
132
+ function looksLikeCreditFailure(status, text) {
133
+ if (status === 402) return true;
134
+ return /credit|quota|payment required|insufficient (balance|funds)/i.test(text || "");
135
+ }
136
+
137
+ function nearRpcFailure(status, providerMessage) {
138
+ const detail = providerMessage ? clipRpcText(providerMessage) : "";
139
+ const message = detail
140
+ ? `NEAR RPC call failed (HTTP ${status}): ${detail}`
141
+ : `NEAR RPC call failed (HTTP ${status})`;
142
+ return new NearRpcError(message, {
143
+ status,
144
+ providerMessage: detail,
145
+ creditFailure: looksLikeCreditFailure(status, detail)
146
+ });
147
+ }
148
+
149
+ async function readResponseErrorMessage(response) {
150
+ let bodyText = "";
151
+ try {
152
+ bodyText = await response.text();
153
+ } catch (_readError) {
154
+ bodyText = "";
155
+ }
156
+ const trimmed = bodyText.trim();
157
+ if (!trimmed) {
158
+ return response.statusText ? clipRpcText(response.statusText) : "";
159
+ }
160
+ try {
161
+ const parsed = JSON.parse(trimmed);
162
+ if (parsed && parsed.error) {
163
+ const described = describeRpcError(parsed.error);
164
+ if (described) return described;
165
+ }
166
+ if (parsed && typeof parsed.message === "string" && parsed.message.trim()) {
167
+ return clipRpcText(parsed.message);
168
+ }
169
+ } catch (_parseError) {
170
+ // Credit notices are often plain text rather than JSON-RPC.
171
+ }
172
+ return clipRpcText(trimmed);
173
+ }
174
+
85
175
  /**
86
176
  * Payload class for NEP-413 standard
87
177
  */
@@ -340,10 +430,16 @@ const PayloadNEP413Schema = {
340
430
  }
341
431
 
342
432
  /**
343
- * Fetches a RODiT token by ID from the NEAR blockchain
344
- *
433
+ * Fetches a RODiT token by ID from the NEAR blockchain.
434
+ *
435
+ * An empty RODiT (no token_id) is returned only when the view call completes
436
+ * and the contract decodes to JSON null (token absent). HTTP failures,
437
+ * JSON-RPC errors, credit exhaustion, WASM errors, and undecodable results
438
+ * throw NearRpcError.
439
+ *
345
440
  * @param {string} roditid - RODiT token ID to fetch
346
441
  * @returns {Promise<RODiT>} RODiT token object
442
+ * @throws {NearRpcError} When the NEAR RPC call does not yield a completed view
347
443
  */
348
444
  async function nearorg_rpc_tokenfromroditid(roditid) {
349
445
  const requestId = ulid();
@@ -423,13 +519,16 @@ const PayloadNEP413Schema = {
423
519
 
424
520
  if (!response.ok) {
425
521
  const duration = Date.now() - startTime;
522
+ const providerMessage = await readResponseErrorMessage(response);
523
+ const httpError = nearRpcFailure(response.status, providerMessage);
426
524
 
427
525
  // Add metric for failed RPC calls
428
526
  logger.metric("near_rpc_calls", fetchDuration, {
429
527
  result: "failure",
430
- reason: `HTTP error from blockchain RPC: status ${response.status}`,
528
+ reason: httpError.message,
431
529
  status_code: response.status,
432
530
  method: "rodit_token",
531
+ credit_failure: httpError.creditFailure ? "true" : "false",
433
532
  });
434
533
 
435
534
  logErrorWithMetrics(
@@ -437,27 +536,37 @@ const PayloadNEP413Schema = {
437
536
  {
438
537
  ...baseContext,
439
538
  statusCode: response.status,
539
+ providerMessage: httpError.providerMessage,
540
+ creditFailure: httpError.creditFailure,
440
541
  duration,
441
542
  result: 'failure',
442
- reason: `HTTP error from blockchain RPC: status ${response.status}`
543
+ reason: httpError.message
443
544
  },
444
- new Error(`HTTP error! status: ${response.status}`),
545
+ httpError,
445
546
  "near_rpc_http_error",
446
547
  {
447
548
  result: "failure",
448
- reason: `HTTP error from blockchain RPC: status ${response.status}`,
549
+ reason: httpError.message,
449
550
  status_code: response.status,
450
551
  method: "rodit_token",
451
552
  duration
452
553
  }
453
554
  );
454
555
 
455
- return new RODiT();
556
+ throw httpError;
456
557
  }
457
558
 
458
559
  const parseStartTime = Date.now();
459
560
  const responseText = await response.text();
460
- const parsedJson = JSON.parse(responseText);
561
+ let parsedJson;
562
+ try {
563
+ parsedJson = JSON.parse(responseText);
564
+ } catch (error) {
565
+ throw nearRpcFailure(
566
+ response.status,
567
+ `Failed to parse NEAR RPC response: ${error.message}`
568
+ );
569
+ }
461
570
  const parseDuration = Date.now() - parseStartTime;
462
571
 
463
572
  logger.debugWithContext("RPC response parsed", {
@@ -466,15 +575,60 @@ const PayloadNEP413Schema = {
466
575
  hasResult: !!parsedJson.result
467
576
  });
468
577
 
578
+ if (parsedJson.error) {
579
+ const duration = Date.now() - startTime;
580
+ const rpcError = nearRpcFailure(
581
+ response.status,
582
+ describeRpcError(parsedJson.error)
583
+ );
584
+
585
+ logger.metric("near_rpc_calls", fetchDuration, {
586
+ result: "failure",
587
+ reason: rpcError.message,
588
+ status_code: response.status,
589
+ method: "rodit_token",
590
+ credit_failure: rpcError.creditFailure ? "true" : "false",
591
+ });
592
+
593
+ logErrorWithMetrics(
594
+ "JSON-RPC error from blockchain RPC",
595
+ {
596
+ ...baseContext,
597
+ statusCode: response.status,
598
+ rpcError: parsedJson.error,
599
+ providerMessage: rpcError.providerMessage,
600
+ creditFailure: rpcError.creditFailure,
601
+ duration,
602
+ result: 'failure',
603
+ reason: rpcError.message
604
+ },
605
+ rpcError,
606
+ "near_rpc_jsonrpc_error",
607
+ {
608
+ result: "failure",
609
+ reason: rpcError.message,
610
+ status_code: response.status,
611
+ method: "rodit_token",
612
+ duration
613
+ }
614
+ );
615
+
616
+ throw rpcError;
617
+ }
618
+
469
619
  if (parsedJson.result && parsedJson.result.error) {
470
620
  const duration = Date.now() - startTime;
621
+ const wasmText = typeof parsedJson.result.error === "string"
622
+ ? parsedJson.result.error
623
+ : JSON.stringify(parsedJson.result.error);
624
+ const wasmError = nearRpcFailure(response.status, `WASM execution error: ${wasmText}`);
471
625
 
472
626
  // Add metric for WASM errors
473
627
  logger.metric("near_rpc_wasm_errors", 1, {
474
628
  method: "rodit_token",
475
629
  rodit_id: roditid,
476
630
  result: 'failure',
477
- reason: `WASM execution error: ${parsedJson.result.error}`
631
+ reason: wasmError.message
478
632
  });
479
633
 
480
634
  logErrorWithMetrics(
@@ -484,25 +638,26 @@ const PayloadNEP413Schema = {
484
638
  wasmError: parsedJson.result.error,
485
639
  duration,
486
640
  result: 'failure',
487
- reason: `WASM execution error: ${parsedJson.result.error}`
641
+ reason: wasmError.message
488
642
  },
489
- new Error(`WASM execution error: ${parsedJson.result.error}`),
643
+ wasmError,
490
644
  "near_rpc_wasm_error",
491
645
  {
492
646
  method: "rodit_token",
493
647
  rodit_id: roditid,
494
648
  duration,
495
649
  result: 'failure',
496
- reason: `WASM execution error: ${parsedJson.result.error}`
650
+ reason: wasmError.message
497
651
  }
498
652
  );
499
653
 
500
- return new RODiT();
654
+ throw wasmError;
501
655
  }
502
656
 
503
- const resultArray = parsedJson.result.result;
657
+ const resultArray = parsedJson.result && parsedJson.result.result;
504
658
  if (!Array.isArray(resultArray)) {
505
659
  const duration = Date.now() - startTime;
660
+ const formatError = nearRpcFailure(response.status, "Result is not an array");
506
661
 
507
662
  // Add metric for format errors
508
663
  logger.metric("near_rpc_format_errors", 1, {
@@ -510,7 +665,7 @@ const PayloadNEP413Schema = {
510
665
  rodit_id: roditid,
511
666
  error_type: "invalid_array",
512
667
  result: 'failure',
513
- reason: 'Invalid result format: not an array'
668
+ reason: formatError.message
514
669
  });
515
670
 
516
671
  logErrorWithMetrics(
@@ -520,9 +675,9 @@ const PayloadNEP413Schema = {
520
675
  resultType: typeof resultArray,
521
676
  duration,
522
677
  result: 'failure',
523
- reason: 'Invalid result format: not an array'
678
+ reason: formatError.message
524
679
  },
525
- new Error("Result is not an array"),
680
+ formatError,
526
681
  "near_rpc_format_error",
527
682
  {
528
683
  method: "rodit_token",
@@ -530,11 +685,11 @@ const PayloadNEP413Schema = {
530
685
  error_type: "invalid_array",
531
686
  duration,
532
687
  result: 'failure',
533
- reason: 'Invalid result format: not an array'
688
+ reason: formatError.message
534
689
  }
535
690
  );
536
691
 
537
- return new RODiT();
692
+ throw formatError;
538
693
  }
539
694
 
540
695
  const decodeStartTime = Date.now();
@@ -544,24 +699,28 @@ const PayloadNEP413Schema = {
544
699
  try {
545
700
  parsed = JSON.parse(resultString);
546
701
  } catch (error) {
702
+ const parseError = nearRpcFailure(
703
+ response.status,
704
+ `Failed to parse RODiT data: ${error.message}`
705
+ );
547
706
  logErrorWithMetrics(
548
707
  "Failed to parse RODiT data",
549
708
  {
550
709
  ...baseContext,
551
710
  error: error.message,
552
711
  result: 'failure',
553
- reason: `Failed to parse RODiT data: ${error.message}`
712
+ reason: parseError.message
554
713
  },
555
- error,
714
+ parseError,
556
715
  "rodit_parse_error",
557
716
  {
558
717
  error_type: error.name || "Unknown",
559
718
  rodit_id: roditid,
560
719
  result: 'failure',
561
- reason: `Failed to parse RODiT data: ${error.message}`
720
+ reason: parseError.message
562
721
  }
563
722
  );
564
- return new RODiT();
723
+ throw parseError;
565
724
  }
566
725
 
567
726
  const decodeDuration = Date.now() - decodeStartTime;
@@ -610,13 +769,18 @@ const PayloadNEP413Schema = {
610
769
  roditMetadata: rodit.metadata
611
770
  });
612
771
  } else {
772
+ const formatError = nearRpcFailure(
773
+ response.status,
774
+ "Invalid RODiT data format from blockchain"
775
+ );
613
776
  logger.warnWithContext("Invalid RODiT data format", {
614
777
  ...baseContext,
615
778
  parsedType: typeof parsed,
616
779
  parsedValue: parsed,
617
780
  result: 'failure',
618
- reason: 'Invalid RODiT data format from blockchain'
781
+ reason: formatError.message
619
782
  });
783
+ throw formatError;
620
784
  }
621
785
 
622
786
  const totalDuration = Date.now() - startTime;
@@ -675,7 +839,7 @@ const PayloadNEP413Schema = {
675
839
  }
676
840
  );
677
841
 
678
- return new RODiT();
842
+ throw error;
679
843
  }
680
844
  }
681
845
 
@@ -1697,6 +1861,7 @@ async function fetchWithRetry(url, options, maxRetries = 3) {
1697
1861
 
1698
1862
  module.exports = {
1699
1863
  RODiT,
1864
+ NearRpcError,
1700
1865
  PayloadNEP413,
1701
1866
  PayloadNEP413Schema,
1702
1867
  CONSTANTS,
@@ -1343,6 +1343,191 @@ async function login_client(req, res) {
1343
1343
  }
1344
1344
  }
1345
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
+
1346
1531
  /**
1347
1532
  * Handle client login with NEP-413 standard
1348
1533
  *
@@ -2474,5 +2659,6 @@ module.exports = {
2474
2659
  login_client_withnep413,
2475
2660
  logout_client,
2476
2661
  logout_server,
2662
+ refresh_client,
2477
2663
  createLoginTimestampChallenge,
2478
2664
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rodit/rodit-auth-be",
3
- "version": "9.16.2",
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: