@rodit/rodit-auth-be 9.16.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 +17 -0
- package/README.md +9 -5
- package/index.js +10 -2
- package/lib/auth/authentication.js +45 -10
- package/lib/auth/login-nonce.js +164 -0
- package/lib/middleware/authenticationmw.js +112 -16
- package/lib/middleware/webhookhandlermw.js +25 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
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
|
+
|
|
5
22
|
## [9.16.0] — 2026-09-14
|
|
6
23
|
|
|
7
24
|
### 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.16.2 · **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
|
@@ -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.';
|
|
@@ -1938,6 +1945,7 @@ module.exports = {
|
|
|
1938
1945
|
login_portal,
|
|
1939
1946
|
login_server,
|
|
1940
1947
|
logout_server,
|
|
1948
|
+
createLoginTimestampChallenge,
|
|
1941
1949
|
validate_jwt_token_be,
|
|
1942
1950
|
generate_jwt_token,
|
|
1943
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
|
-
//
|
|
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
|
+
};
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
1642
|
-
signatureIdentifier
|
|
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 {
|
|
1976
|
-
|
|
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 =
|
|
2014
|
-
signatureIdentifier
|
|
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 = {
|
|
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
|
-
//
|
|
1212
|
-
//
|
|
1213
|
-
const
|
|
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
|
|