@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 +28 -0
- package/README.md +9 -5
- package/index.js +104 -9
- package/lib/auth/authentication.js +45 -10
- package/lib/auth/login-nonce.js +164 -0
- package/lib/auth/tokenservice.js +110 -32
- package/lib/blockchain/blockchainservice.js +190 -25
- package/lib/middleware/authenticationmw.js +298 -16
- package/lib/middleware/webhookhandlermw.js +25 -7
- package/package.json +1 -1
- package/services/configsdk.js +5 -3
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1844
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
65
|
+
// Legacy: identifier + timestamp_iso. With nonce: identifier + timestamp_iso + nonce.
|
|
60
66
|
const timeString = await unixTimeToDateString(peertimestamp);
|
|
61
|
-
const roditidandtimestamp =
|
|
62
|
-
peerroditid
|
|
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
|
|
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:
|
|
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
|
+
};
|