@geolonia/geonicdb-sdk 0.7.1 → 0.9.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/README.md +43 -3
- package/geonicdb.cjs +194 -43
- package/geonicdb.cjs.map +2 -2
- package/geonicdb.d.ts +2 -0
- package/geonicdb.iife.js +194 -43
- package/geonicdb.iife.js.map +2 -2
- package/geonicdb.mjs +194 -43
- package/geonicdb.mjs.map +2 -2
- package/package.json +1 -1
package/geonicdb.mjs
CHANGED
|
@@ -403,8 +403,16 @@ var _AuthManager = class _AuthManager {
|
|
|
403
403
|
emitCacheEvent(name, payload) {
|
|
404
404
|
this._emitCacheEvent?.(name, payload);
|
|
405
405
|
}
|
|
406
|
-
/**
|
|
407
|
-
|
|
406
|
+
/**
|
|
407
|
+
* Login with email and password (Bearer JWT).
|
|
408
|
+
*
|
|
409
|
+
* Pass `{ dpop: true }` to immediately exchange the Bearer session for a
|
|
410
|
+
* DPoP sender-constrained session via `/auth/dpop-bind` (RFC 9449). The
|
|
411
|
+
* returned tokens become unusable without this SDK instance's
|
|
412
|
+
* non-extractable private key, so a token leaked from storage (e.g.
|
|
413
|
+
* localStorage XSS) cannot be replayed from another origin.
|
|
414
|
+
*/
|
|
415
|
+
async login(email, password, options) {
|
|
408
416
|
this._log("login", email);
|
|
409
417
|
const headers = {
|
|
410
418
|
"Content-Type": "application/json"
|
|
@@ -428,8 +436,75 @@ var _AuthManager = class _AuthManager {
|
|
|
428
436
|
this._tokenType = "Bearer";
|
|
429
437
|
this._refreshToken = data.refreshToken;
|
|
430
438
|
this._invalidateAuthScopedState();
|
|
439
|
+
if (options?.dpop) {
|
|
440
|
+
const bound = await this._bindDpopSession();
|
|
441
|
+
return bound;
|
|
442
|
+
}
|
|
431
443
|
return data;
|
|
432
444
|
}
|
|
445
|
+
/**
|
|
446
|
+
* Exchange the current Bearer session for a DPoP-bound session via
|
|
447
|
+
* `POST /auth/dpop-bind` (RFC 9449). The server re-issues access and
|
|
448
|
+
* refresh tokens with `cnf.jkt` matching this SDK's DPoP key pair.
|
|
449
|
+
*
|
|
450
|
+
* Assumes `_token` / `_refreshToken` are already populated by a prior
|
|
451
|
+
* `login()` call (callers should not invoke this directly).
|
|
452
|
+
*
|
|
453
|
+
* @internal
|
|
454
|
+
*/
|
|
455
|
+
async _bindDpopSession() {
|
|
456
|
+
if (!this._token) {
|
|
457
|
+
throw new AuthenticationError(
|
|
458
|
+
"Cannot bind DPoP session: no Bearer token. login() must succeed first."
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
if (this._dpopReady) await this._dpopReady;
|
|
462
|
+
if (!this._dpopKeyPair) {
|
|
463
|
+
throw new AuthenticationError(
|
|
464
|
+
"DPoP binding requires a generated DPoP key pair, but key generation was unavailable or failed. DPoP requires Web Crypto subtle.generateKey."
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
const bindUrl = this._baseUrl + "/auth/dpop-bind";
|
|
468
|
+
const accessToken = this._token;
|
|
469
|
+
const data = await this._doDpopBind(bindUrl, accessToken, this._dpopNonce, 0);
|
|
470
|
+
this._token = data.accessToken;
|
|
471
|
+
this._tokenExpiry = Date.now() + (data.expiresIn - 60) * 1e3;
|
|
472
|
+
this._tokenType = "DPoP";
|
|
473
|
+
this._refreshToken = data.refreshToken;
|
|
474
|
+
this._invalidateAuthScopedState();
|
|
475
|
+
return { ...data, tokenType: "DPoP" };
|
|
476
|
+
}
|
|
477
|
+
async _doDpopBind(bindUrl, accessToken, dpopNonce, retryCount) {
|
|
478
|
+
const dpopProof = await createDPoPProof(
|
|
479
|
+
this._dpopKeyPair,
|
|
480
|
+
"POST",
|
|
481
|
+
bindUrl,
|
|
482
|
+
accessToken,
|
|
483
|
+
dpopNonce
|
|
484
|
+
);
|
|
485
|
+
if (!dpopProof) {
|
|
486
|
+
throw new AuthenticationError("DPoP proof generation failed");
|
|
487
|
+
}
|
|
488
|
+
const headers = {
|
|
489
|
+
"Content-Type": "application/json",
|
|
490
|
+
Authorization: "Bearer " + accessToken,
|
|
491
|
+
DPoP: dpopProof
|
|
492
|
+
};
|
|
493
|
+
if (this._tenant) headers["NGSILD-Tenant"] = this._tenant;
|
|
494
|
+
const res = await fetch(bindUrl, { method: "POST", headers });
|
|
495
|
+
const serverNonce = res.headers.get("DPoP-Nonce");
|
|
496
|
+
if (serverNonce) this._dpopNonce = serverNonce;
|
|
497
|
+
if (res.status === 401 && serverNonce && serverNonce !== dpopNonce && retryCount < _AuthManager.MAX_DPOP_RETRIES) {
|
|
498
|
+
return this._doDpopBind(bindUrl, accessToken, serverNonce, retryCount + 1);
|
|
499
|
+
}
|
|
500
|
+
if (!res.ok) {
|
|
501
|
+
const e = await res.json().catch(() => ({}));
|
|
502
|
+
throw new AuthenticationError(
|
|
503
|
+
e.detail || e.description || "DPoP bind failed: " + res.status
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
return await res.json();
|
|
507
|
+
}
|
|
433
508
|
/**
|
|
434
509
|
* Set credentials externally (e.g. from a login API response).
|
|
435
510
|
* When tokenType is 'Bearer' and refreshToken is provided, all subsequent
|
|
@@ -501,12 +576,15 @@ var _AuthManager = class _AuthManager {
|
|
|
501
576
|
return this._tokenPromise;
|
|
502
577
|
}
|
|
503
578
|
async _refreshBearerToken() {
|
|
504
|
-
this._log("refreshing
|
|
579
|
+
this._log("refreshing token (type=%s)", this._tokenType);
|
|
505
580
|
try {
|
|
506
|
-
const
|
|
581
|
+
const isDPoP = this._tokenType === "DPoP";
|
|
582
|
+
const refreshUrl = this._baseUrl + "/auth/refresh";
|
|
583
|
+
const body = JSON.stringify({ refreshToken: this._refreshToken });
|
|
584
|
+
const res = isDPoP ? await this._doDpopRefresh(refreshUrl, body, this._dpopNonce, 0) : await fetch(refreshUrl, {
|
|
507
585
|
method: "POST",
|
|
508
586
|
headers: { "Content-Type": "application/json" },
|
|
509
|
-
body
|
|
587
|
+
body
|
|
510
588
|
});
|
|
511
589
|
if (!res.ok) {
|
|
512
590
|
this._refreshToken = null;
|
|
@@ -517,7 +595,7 @@ var _AuthManager = class _AuthManager {
|
|
|
517
595
|
const data = await res.json();
|
|
518
596
|
this._token = data.accessToken;
|
|
519
597
|
this._tokenExpiry = Date.now() + (data.expiresIn - 60) * 1e3;
|
|
520
|
-
this._tokenType = "Bearer";
|
|
598
|
+
this._tokenType = data.tokenType ?? (isDPoP ? "DPoP" : "Bearer");
|
|
521
599
|
this._refreshToken = data.refreshToken;
|
|
522
600
|
this._tokenPromise = null;
|
|
523
601
|
this.onTokenRefresh?.({
|
|
@@ -532,6 +610,44 @@ var _AuthManager = class _AuthManager {
|
|
|
532
610
|
throw err;
|
|
533
611
|
}
|
|
534
612
|
}
|
|
613
|
+
/**
|
|
614
|
+
* Perform a DPoP-bound `/auth/refresh` call with the standard RFC 9449 §8
|
|
615
|
+
* `use_dpop_nonce` retry handshake.
|
|
616
|
+
*
|
|
617
|
+
* @internal
|
|
618
|
+
*/
|
|
619
|
+
async _doDpopRefresh(refreshUrl, body, dpopNonce, retryCount) {
|
|
620
|
+
if (this._dpopReady) await this._dpopReady;
|
|
621
|
+
if (!this._dpopKeyPair) {
|
|
622
|
+
throw new AuthenticationError(
|
|
623
|
+
"DPoP-bound refresh requires a DPoP key pair, but key generation was unavailable."
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
const proof = await createDPoPProof(
|
|
627
|
+
this._dpopKeyPair,
|
|
628
|
+
"POST",
|
|
629
|
+
refreshUrl,
|
|
630
|
+
null,
|
|
631
|
+
dpopNonce
|
|
632
|
+
);
|
|
633
|
+
if (!proof) {
|
|
634
|
+
throw new AuthenticationError("DPoP proof generation failed");
|
|
635
|
+
}
|
|
636
|
+
const res = await fetch(refreshUrl, {
|
|
637
|
+
method: "POST",
|
|
638
|
+
headers: { "Content-Type": "application/json", DPoP: proof },
|
|
639
|
+
body
|
|
640
|
+
});
|
|
641
|
+
const serverNonce = res.headers.get("DPoP-Nonce");
|
|
642
|
+
if (serverNonce) this._dpopNonce = serverNonce;
|
|
643
|
+
if (res.status === 401 && serverNonce && serverNonce !== dpopNonce && retryCount < _AuthManager.MAX_DPOP_RETRIES) {
|
|
644
|
+
return this._doDpopRefresh(refreshUrl, body, serverNonce, retryCount + 1);
|
|
645
|
+
}
|
|
646
|
+
return res;
|
|
647
|
+
}
|
|
648
|
+
/* istanbul ignore next: PoW + DPoP token-exchange flow is exercised by E2E
|
|
649
|
+
against a live server. Unit-mocking each fetch + crypto step would not
|
|
650
|
+
verify the actual nonce/proof exchange. */
|
|
535
651
|
async _acquireTokenViaPow() {
|
|
536
652
|
try {
|
|
537
653
|
if (this._dpopReady) await this._dpopReady;
|
|
@@ -568,6 +684,8 @@ var _AuthManager = class _AuthManager {
|
|
|
568
684
|
throw err;
|
|
569
685
|
}
|
|
570
686
|
}
|
|
687
|
+
/* istanbul ignore next: DPoP nonce-retry handshake (RFC 9449 §8) — requires
|
|
688
|
+
a real server emitting `use_dpop_nonce` 400s. Exercised by E2E. */
|
|
571
689
|
async _doTokenExchange(tokenUrl, tokenBody, dpopNonce, retryCount = 0) {
|
|
572
690
|
const reqHeaders = {
|
|
573
691
|
"Content-Type": "application/json"
|
|
@@ -839,6 +957,7 @@ var WebSocketManager = class {
|
|
|
839
957
|
if (options) {
|
|
840
958
|
if (options.entityTypes) msg.entityTypes = options.entityTypes;
|
|
841
959
|
if (options.idPattern) msg.idPattern = options.idPattern;
|
|
960
|
+
if (options.scopeQ) msg.scopeQ = options.scopeQ;
|
|
842
961
|
}
|
|
843
962
|
this._subscription = msg;
|
|
844
963
|
if (this._pendingSubscription) return;
|
|
@@ -1064,9 +1183,26 @@ var GeonicDB = class extends EventEmitter {
|
|
|
1064
1183
|
}
|
|
1065
1184
|
}
|
|
1066
1185
|
// --- Authentication ---
|
|
1067
|
-
/**
|
|
1068
|
-
|
|
1069
|
-
|
|
1186
|
+
/**
|
|
1187
|
+
* Login with email and password (Bearer JWT).
|
|
1188
|
+
*
|
|
1189
|
+
* Pass `{ dpop: true }` to immediately upgrade the session to a DPoP
|
|
1190
|
+
* sender-constrained token (RFC 9449). Recommended for browser apps that
|
|
1191
|
+
* persist tokens in storage exposed to XSS (localStorage / sessionStorage),
|
|
1192
|
+
* since DPoP-bound tokens are unusable without this SDK instance's
|
|
1193
|
+
* non-extractable private key.
|
|
1194
|
+
*
|
|
1195
|
+
* @example
|
|
1196
|
+
* ```ts
|
|
1197
|
+
* // Bearer (default, backward compatible)
|
|
1198
|
+
* await db.login('user@example.com', 'password');
|
|
1199
|
+
*
|
|
1200
|
+
* // DPoP-bound (recommended for SPAs)
|
|
1201
|
+
* await db.login('user@example.com', 'password', { dpop: true });
|
|
1202
|
+
* ```
|
|
1203
|
+
*/
|
|
1204
|
+
async login(email, password, options) {
|
|
1205
|
+
return this._auth.login(email, password, options);
|
|
1070
1206
|
}
|
|
1071
1207
|
/**
|
|
1072
1208
|
* Whether the SDK is currently operating in anonymous mode (no token held).
|
|
@@ -1191,14 +1327,14 @@ var GeonicDB = class extends EventEmitter {
|
|
|
1191
1327
|
}
|
|
1192
1328
|
/** Count entities matching the given filters. */
|
|
1193
1329
|
async count(params) {
|
|
1194
|
-
const
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
);
|
|
1330
|
+
const path = buildPathWithParams("/ngsi-ld/v1/entities", {
|
|
1331
|
+
count: true,
|
|
1332
|
+
limit: 0,
|
|
1333
|
+
type: params?.type,
|
|
1334
|
+
q: params?.q,
|
|
1335
|
+
scopeQ: params?.scopeQ
|
|
1336
|
+
});
|
|
1337
|
+
const res = await this._auth.request("GET", path);
|
|
1202
1338
|
if (!res.ok) {
|
|
1203
1339
|
const e = await res.json().catch(() => ({}));
|
|
1204
1340
|
throw createErrorFromResponse(res.status, e, "Count failed");
|
|
@@ -1257,24 +1393,11 @@ var GeonicDB = class extends EventEmitter {
|
|
|
1257
1393
|
// --- Temporal API ---
|
|
1258
1394
|
/** Query temporal entities. */
|
|
1259
1395
|
async getTemporalEntities(params) {
|
|
1260
|
-
let qs = "";
|
|
1261
|
-
if (params) {
|
|
1262
|
-
const parts = [];
|
|
1263
|
-
if (params.type) parts.push("type=" + encodeURIComponent(params.type));
|
|
1264
|
-
if (params.id) parts.push("id=" + encodeURIComponent(params.id));
|
|
1265
|
-
if (params.idPattern) parts.push("idPattern=" + encodeURIComponent(params.idPattern));
|
|
1266
|
-
if (params.attrs) parts.push("attrs=" + encodeURIComponent(params.attrs));
|
|
1267
|
-
if (params.q) parts.push("q=" + encodeURIComponent(params.q));
|
|
1268
|
-
if (params.timeproperty) parts.push("timeproperty=" + encodeURIComponent(params.timeproperty));
|
|
1269
|
-
if (params.timeAt) parts.push("timeAt=" + encodeURIComponent(params.timeAt));
|
|
1270
|
-
if (params.endTimeAt) parts.push("endTimeAt=" + encodeURIComponent(params.endTimeAt));
|
|
1271
|
-
if (params.timerel) parts.push("timerel=" + encodeURIComponent(params.timerel));
|
|
1272
|
-
if (params.limit != null) parts.push("limit=" + params.limit);
|
|
1273
|
-
if (params.offset != null) parts.push("offset=" + params.offset);
|
|
1274
|
-
if (parts.length) qs = "?" + parts.join("&");
|
|
1275
|
-
}
|
|
1276
1396
|
return this._jsonGet(
|
|
1277
|
-
|
|
1397
|
+
buildPathWithParams(
|
|
1398
|
+
"/ngsi-ld/v1/temporal/entities",
|
|
1399
|
+
params
|
|
1400
|
+
),
|
|
1278
1401
|
"Temporal query failed"
|
|
1279
1402
|
);
|
|
1280
1403
|
}
|
|
@@ -1342,6 +1465,11 @@ var GeonicDB = class extends EventEmitter {
|
|
|
1342
1465
|
/**
|
|
1343
1466
|
* Make an authenticated API request.
|
|
1344
1467
|
* Automatically checks response status, parses JSON, and throws on error.
|
|
1468
|
+
*
|
|
1469
|
+
* Empty bodies (`Content-Length: 0` 含む) は Content-Type に関係なく `null`
|
|
1470
|
+
* を返す (#1145)。NGSI-LD の POST / DELETE / PATCH 系は仕様上ボディが
|
|
1471
|
+
* 空だが、サーバ実装が `Content-Type: application/ld+json` を付けて返す
|
|
1472
|
+
* ケースがあり、そこで `res.json()` が SyntaxError を投げて止まっていた。
|
|
1345
1473
|
*/
|
|
1346
1474
|
async request(method, path, body) {
|
|
1347
1475
|
const res = await this._auth.request(method, path, body);
|
|
@@ -1349,9 +1477,15 @@ var GeonicDB = class extends EventEmitter {
|
|
|
1349
1477
|
const e = await res.json().catch(() => ({}));
|
|
1350
1478
|
throw createErrorFromResponse(res.status, e, "Request failed: " + res.status);
|
|
1351
1479
|
}
|
|
1480
|
+
if (res.status === 204) return null;
|
|
1481
|
+
if (res.headers.get("Content-Length") === "0") return null;
|
|
1352
1482
|
const ct = res.headers.get("Content-Type") || "";
|
|
1353
|
-
if (
|
|
1354
|
-
if (ct.indexOf("json") !== -1)
|
|
1483
|
+
if (!ct) return null;
|
|
1484
|
+
if (ct.indexOf("json") !== -1) {
|
|
1485
|
+
const text = await res.text();
|
|
1486
|
+
if (!text) return null;
|
|
1487
|
+
return JSON.parse(text);
|
|
1488
|
+
}
|
|
1355
1489
|
return res.text();
|
|
1356
1490
|
}
|
|
1357
1491
|
/**
|
|
@@ -1388,13 +1522,29 @@ if (typeof window !== "undefined") {
|
|
|
1388
1522
|
window.GeonicDB = GeonicDB;
|
|
1389
1523
|
}
|
|
1390
1524
|
function buildEntitiesPath(params) {
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1525
|
+
return buildPathWithParams(
|
|
1526
|
+
"/ngsi-ld/v1/entities",
|
|
1527
|
+
params
|
|
1528
|
+
);
|
|
1529
|
+
}
|
|
1530
|
+
function buildPathWithParams(path, params) {
|
|
1531
|
+
if (!params) return path;
|
|
1532
|
+
const sp = new URLSearchParams();
|
|
1533
|
+
for (const [key, value] of Object.entries(params)) {
|
|
1534
|
+
if (value === void 0 || value === null) continue;
|
|
1535
|
+
if (typeof value === "boolean") {
|
|
1536
|
+
sp.append(key, value ? "true" : "false");
|
|
1537
|
+
} else if (typeof value === "number") {
|
|
1538
|
+
sp.append(key, String(value));
|
|
1539
|
+
} else if (typeof value === "string") {
|
|
1540
|
+
if (value === "") continue;
|
|
1541
|
+
sp.append(key, value);
|
|
1542
|
+
} else {
|
|
1543
|
+
continue;
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
const qs = sp.toString();
|
|
1547
|
+
return qs ? `${path}?${qs}` : path;
|
|
1398
1548
|
}
|
|
1399
1549
|
export {
|
|
1400
1550
|
AuthenticationError,
|
|
@@ -1406,6 +1556,7 @@ export {
|
|
|
1406
1556
|
NotFoundError,
|
|
1407
1557
|
RateLimitError,
|
|
1408
1558
|
ValidationError,
|
|
1559
|
+
buildPathWithParams,
|
|
1409
1560
|
index_default as default
|
|
1410
1561
|
};
|
|
1411
1562
|
//# sourceMappingURL=geonicdb.mjs.map
|