@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/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
- /** Login with email and password (Bearer JWT). */
407
- async login(email, password) {
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 Bearer token");
579
+ this._log("refreshing token (type=%s)", this._tokenType);
505
580
  try {
506
- const res = await fetch(this._baseUrl + "/auth/refresh", {
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: JSON.stringify({ refreshToken: this._refreshToken })
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
- /** Login with email and password (Bearer JWT). */
1068
- async login(email, password) {
1069
- return this._auth.login(email, password);
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 parts = ["count=true", "limit=0"];
1195
- if (params?.type) parts.push("type=" + encodeURIComponent(params.type));
1196
- if (params?.q) parts.push("q=" + encodeURIComponent(params.q));
1197
- const qs = "?" + parts.join("&");
1198
- const res = await this._auth.request(
1199
- "GET",
1200
- "/ngsi-ld/v1/entities" + qs
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
- "/ngsi-ld/v1/temporal/entities" + qs,
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 (res.status === 204 || !ct) return null;
1354
- if (ct.indexOf("json") !== -1) return res.json();
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
- if (!params) return "/ngsi-ld/v1/entities";
1392
- const parts = [];
1393
- if (params.type) parts.push("type=" + encodeURIComponent(params.type));
1394
- if (params.limit != null) parts.push("limit=" + params.limit);
1395
- if (params.offset != null) parts.push("offset=" + params.offset);
1396
- if (params.q) parts.push("q=" + encodeURIComponent(params.q));
1397
- return parts.length > 0 ? "/ngsi-ld/v1/entities?" + parts.join("&") : "/ngsi-ld/v1/entities";
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