@rodit/rodit-auth-be 9.15.0 → 9.16.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +25 -9
- package/index.js +10 -8
- package/lib/auth/authentication.js +45 -150
- package/lib/auth/login-nonce.js +164 -0
- package/lib/auth/tokenservice.js +1 -45
- package/lib/middleware/authenticationmw.js +112 -16
- package/lib/middleware/webhookhandlermw.js +25 -7
- package/package.json +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@rodit/rodit-auth-be` are documented here.
|
|
4
4
|
|
|
5
|
+
## [9.16.2] — 2026-09-19
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **`send_webhook` URL join:** append `endpoint` (e.g. `/hooks/wake`) only when
|
|
10
|
+
peer `webhook_url` / `rodit_webhookurl` has no path. A URL that already
|
|
11
|
+
includes a path (e.g. `host:7443/hooks/agent`) is used as-is, so paths are
|
|
12
|
+
no longer doubled.
|
|
13
|
+
|
|
14
|
+
## [9.16.1] — 2026-09-18
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **Docs:** Clarify that peer `webhook_url` / `rodit_webhookurl` is a base
|
|
19
|
+
(`host` or `host:port` only). The SDK appends the endpoint path; including
|
|
20
|
+
`/hooks/wake` or `/hooks/agent` in the metadata doubles the path.
|
|
21
|
+
|
|
22
|
+
## [9.16.0] — 2026-09-14
|
|
23
|
+
|
|
24
|
+
### Removed
|
|
25
|
+
|
|
26
|
+
- **`verify_rodit_isactive`** — DNS TXT revocation check
|
|
27
|
+
(`<token_id>.revoked.<domain>`). The function short-circuited with an
|
|
28
|
+
unconditional `return true` while debugging, so its body was unreachable and
|
|
29
|
+
no RODiT could be rejected as revoked. Removed along with its call sites in
|
|
30
|
+
`verify_peer_rodit` and `thorough_validate_jwt_token_be`, and the
|
|
31
|
+
`RODIT_REVOKED` / `SERVER_RODIT_REVOKED` failure codes they emitted.
|
|
32
|
+
No behavior change; the SDK now has no revocation check.
|
|
33
|
+
`verify_rodit_isactive_fe` in `@rodit/rodit-auth-fe` is unaffected.
|
|
34
|
+
|
|
5
35
|
## [9.15.0] — 2026-08-11
|
|
6
36
|
|
|
7
37
|
### Added
|
package/README.md
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @rodit/rodit-auth-be
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Node.js / Express SDK for RODiT-based mutual authentication, authorization, session management, and webhooks.
|
|
4
4
|
|
|
5
|
-
**
|
|
6
|
-
**
|
|
7
|
-
**Author:** Discernible IO
|
|
5
|
+
**npm:** [https://www.npmjs.com/package/@rodit/rodit-auth-be](https://www.npmjs.com/package/@rodit/rodit-auth-be)
|
|
6
|
+
**Monorepo:** [discernible-io/rodit-sdk](https://github.com/discernible-io/rodit-sdk) (this directory)
|
|
7
|
+
**Version:** 9.16.2 · **License:** Proprietary · **Author:** Discernible IO
|
|
8
|
+
|
|
9
|
+
## Production deployments
|
|
10
|
+
|
|
11
|
+
This package is the server runtime behind RODiT-powered APIs, including:
|
|
12
|
+
|
|
13
|
+
| Deployment | Role |
|
|
14
|
+
|------------|------|
|
|
15
|
+
| [api.identyclaw.com](https://api.identyclaw.com) | IdentyClaw platform API ([source: api-idc](https://github.com/discernible-io/api-idc)) |
|
|
16
|
+
| [api.lastcradle.io](https://api.lastcradle.io) | Last Cradle family API |
|
|
17
|
+
| Custom / federated APIs | Same SDK; sibling APIs in one token family support [federated login](#federated-login-same-family-different-api-url) |
|
|
18
|
+
|
|
19
|
+
Browser and wallet clients for these APIs use the companion package [`@rodit/rodit-auth-fe`](https://www.npmjs.com/package/@rodit/rodit-auth-fe).
|
|
8
20
|
|
|
9
21
|
**Login `POST` /api/login:** Use **`accountid`** (or **`roditid`**), **`timestamp`**, and **`base64url_signature`**. Sign UTF-8 bytes of `identifier + timestamp_iso`, and reject deprecated keys such as **`signature`** and **`account_id`**. See [CHANGELOG.md](./CHANGELOG.md).
|
|
10
22
|
|
|
@@ -1185,7 +1197,7 @@ const jwtDuration = metadata.jwt_duration; // JWT expiration time
|
|
|
1185
1197
|
const maxRequests = metadata.max_requests; // Rate limit
|
|
1186
1198
|
const maxRqWindow = metadata.maxrq_window; // Rate limit window
|
|
1187
1199
|
const apiEndpoint = metadata.subjectuniqueidentifier_url; // API URL
|
|
1188
|
-
const webhookUrl = metadata.webhook_url; // Webhook
|
|
1200
|
+
const webhookUrl = metadata.webhook_url; // Webhook host[:port] (optional path)
|
|
1189
1201
|
// Parse permissioned routes
|
|
1190
1202
|
const permissionedRoutes = JSON.parse(metadata.permissioned_routes || '{}')
|
|
1191
1203
|
// Use SDK config for application settings
|
|
@@ -1530,12 +1542,12 @@ Peer RODiT metadata:
|
|
|
1530
1542
|
|
|
1531
1543
|
```json
|
|
1532
1544
|
{
|
|
1533
|
-
"webhook_url": "hooks.example.com",
|
|
1545
|
+
"webhook_url": "hooks.example.com:7443",
|
|
1534
1546
|
"webhook_cidr": "0.0.0.0/0"
|
|
1535
1547
|
}
|
|
1536
1548
|
```
|
|
1537
1549
|
|
|
1538
|
-
- `webhook_url` — host (optional path
|
|
1550
|
+
- `webhook_url` — `host` or `host:port` (scheme optional), optionally with a path. If **no path** is present, the SDK appends `endpoint` (e.g. `https://hooks.example.com:7443/hooks/wake`). If a path is already present (e.g. `…:7443/hooks/agent`), that path is used as-is and nothing is appended.
|
|
1539
1551
|
- `webhook_cidr` — allowlist for the **resolved** destination IP (`0.0.0.0/0` / empty = any public IP still subject to the SSRF blocklist)
|
|
1540
1552
|
|
|
1541
1553
|
Outbound rejection codes (returned as `{ isValid: false, error: { code, message, requestId } }`):
|
|
@@ -2378,6 +2390,10 @@ const webhookHandler = roditClient.getWebhookHandler()
|
|
|
2378
2390
|
|
|
2379
2391
|
Send a webhook notification to the peer URL in `req.user.rodit_webhookurl`.
|
|
2380
2392
|
|
|
2393
|
+
`rodit_webhookurl` is typically a base (`host` or `host:port`). When it has
|
|
2394
|
+
no path, the SDK appends `options.endpoint` (default `/webhook`). When a path
|
|
2395
|
+
is already present, that path is used as-is (endpoint is not appended).
|
|
2396
|
+
|
|
2381
2397
|
Outbound delivery rejects private / loopback / metadata destinations (and
|
|
2382
2398
|
resolved addresses), enforces peer JWT `rodit_webhookcidr` when set, and pins
|
|
2383
2399
|
DNS for the request. `SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY` still applies
|
|
@@ -2473,7 +2489,7 @@ When you call `roditClient.getConfigOwnRodit()`, you get access to these metadat
|
|
|
2473
2489
|
| `subjectuniqueidentifier_url` | string | Primary API service endpoint |
|
|
2474
2490
|
| `userselected_dn` | string | User-selected display name |
|
|
2475
2491
|
| `webhook_cidr` | string | Allowlist CIDR(s) for outbound webhook resolved IPs (`0.0.0.0/0` = any public IP; private ranges are still blocked by SSRF controls) |
|
|
2476
|
-
| `webhook_url` | string | Peer webhook host
|
|
2492
|
+
| `webhook_url` | string | Peer webhook host (`host` or `host:port`, optional path). Used as `rodit_webhookurl` in issued JWTs; the SDK appends the endpoint only when no path is present |
|
|
2477
2493
|
|
|
2478
2494
|
## Best Practices
|
|
2479
2495
|
|
package/index.js
CHANGED
|
@@ -21,7 +21,8 @@ const {
|
|
|
21
21
|
login_client_withnep413,
|
|
22
22
|
login_portal,
|
|
23
23
|
login_server,
|
|
24
|
-
logout_server
|
|
24
|
+
logout_server,
|
|
25
|
+
createLoginTimestampChallenge,
|
|
25
26
|
} = require('./lib/middleware/authenticationmw');
|
|
26
27
|
|
|
27
28
|
const {
|
|
@@ -1137,7 +1138,13 @@ class RoditClient {
|
|
|
1137
1138
|
errorMessage += '\n→ [CLIENT REJECTED] The RODiT is missing required metadata. The RODiT may be corrupted or incomplete.';
|
|
1138
1139
|
break;
|
|
1139
1140
|
case 'LOGIN_BASE64URL_SIGNATURE_INVALID':
|
|
1140
|
-
errorMessage += '\n→ [CLIENT REJECTED] The base64url login signature did not verify. Sign UTF-8 (roditid or accountid + canonical timestamp_iso) with the correct NEAR account private key; encoding must be base64url.';
|
|
1141
|
+
errorMessage += '\n→ [CLIENT REJECTED] The base64url login signature did not verify. Sign UTF-8 (roditid or accountid + canonical timestamp_iso[, + nonce when present]) with the correct NEAR account private key; encoding must be base64url.';
|
|
1142
|
+
break;
|
|
1143
|
+
case 'LOGIN_NONCE_REPLAY':
|
|
1144
|
+
errorMessage += '\n→ [CLIENT REJECTED] The login nonce was already used. Fetch a fresh challenge from GET /api/login/timestamp and sign again.';
|
|
1145
|
+
break;
|
|
1146
|
+
case 'INVALID_LOGIN_NONCE':
|
|
1147
|
+
errorMessage += '\n→ [CLIENT REJECTED] The login nonce is malformed. Use the nonce string from GET /api/login/timestamp (base64url or hex).';
|
|
1141
1148
|
break;
|
|
1142
1149
|
case 'RODIT_FAMILY_MISMATCH':
|
|
1143
1150
|
errorMessage += '\n→ [CLIENT REJECTED] Your RODiT does not belong to the same family as the server. You may need a different RODiT.';
|
|
@@ -1145,9 +1152,6 @@ class RoditClient {
|
|
|
1145
1152
|
case 'RODIT_NOT_LIVE':
|
|
1146
1153
|
errorMessage += '\n→ [CLIENT REJECTED] Your RODiT is expired or not yet valid. Check the validity period.';
|
|
1147
1154
|
break;
|
|
1148
|
-
case 'RODIT_REVOKED':
|
|
1149
|
-
errorMessage += '\n→ [CLIENT REJECTED] Your RODiT has been revoked and is no longer valid.';
|
|
1150
|
-
break;
|
|
1151
1155
|
case 'SMART_CONTRACT_NOT_TRUSTED':
|
|
1152
1156
|
errorMessage += '\n→ [CLIENT REJECTED] The smart contract that issued your RODiT is not trusted by this server.';
|
|
1153
1157
|
break;
|
|
@@ -1160,9 +1164,6 @@ class RoditClient {
|
|
|
1160
1164
|
case 'SERVER_RODIT_NOT_LIVE':
|
|
1161
1165
|
errorMessage += '\n→ [SERVER REJECTED] The server\'s RODiT is expired or not yet valid. Contact the server administrator.';
|
|
1162
1166
|
break;
|
|
1163
|
-
case 'SERVER_RODIT_REVOKED':
|
|
1164
|
-
errorMessage += '\n→ [SERVER REJECTED] The server\'s RODiT has been revoked. Contact the server administrator.';
|
|
1165
|
-
break;
|
|
1166
1167
|
case 'SERVER_SMART_CONTRACT_NOT_TRUSTED':
|
|
1167
1168
|
errorMessage += '\n→ [SERVER REJECTED] The server\'s issuing smart contract is not trusted by your client. Update your trust configuration.';
|
|
1168
1169
|
break;
|
|
@@ -1944,6 +1945,7 @@ module.exports = {
|
|
|
1944
1945
|
login_portal,
|
|
1945
1946
|
login_server,
|
|
1946
1947
|
logout_server,
|
|
1948
|
+
createLoginTimestampChallenge,
|
|
1947
1949
|
validate_jwt_token_be,
|
|
1948
1950
|
generate_jwt_token,
|
|
1949
1951
|
normalizeUrlWithoutPort,
|
|
@@ -23,12 +23,17 @@ const {
|
|
|
23
23
|
PayloadNEP413Schema,
|
|
24
24
|
CONSTANTS,
|
|
25
25
|
} = require("../blockchain/blockchainservice");
|
|
26
|
+
const {
|
|
27
|
+
buildLoginSigningMessageBytes,
|
|
28
|
+
tryConsumeLoginNonce,
|
|
29
|
+
} = require("./login-nonce");
|
|
26
30
|
|
|
27
31
|
async function verify_rodit_ownership(
|
|
28
32
|
peerroditid,
|
|
29
33
|
peertimestamp,
|
|
30
34
|
peerroditid_base64url_signature,
|
|
31
|
-
peer_rodit
|
|
35
|
+
peer_rodit,
|
|
36
|
+
peerNonce = null
|
|
32
37
|
) {
|
|
33
38
|
const requestId = ulid();
|
|
34
39
|
const startTime = Date.now();
|
|
@@ -41,6 +46,7 @@ async function verify_rodit_ownership(
|
|
|
41
46
|
requestId,
|
|
42
47
|
peerRoditId: peerroditid,
|
|
43
48
|
timestamp: peertimestamp,
|
|
49
|
+
hasNonce: !!peerNonce,
|
|
44
50
|
}
|
|
45
51
|
);
|
|
46
52
|
|
|
@@ -56,16 +62,21 @@ async function verify_rodit_ownership(
|
|
|
56
62
|
// DO NOT DELETE THE FOLLOWING COMMENT
|
|
57
63
|
/* Maybe for NEP413 compatibility, the following line added "NEAR" before peerroditid */
|
|
58
64
|
|
|
59
|
-
//
|
|
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
|
|
|
@@ -993,32 +1028,6 @@ async function verify_rodit_ownership(
|
|
|
993
1028
|
};
|
|
994
1029
|
}
|
|
995
1030
|
|
|
996
|
-
const activeStart = Date.now();
|
|
997
|
-
const isActive = await verify_rodit_isactive(
|
|
998
|
-
peer_rodit.token_id,
|
|
999
|
-
config_own_rodit.own_rodit.metadata.subjectuniqueidentifier_url
|
|
1000
|
-
);
|
|
1001
|
-
const activeDuration = Date.now() - activeStart;
|
|
1002
|
-
|
|
1003
|
-
logger.debug("Active verification completed", {
|
|
1004
|
-
requestId,
|
|
1005
|
-
activeDuration,
|
|
1006
|
-
isActive,
|
|
1007
|
-
});
|
|
1008
|
-
|
|
1009
|
-
if (!isActive) {
|
|
1010
|
-
logger.warn("RODiT active verification failed", {
|
|
1011
|
-
requestId,
|
|
1012
|
-
roditId: peerroditid,
|
|
1013
|
-
});
|
|
1014
|
-
return {
|
|
1015
|
-
peer_rodit,
|
|
1016
|
-
goodrodit: false,
|
|
1017
|
-
failureReason: "RODIT_REVOKED",
|
|
1018
|
-
failureMessage: "RODiT has been revoked"
|
|
1019
|
-
};
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
1031
|
const trustedStart = Date.now();
|
|
1023
1032
|
const isTrusted = await verify_rodit_istrusted_issuingsmartcontract(
|
|
1024
1033
|
config_own_rodit.own_rodit.metadata.subjectuniqueidentifier_url
|
|
@@ -1241,119 +1250,6 @@ async function verify_rodit_ownership(
|
|
|
1241
1250
|
}
|
|
1242
1251
|
}
|
|
1243
1252
|
|
|
1244
|
-
async function verify_rodit_isactive(tokenId, ownsubjectuniqueidentifier_url) {
|
|
1245
|
-
const requestId = ulid();
|
|
1246
|
-
const startTime = Date.now();
|
|
1247
|
-
|
|
1248
|
-
// WHILE DEBUGGING TEMPORARY FIX DO NOT REMOVE THIS LINE EVER WITHOUT PERMISSION
|
|
1249
|
-
return true;
|
|
1250
|
-
|
|
1251
|
-
logger.debug("Checking RODiT activity status", {
|
|
1252
|
-
component: "RoditAuth",
|
|
1253
|
-
method: "verify_rodit_isactive",
|
|
1254
|
-
requestId,
|
|
1255
|
-
tokenId,
|
|
1256
|
-
subjectUrl: ownsubjectuniqueidentifier_url,
|
|
1257
|
-
});
|
|
1258
|
-
|
|
1259
|
-
const domainandextensionRegex =
|
|
1260
|
-
/(?:https?:\/\/)?(?:www\.)?([a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)/i;
|
|
1261
|
-
|
|
1262
|
-
const match = ownsubjectuniqueidentifier_url.match(domainandextensionRegex);
|
|
1263
|
-
|
|
1264
|
-
if (match) {
|
|
1265
|
-
const domainandextension = match[1];
|
|
1266
|
-
const revokingDnsEntry = `${tokenId}.revoked.${domainandextension}`;
|
|
1267
|
-
|
|
1268
|
-
logger.debug("Checking DNS revocation entry", {
|
|
1269
|
-
requestId,
|
|
1270
|
-
domain: domainandextension,
|
|
1271
|
-
revokingDnsEntry,
|
|
1272
|
-
});
|
|
1273
|
-
|
|
1274
|
-
try {
|
|
1275
|
-
const dnsStart = Date.now();
|
|
1276
|
-
const resolver = new Resolver();
|
|
1277
|
-
await resolver.resolveTxt(revokingDnsEntry);
|
|
1278
|
-
const dnsDuration = Date.now() - dnsStart;
|
|
1279
|
-
const totalDuration = Date.now() - startTime;
|
|
1280
|
-
|
|
1281
|
-
logger.info("RODiT revocation found", {
|
|
1282
|
-
component: "AuthServices",
|
|
1283
|
-
method: "verify_rodit_isactive",
|
|
1284
|
-
requestId,
|
|
1285
|
-
duration: totalDuration,
|
|
1286
|
-
dnsDuration,
|
|
1287
|
-
tokenId,
|
|
1288
|
-
domain: domainandextension,
|
|
1289
|
-
revokingDnsEntry,
|
|
1290
|
-
isActive: false,
|
|
1291
|
-
});
|
|
1292
|
-
|
|
1293
|
-
// Add metrics for revoked tokens
|
|
1294
|
-
logger.metric &&
|
|
1295
|
-
logger.metric("rodit_revocation_checks", totalDuration, {
|
|
1296
|
-
result: "revoked",
|
|
1297
|
-
token_id: tokenId,
|
|
1298
|
-
});
|
|
1299
|
-
|
|
1300
|
-
return false;
|
|
1301
|
-
} catch (error) {
|
|
1302
|
-
// DNS error usually means no revocation entry found, which is good
|
|
1303
|
-
const dnsDuration = Date.now() - dnsStart || 0;
|
|
1304
|
-
const totalDuration = Date.now() - startTime;
|
|
1305
|
-
|
|
1306
|
-
logger.debug("No revocation found for RODiT", {
|
|
1307
|
-
requestId,
|
|
1308
|
-
dnsDuration,
|
|
1309
|
-
tokenId,
|
|
1310
|
-
error: error.code,
|
|
1311
|
-
});
|
|
1312
|
-
|
|
1313
|
-
logger.info("RODiT is active", {
|
|
1314
|
-
component: "AuthServices",
|
|
1315
|
-
method: "verify_rodit_isactive",
|
|
1316
|
-
requestId,
|
|
1317
|
-
duration: totalDuration,
|
|
1318
|
-
dnsDuration,
|
|
1319
|
-
tokenId,
|
|
1320
|
-
domain: domainandextension,
|
|
1321
|
-
isActive: true,
|
|
1322
|
-
});
|
|
1323
|
-
|
|
1324
|
-
// Add metrics for active tokens
|
|
1325
|
-
logger.metric &&
|
|
1326
|
-
logger.metric("rodit_revocation_checks", totalDuration, {
|
|
1327
|
-
result: "active",
|
|
1328
|
-
token_id: tokenId,
|
|
1329
|
-
});
|
|
1330
|
-
|
|
1331
|
-
return true;
|
|
1332
|
-
}
|
|
1333
|
-
} else {
|
|
1334
|
-
const duration = Date.now() - startTime;
|
|
1335
|
-
|
|
1336
|
-
logger.warn("Unable to parse domain from URL", {
|
|
1337
|
-
component: "AuthServices",
|
|
1338
|
-
method: "verify_rodit_isactive",
|
|
1339
|
-
requestId,
|
|
1340
|
-
duration,
|
|
1341
|
-
tokenId,
|
|
1342
|
-
subjectUrl: ownsubjectuniqueidentifier_url,
|
|
1343
|
-
});
|
|
1344
|
-
|
|
1345
|
-
// Add metrics for parsing errors
|
|
1346
|
-
logger.metric &&
|
|
1347
|
-
logger.metric("rodit_revocation_checks", duration, {
|
|
1348
|
-
result: "parse_error",
|
|
1349
|
-
token_id: tokenId,
|
|
1350
|
-
});
|
|
1351
|
-
|
|
1352
|
-
// Default to allowing the token if domain parsing fails
|
|
1353
|
-
return true;
|
|
1354
|
-
}
|
|
1355
|
-
}
|
|
1356
|
-
|
|
1357
1253
|
async function verify_rodit_istrusted_issuingsmartcontract(
|
|
1358
1254
|
ownsubjectuniqueidentifier_url
|
|
1359
1255
|
) {
|
|
@@ -1963,9 +1859,8 @@ module.exports = {
|
|
|
1963
1859
|
resolve_peer_rodit_for_login,
|
|
1964
1860
|
verify_peer_rodit,
|
|
1965
1861
|
validateTimestamp,
|
|
1966
|
-
verify_rodit_isactive,
|
|
1967
1862
|
verify_rodit_isamatch,
|
|
1968
1863
|
verify_rodit_islive,
|
|
1969
1864
|
verify_rodit_istrusted_issuingsmartcontract,
|
|
1970
|
-
authenticate_webhook
|
|
1865
|
+
authenticate_webhook,
|
|
1971
1866
|
};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login challenge nonce helpers (optional additive field for POST /api/login).
|
|
3
|
+
* When a nonce is present it is bound into the signed payload and single-use.
|
|
4
|
+
* Copyright (c) 2026 Discernible IO. All rights reserved.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const crypto = require("crypto");
|
|
8
|
+
const config = require("../../services/configsdk");
|
|
9
|
+
const { unixTimeToDateString } = require("../../services/utils");
|
|
10
|
+
|
|
11
|
+
const LOGIN_NONCE_BYTES = 16;
|
|
12
|
+
const LOGIN_NONCE_MIN_LEN = 8;
|
|
13
|
+
const LOGIN_NONCE_MAX_LEN = 128;
|
|
14
|
+
|
|
15
|
+
/** @type {Map<string, { expiresAtMs: number, firstSeenAtMs: number }>} */
|
|
16
|
+
const loginNonceReplayCache = new Map();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Cryptographic login nonce for challenges (base64url, 128-bit).
|
|
20
|
+
* @returns {string}
|
|
21
|
+
*/
|
|
22
|
+
function generateLoginNonce() {
|
|
23
|
+
return crypto.randomBytes(LOGIN_NONCE_BYTES).toString("base64url");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Normalize optional login nonce from a request/challenge body.
|
|
28
|
+
* Missing/empty → null (legacy timestamp-only login). Invalid → { error }.
|
|
29
|
+
*
|
|
30
|
+
* @param {*} rawNonce
|
|
31
|
+
* @returns {{ nonce: string|null, error?: string, errorCode?: string }}
|
|
32
|
+
*/
|
|
33
|
+
function normalizeOptionalLoginNonce(rawNonce) {
|
|
34
|
+
if (rawNonce === undefined || rawNonce === null) {
|
|
35
|
+
return { nonce: null };
|
|
36
|
+
}
|
|
37
|
+
if (typeof rawNonce !== "string") {
|
|
38
|
+
return {
|
|
39
|
+
nonce: null,
|
|
40
|
+
errorCode: "INVALID_LOGIN_NONCE",
|
|
41
|
+
error: "Login nonce must be a string when provided",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const trimmed = rawNonce.trim();
|
|
45
|
+
if (trimmed.length === 0) {
|
|
46
|
+
return { nonce: null };
|
|
47
|
+
}
|
|
48
|
+
if (
|
|
49
|
+
trimmed.length < LOGIN_NONCE_MIN_LEN ||
|
|
50
|
+
trimmed.length > LOGIN_NONCE_MAX_LEN
|
|
51
|
+
) {
|
|
52
|
+
return {
|
|
53
|
+
nonce: null,
|
|
54
|
+
errorCode: "INVALID_LOGIN_NONCE",
|
|
55
|
+
error: `Login nonce length must be between ${LOGIN_NONCE_MIN_LEN} and ${LOGIN_NONCE_MAX_LEN} characters`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
// Reject whitespace / control characters inside the token.
|
|
59
|
+
if (!/^[A-Za-z0-9_-]+$/.test(trimmed)) {
|
|
60
|
+
return {
|
|
61
|
+
nonce: null,
|
|
62
|
+
errorCode: "INVALID_LOGIN_NONCE",
|
|
63
|
+
error:
|
|
64
|
+
"Login nonce must be base64url or hex (letters, digits, '-', '_' only)",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return { nonce: trimmed };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build UTF-8 bytes for login signature.
|
|
72
|
+
* Legacy: identifier + timestamp_iso
|
|
73
|
+
* With nonce: identifier + timestamp_iso + nonce
|
|
74
|
+
*
|
|
75
|
+
* @param {string} identifier
|
|
76
|
+
* @param {string} timeString - ISO timestamp string
|
|
77
|
+
* @param {string|null|undefined} nonce
|
|
78
|
+
* @returns {Uint8Array}
|
|
79
|
+
*/
|
|
80
|
+
function buildLoginSigningMessageBytes(identifier, timeString, nonce) {
|
|
81
|
+
const payload =
|
|
82
|
+
nonce && String(nonce).length > 0
|
|
83
|
+
? String(identifier) + String(timeString) + String(nonce)
|
|
84
|
+
: String(identifier) + String(timeString);
|
|
85
|
+
return new TextEncoder().encode(payload);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getLoginNonceReplayTtlMs() {
|
|
89
|
+
const maxAgeSeconds = Number(
|
|
90
|
+
config.get("API_DEFAULT_OPTIONS.TIMESTAMP_MAX_AGE", 300)
|
|
91
|
+
);
|
|
92
|
+
const seconds =
|
|
93
|
+
Number.isFinite(maxAgeSeconds) && maxAgeSeconds > 0 ? maxAgeSeconds : 300;
|
|
94
|
+
return seconds * 1000;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function cleanupExpiredLoginNonceEntries(nowMs = Date.now()) {
|
|
98
|
+
for (const [key, entry] of loginNonceReplayCache.entries()) {
|
|
99
|
+
if (!entry || entry.expiresAtMs <= nowMs) {
|
|
100
|
+
loginNonceReplayCache.delete(key);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function buildLoginNonceReplayKey(nonce) {
|
|
106
|
+
return String(nonce);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Atomically consume a login nonce. Returns false if already used.
|
|
111
|
+
* Call only after the signature that binds the nonce has verified.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} identifier - roditid or accountid (reserved for future scoped caches / logging)
|
|
114
|
+
* @param {string} nonce
|
|
115
|
+
* @returns {boolean} true if consumed (first use), false if replay
|
|
116
|
+
*/
|
|
117
|
+
function tryConsumeLoginNonce(identifier, nonce) {
|
|
118
|
+
if (!nonce) {
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
const nowMs = Date.now();
|
|
122
|
+
cleanupExpiredLoginNonceEntries(nowMs);
|
|
123
|
+
const key = buildLoginNonceReplayKey(nonce);
|
|
124
|
+
if (loginNonceReplayCache.has(key)) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
loginNonceReplayCache.set(key, {
|
|
128
|
+
expiresAtMs: nowMs + getLoginNonceReplayTtlMs(),
|
|
129
|
+
firstSeenAtMs: nowMs,
|
|
130
|
+
identifier: identifier ? String(identifier) : undefined,
|
|
131
|
+
});
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Host helper for GET /api/login/timestamp — issues timestamp + CSPRNG nonce.
|
|
137
|
+
* Clients that understand nonce will bind it into the signature; older clients ignore it.
|
|
138
|
+
*
|
|
139
|
+
* @returns {Promise<{ timestamp: number, timestamp_iso: string, nonce: string }>}
|
|
140
|
+
*/
|
|
141
|
+
async function createLoginTimestampChallenge() {
|
|
142
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
143
|
+
const timestamp_iso = await unixTimeToDateString(timestamp);
|
|
144
|
+
return {
|
|
145
|
+
timestamp,
|
|
146
|
+
timestamp_iso,
|
|
147
|
+
nonce: generateLoginNonce(),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Test/support: clear replay cache (does not affect production callers). */
|
|
152
|
+
function clearLoginNonceReplayCacheForTests() {
|
|
153
|
+
loginNonceReplayCache.clear();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = {
|
|
157
|
+
generateLoginNonce,
|
|
158
|
+
normalizeOptionalLoginNonce,
|
|
159
|
+
buildLoginSigningMessageBytes,
|
|
160
|
+
tryConsumeLoginNonce,
|
|
161
|
+
createLoginTimestampChallenge,
|
|
162
|
+
clearLoginNonceReplayCacheForTests,
|
|
163
|
+
buildLoginNonceReplayKey,
|
|
164
|
+
};
|
package/lib/auth/tokenservice.js
CHANGED
|
@@ -2085,7 +2085,7 @@ async function thorough_validate_jwt_token_be(token, requestId = ulid()) {
|
|
|
2085
2085
|
// Starting verification with more detailed logging
|
|
2086
2086
|
logger.debug("Starting verification checks", {
|
|
2087
2087
|
requestId,
|
|
2088
|
-
checks: ["match", "live", "
|
|
2088
|
+
checks: ["match", "live", "trusted"],
|
|
2089
2089
|
serviceProviderId: config_own_rodit.own_rodit.metadata.serviceprovider_id,
|
|
2090
2090
|
});
|
|
2091
2091
|
|
|
@@ -2093,7 +2093,6 @@ async function thorough_validate_jwt_token_be(token, requestId = ulid()) {
|
|
|
2093
2093
|
const {
|
|
2094
2094
|
verify_rodit_isamatch,
|
|
2095
2095
|
verify_rodit_islive,
|
|
2096
|
-
verify_rodit_isactive,
|
|
2097
2096
|
verify_rodit_istrusted_issuingsmartcontract
|
|
2098
2097
|
} = require("./authentication");
|
|
2099
2098
|
|
|
@@ -2188,49 +2187,6 @@ async function thorough_validate_jwt_token_be(token, requestId = ulid()) {
|
|
|
2188
2187
|
};
|
|
2189
2188
|
}
|
|
2190
2189
|
|
|
2191
|
-
// Perform active verification
|
|
2192
|
-
const activeStart = performance.now();
|
|
2193
|
-
const isActive = await verify_rodit_isactive(
|
|
2194
|
-
peer_rodit.token_id,
|
|
2195
|
-
config_own_rodit.own_rodit.metadata.subjectuniqueidentifier_url
|
|
2196
|
-
);
|
|
2197
|
-
const activeDuration = performance.now() - activeStart;
|
|
2198
|
-
|
|
2199
|
-
logger.debug("Active verification completed", {
|
|
2200
|
-
requestId,
|
|
2201
|
-
activeDuration,
|
|
2202
|
-
isActive,
|
|
2203
|
-
tokenId: peer_rodit.token_id,
|
|
2204
|
-
url: config_own_rodit.own_rodit.metadata.subjectuniqueidentifier_url,
|
|
2205
|
-
});
|
|
2206
|
-
|
|
2207
|
-
if (!isActive) {
|
|
2208
|
-
logger.warn("RODiT active verification failed", {
|
|
2209
|
-
component: "JwtAuth",
|
|
2210
|
-
method: "thorough_validate_jwt_token_be",
|
|
2211
|
-
requestId,
|
|
2212
|
-
duration: performance.now() - startTime,
|
|
2213
|
-
tokenId: peer_rodit.token_id,
|
|
2214
|
-
url: config_own_rodit.own_rodit.metadata.subjectuniqueidentifier_url,
|
|
2215
|
-
});
|
|
2216
|
-
|
|
2217
|
-
// Add metrics for failed active verification
|
|
2218
|
-
logger.metric &&
|
|
2219
|
-
logger.metric("jwt_thorough_validation", performance.now() - startTime, {
|
|
2220
|
-
result: "active_failed",
|
|
2221
|
-
token_jti: token.jti || "unknown",
|
|
2222
|
-
peer_rodit_id: peer_rodit.token_id,
|
|
2223
|
-
});
|
|
2224
|
-
|
|
2225
|
-
return {
|
|
2226
|
-
isValid: false,
|
|
2227
|
-
notAfter: null,
|
|
2228
|
-
error: "RODiT active verification failed",
|
|
2229
|
-
errorCode: "SERVER_RODIT_REVOKED",
|
|
2230
|
-
errorMessage: "Server's RODiT has been revoked"
|
|
2231
|
-
};
|
|
2232
|
-
}
|
|
2233
|
-
|
|
2234
2190
|
// Perform trusted verification
|
|
2235
2191
|
const trustedStart = performance.now();
|
|
2236
2192
|
const isTrusted = await verify_rodit_istrusted_issuingsmartcontract(
|
|
@@ -24,6 +24,11 @@ const {
|
|
|
24
24
|
verify_peer_rodit,
|
|
25
25
|
verify_rodit_ownership_withnep413
|
|
26
26
|
} = require("../auth/authentication");
|
|
27
|
+
const {
|
|
28
|
+
normalizeOptionalLoginNonce,
|
|
29
|
+
buildLoginSigningMessageBytes,
|
|
30
|
+
createLoginTimestampChallenge,
|
|
31
|
+
} = require("../auth/login-nonce");
|
|
27
32
|
const {
|
|
28
33
|
nearorg_rpc_tokenfromroditid
|
|
29
34
|
} = require("../blockchain/blockchainservice");
|
|
@@ -163,7 +168,16 @@ function buildLoginUrl(apiendpoint, loginPath = "/api/login") {
|
|
|
163
168
|
async function resolveServerLoginTimestamp(apiendpoint, options = {}) {
|
|
164
169
|
const explicit = parseRequiredServerLoginTimestamp(options.timestamp);
|
|
165
170
|
if (explicit !== null) {
|
|
166
|
-
|
|
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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rodit/rodit-auth-be",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.16.2",
|
|
4
4
|
"description": "RODiT-based authentication system for Express.js applications",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"exports": {
|
|
@@ -46,8 +46,13 @@
|
|
|
46
46
|
},
|
|
47
47
|
"repository": {
|
|
48
48
|
"type": "git",
|
|
49
|
-
"url": "https://github.com/discernible-io/rodit-
|
|
49
|
+
"url": "git+https://github.com/discernible-io/rodit-sdk.git",
|
|
50
|
+
"directory": "rodit-auth-be"
|
|
50
51
|
},
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/discernible-io/rodit-sdk/issues"
|
|
54
|
+
},
|
|
55
|
+
"homepage": "https://github.com/discernible-io/rodit-sdk/tree/main/rodit-auth-be#readme",
|
|
51
56
|
"publishConfig": {
|
|
52
57
|
"access": "public"
|
|
53
58
|
},
|