@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 CHANGED
@@ -96,6 +96,34 @@ await db.login('user@example.com', 'password');
96
96
  // Token refresh is automatic via refresh token.
97
97
  ```
98
98
 
99
+ #### DPoP-bound session (recommended for SPAs)
100
+
101
+ If you persist tokens in storage that may be exposed to XSS
102
+ (`localStorage` / `sessionStorage`), pass `{ dpop: true }` to immediately
103
+ exchange the Bearer session for a **DPoP sender-constrained session**
104
+ (RFC 9449). The SDK generates a non-extractable ECDSA P-256 key pair via Web
105
+ Crypto and binds the access / refresh tokens to it, so a leaked token cannot
106
+ be replayed from another origin.
107
+
108
+ ```typescript
109
+ const result = await db.login('user@example.com', 'password', { dpop: true });
110
+ // result.tokenType === 'DPoP'
111
+ // Subsequent requests use `Authorization: DPoP <token>` + per-request proof.
112
+ // Refresh calls send the DPoP proof on /auth/refresh as well.
113
+ localStorage.setItem('accessToken', result.accessToken);
114
+ localStorage.setItem('refreshToken', result.refreshToken);
115
+ ```
116
+
117
+ Notes:
118
+
119
+ - The bind step (`POST /auth/dpop-bind`) is performed transparently after
120
+ `POST /auth/login` succeeds.
121
+ - The RFC 9449 §8 `use_dpop_nonce` handshake is handled internally.
122
+ - Fails with `AuthenticationError` if Web Crypto `subtle.generateKey` is
123
+ unavailable, instead of silently falling back to Bearer (fail-closed).
124
+ - Omitting the option preserves the previous behavior (Bearer only) for
125
+ backward compatibility.
126
+
99
127
  ### External Token
100
128
 
101
129
  ```typescript
@@ -146,8 +174,20 @@ await db.createEntity({ id: 'urn:ngsi-ld:Room:001', type: 'Room', ... });
146
174
  // Read
147
175
  const entity = await db.getEntity('urn:ngsi-ld:Room:001');
148
176
 
149
- // Query
150
- const entities = await db.getEntities({ type: 'Room', limit: 10 });
177
+ // Query (NGSI-LD §5.7.2 のクエリパラメータを網羅)
178
+ const entities = await db.getEntities({
179
+ type: 'Room',
180
+ limit: 10,
181
+ // scopeQ: '/OpenData;/OpenData/#', // exact + descendants
182
+ // georel: 'near;maxDistance==1000',
183
+ // geometry: 'Point',
184
+ // coordinates: '139.7,35.6',
185
+ // attrs: 'name,location',
186
+ // pick: 'name', omit: 'createdAt',
187
+ // lang: 'ja',
188
+ // orderBy: 'modifiedAt', orderDirection: 'desc',
189
+ // count: true,
190
+ });
151
191
 
152
192
  // Update
153
193
  await db.updateEntity('urn:ngsi-ld:Room:001', { temperature: { type: 'Property', value: 25 } });
@@ -320,7 +360,7 @@ handle.stop();
320
360
 
321
361
  | Method | Parameters | Returns | Description |
322
362
  |--------|-----------|---------|-------------|
323
- | `login(email, password)` | `string, string` | `Promise<LoginResponse>` | Login with email and password (Bearer JWT). Upgrades the SDK out of anonymous mode |
363
+ | `login(email, password, options?)` | `string, string, LoginOptions?` | `Promise<LoginResponse>` | Login with email and password (Bearer JWT). Upgrades the SDK out of anonymous mode. Pass `{ dpop: true }` to immediately exchange the Bearer session for a DPoP sender-constrained session (RFC 9449) — recommended when persisting tokens in storage exposed to XSS |
324
364
  | `setCredentials(opts)` | `CredentialsOptions` | `this` | Set credentials externally (e.g. from a login API response). Upgrades the SDK out of anonymous mode when `tokenType: 'Bearer'`. **Throws** when `tokenType: 'DPoP'` is passed to an anonymous-built instance — anonymous instances do not generate a DPoP key pair |
325
365
  | `logout()` | — | `void` | Clear all credentials and disconnect WebSocket. Reverts to anonymous mode if `anonymous: true` was set at construction |
326
366
  | `isAnonymous()` | — | `boolean` | Whether explicit anonymous mode is active. Returns the explicit mode flag — not derived from credential presence — so a refresh failure that clears `_token` does NOT silently flip this back to `true` |
package/geonicdb.cjs CHANGED
@@ -31,6 +31,7 @@ __export(index_exports, {
31
31
  NotFoundError: () => NotFoundError,
32
32
  RateLimitError: () => RateLimitError,
33
33
  ValidationError: () => ValidationError,
34
+ buildPathWithParams: () => buildPathWithParams,
34
35
  default: () => index_default
35
36
  });
36
37
  module.exports = __toCommonJS(index_exports);
@@ -436,8 +437,16 @@ var _AuthManager = class _AuthManager {
436
437
  emitCacheEvent(name, payload) {
437
438
  this._emitCacheEvent?.(name, payload);
438
439
  }
439
- /** Login with email and password (Bearer JWT). */
440
- async login(email, password) {
440
+ /**
441
+ * Login with email and password (Bearer JWT).
442
+ *
443
+ * Pass `{ dpop: true }` to immediately exchange the Bearer session for a
444
+ * DPoP sender-constrained session via `/auth/dpop-bind` (RFC 9449). The
445
+ * returned tokens become unusable without this SDK instance's
446
+ * non-extractable private key, so a token leaked from storage (e.g.
447
+ * localStorage XSS) cannot be replayed from another origin.
448
+ */
449
+ async login(email, password, options) {
441
450
  this._log("login", email);
442
451
  const headers = {
443
452
  "Content-Type": "application/json"
@@ -461,8 +470,75 @@ var _AuthManager = class _AuthManager {
461
470
  this._tokenType = "Bearer";
462
471
  this._refreshToken = data.refreshToken;
463
472
  this._invalidateAuthScopedState();
473
+ if (options?.dpop) {
474
+ const bound = await this._bindDpopSession();
475
+ return bound;
476
+ }
464
477
  return data;
465
478
  }
479
+ /**
480
+ * Exchange the current Bearer session for a DPoP-bound session via
481
+ * `POST /auth/dpop-bind` (RFC 9449). The server re-issues access and
482
+ * refresh tokens with `cnf.jkt` matching this SDK's DPoP key pair.
483
+ *
484
+ * Assumes `_token` / `_refreshToken` are already populated by a prior
485
+ * `login()` call (callers should not invoke this directly).
486
+ *
487
+ * @internal
488
+ */
489
+ async _bindDpopSession() {
490
+ if (!this._token) {
491
+ throw new AuthenticationError(
492
+ "Cannot bind DPoP session: no Bearer token. login() must succeed first."
493
+ );
494
+ }
495
+ if (this._dpopReady) await this._dpopReady;
496
+ if (!this._dpopKeyPair) {
497
+ throw new AuthenticationError(
498
+ "DPoP binding requires a generated DPoP key pair, but key generation was unavailable or failed. DPoP requires Web Crypto subtle.generateKey."
499
+ );
500
+ }
501
+ const bindUrl = this._baseUrl + "/auth/dpop-bind";
502
+ const accessToken = this._token;
503
+ const data = await this._doDpopBind(bindUrl, accessToken, this._dpopNonce, 0);
504
+ this._token = data.accessToken;
505
+ this._tokenExpiry = Date.now() + (data.expiresIn - 60) * 1e3;
506
+ this._tokenType = "DPoP";
507
+ this._refreshToken = data.refreshToken;
508
+ this._invalidateAuthScopedState();
509
+ return { ...data, tokenType: "DPoP" };
510
+ }
511
+ async _doDpopBind(bindUrl, accessToken, dpopNonce, retryCount) {
512
+ const dpopProof = await createDPoPProof(
513
+ this._dpopKeyPair,
514
+ "POST",
515
+ bindUrl,
516
+ accessToken,
517
+ dpopNonce
518
+ );
519
+ if (!dpopProof) {
520
+ throw new AuthenticationError("DPoP proof generation failed");
521
+ }
522
+ const headers = {
523
+ "Content-Type": "application/json",
524
+ Authorization: "Bearer " + accessToken,
525
+ DPoP: dpopProof
526
+ };
527
+ if (this._tenant) headers["NGSILD-Tenant"] = this._tenant;
528
+ const res = await fetch(bindUrl, { method: "POST", headers });
529
+ const serverNonce = res.headers.get("DPoP-Nonce");
530
+ if (serverNonce) this._dpopNonce = serverNonce;
531
+ if (res.status === 401 && serverNonce && serverNonce !== dpopNonce && retryCount < _AuthManager.MAX_DPOP_RETRIES) {
532
+ return this._doDpopBind(bindUrl, accessToken, serverNonce, retryCount + 1);
533
+ }
534
+ if (!res.ok) {
535
+ const e = await res.json().catch(() => ({}));
536
+ throw new AuthenticationError(
537
+ e.detail || e.description || "DPoP bind failed: " + res.status
538
+ );
539
+ }
540
+ return await res.json();
541
+ }
466
542
  /**
467
543
  * Set credentials externally (e.g. from a login API response).
468
544
  * When tokenType is 'Bearer' and refreshToken is provided, all subsequent
@@ -534,12 +610,15 @@ var _AuthManager = class _AuthManager {
534
610
  return this._tokenPromise;
535
611
  }
536
612
  async _refreshBearerToken() {
537
- this._log("refreshing Bearer token");
613
+ this._log("refreshing token (type=%s)", this._tokenType);
538
614
  try {
539
- const res = await fetch(this._baseUrl + "/auth/refresh", {
615
+ const isDPoP = this._tokenType === "DPoP";
616
+ const refreshUrl = this._baseUrl + "/auth/refresh";
617
+ const body = JSON.stringify({ refreshToken: this._refreshToken });
618
+ const res = isDPoP ? await this._doDpopRefresh(refreshUrl, body, this._dpopNonce, 0) : await fetch(refreshUrl, {
540
619
  method: "POST",
541
620
  headers: { "Content-Type": "application/json" },
542
- body: JSON.stringify({ refreshToken: this._refreshToken })
621
+ body
543
622
  });
544
623
  if (!res.ok) {
545
624
  this._refreshToken = null;
@@ -550,7 +629,7 @@ var _AuthManager = class _AuthManager {
550
629
  const data = await res.json();
551
630
  this._token = data.accessToken;
552
631
  this._tokenExpiry = Date.now() + (data.expiresIn - 60) * 1e3;
553
- this._tokenType = "Bearer";
632
+ this._tokenType = data.tokenType ?? (isDPoP ? "DPoP" : "Bearer");
554
633
  this._refreshToken = data.refreshToken;
555
634
  this._tokenPromise = null;
556
635
  this.onTokenRefresh?.({
@@ -565,6 +644,44 @@ var _AuthManager = class _AuthManager {
565
644
  throw err;
566
645
  }
567
646
  }
647
+ /**
648
+ * Perform a DPoP-bound `/auth/refresh` call with the standard RFC 9449 §8
649
+ * `use_dpop_nonce` retry handshake.
650
+ *
651
+ * @internal
652
+ */
653
+ async _doDpopRefresh(refreshUrl, body, dpopNonce, retryCount) {
654
+ if (this._dpopReady) await this._dpopReady;
655
+ if (!this._dpopKeyPair) {
656
+ throw new AuthenticationError(
657
+ "DPoP-bound refresh requires a DPoP key pair, but key generation was unavailable."
658
+ );
659
+ }
660
+ const proof = await createDPoPProof(
661
+ this._dpopKeyPair,
662
+ "POST",
663
+ refreshUrl,
664
+ null,
665
+ dpopNonce
666
+ );
667
+ if (!proof) {
668
+ throw new AuthenticationError("DPoP proof generation failed");
669
+ }
670
+ const res = await fetch(refreshUrl, {
671
+ method: "POST",
672
+ headers: { "Content-Type": "application/json", DPoP: proof },
673
+ body
674
+ });
675
+ const serverNonce = res.headers.get("DPoP-Nonce");
676
+ if (serverNonce) this._dpopNonce = serverNonce;
677
+ if (res.status === 401 && serverNonce && serverNonce !== dpopNonce && retryCount < _AuthManager.MAX_DPOP_RETRIES) {
678
+ return this._doDpopRefresh(refreshUrl, body, serverNonce, retryCount + 1);
679
+ }
680
+ return res;
681
+ }
682
+ /* istanbul ignore next: PoW + DPoP token-exchange flow is exercised by E2E
683
+ against a live server. Unit-mocking each fetch + crypto step would not
684
+ verify the actual nonce/proof exchange. */
568
685
  async _acquireTokenViaPow() {
569
686
  try {
570
687
  if (this._dpopReady) await this._dpopReady;
@@ -601,6 +718,8 @@ var _AuthManager = class _AuthManager {
601
718
  throw err;
602
719
  }
603
720
  }
721
+ /* istanbul ignore next: DPoP nonce-retry handshake (RFC 9449 §8) — requires
722
+ a real server emitting `use_dpop_nonce` 400s. Exercised by E2E. */
604
723
  async _doTokenExchange(tokenUrl, tokenBody, dpopNonce, retryCount = 0) {
605
724
  const reqHeaders = {
606
725
  "Content-Type": "application/json"
@@ -872,6 +991,7 @@ var WebSocketManager = class {
872
991
  if (options) {
873
992
  if (options.entityTypes) msg.entityTypes = options.entityTypes;
874
993
  if (options.idPattern) msg.idPattern = options.idPattern;
994
+ if (options.scopeQ) msg.scopeQ = options.scopeQ;
875
995
  }
876
996
  this._subscription = msg;
877
997
  if (this._pendingSubscription) return;
@@ -1097,9 +1217,26 @@ var GeonicDB = class extends EventEmitter {
1097
1217
  }
1098
1218
  }
1099
1219
  // --- Authentication ---
1100
- /** Login with email and password (Bearer JWT). */
1101
- async login(email, password) {
1102
- return this._auth.login(email, password);
1220
+ /**
1221
+ * Login with email and password (Bearer JWT).
1222
+ *
1223
+ * Pass `{ dpop: true }` to immediately upgrade the session to a DPoP
1224
+ * sender-constrained token (RFC 9449). Recommended for browser apps that
1225
+ * persist tokens in storage exposed to XSS (localStorage / sessionStorage),
1226
+ * since DPoP-bound tokens are unusable without this SDK instance's
1227
+ * non-extractable private key.
1228
+ *
1229
+ * @example
1230
+ * ```ts
1231
+ * // Bearer (default, backward compatible)
1232
+ * await db.login('user@example.com', 'password');
1233
+ *
1234
+ * // DPoP-bound (recommended for SPAs)
1235
+ * await db.login('user@example.com', 'password', { dpop: true });
1236
+ * ```
1237
+ */
1238
+ async login(email, password, options) {
1239
+ return this._auth.login(email, password, options);
1103
1240
  }
1104
1241
  /**
1105
1242
  * Whether the SDK is currently operating in anonymous mode (no token held).
@@ -1224,14 +1361,14 @@ var GeonicDB = class extends EventEmitter {
1224
1361
  }
1225
1362
  /** Count entities matching the given filters. */
1226
1363
  async count(params) {
1227
- const parts = ["count=true", "limit=0"];
1228
- if (params?.type) parts.push("type=" + encodeURIComponent(params.type));
1229
- if (params?.q) parts.push("q=" + encodeURIComponent(params.q));
1230
- const qs = "?" + parts.join("&");
1231
- const res = await this._auth.request(
1232
- "GET",
1233
- "/ngsi-ld/v1/entities" + qs
1234
- );
1364
+ const path = buildPathWithParams("/ngsi-ld/v1/entities", {
1365
+ count: true,
1366
+ limit: 0,
1367
+ type: params?.type,
1368
+ q: params?.q,
1369
+ scopeQ: params?.scopeQ
1370
+ });
1371
+ const res = await this._auth.request("GET", path);
1235
1372
  if (!res.ok) {
1236
1373
  const e = await res.json().catch(() => ({}));
1237
1374
  throw createErrorFromResponse(res.status, e, "Count failed");
@@ -1290,24 +1427,11 @@ var GeonicDB = class extends EventEmitter {
1290
1427
  // --- Temporal API ---
1291
1428
  /** Query temporal entities. */
1292
1429
  async getTemporalEntities(params) {
1293
- let qs = "";
1294
- if (params) {
1295
- const parts = [];
1296
- if (params.type) parts.push("type=" + encodeURIComponent(params.type));
1297
- if (params.id) parts.push("id=" + encodeURIComponent(params.id));
1298
- if (params.idPattern) parts.push("idPattern=" + encodeURIComponent(params.idPattern));
1299
- if (params.attrs) parts.push("attrs=" + encodeURIComponent(params.attrs));
1300
- if (params.q) parts.push("q=" + encodeURIComponent(params.q));
1301
- if (params.timeproperty) parts.push("timeproperty=" + encodeURIComponent(params.timeproperty));
1302
- if (params.timeAt) parts.push("timeAt=" + encodeURIComponent(params.timeAt));
1303
- if (params.endTimeAt) parts.push("endTimeAt=" + encodeURIComponent(params.endTimeAt));
1304
- if (params.timerel) parts.push("timerel=" + encodeURIComponent(params.timerel));
1305
- if (params.limit != null) parts.push("limit=" + params.limit);
1306
- if (params.offset != null) parts.push("offset=" + params.offset);
1307
- if (parts.length) qs = "?" + parts.join("&");
1308
- }
1309
1430
  return this._jsonGet(
1310
- "/ngsi-ld/v1/temporal/entities" + qs,
1431
+ buildPathWithParams(
1432
+ "/ngsi-ld/v1/temporal/entities",
1433
+ params
1434
+ ),
1311
1435
  "Temporal query failed"
1312
1436
  );
1313
1437
  }
@@ -1375,6 +1499,11 @@ var GeonicDB = class extends EventEmitter {
1375
1499
  /**
1376
1500
  * Make an authenticated API request.
1377
1501
  * Automatically checks response status, parses JSON, and throws on error.
1502
+ *
1503
+ * Empty bodies (`Content-Length: 0` 含む) は Content-Type に関係なく `null`
1504
+ * を返す (#1145)。NGSI-LD の POST / DELETE / PATCH 系は仕様上ボディが
1505
+ * 空だが、サーバ実装が `Content-Type: application/ld+json` を付けて返す
1506
+ * ケースがあり、そこで `res.json()` が SyntaxError を投げて止まっていた。
1378
1507
  */
1379
1508
  async request(method, path, body) {
1380
1509
  const res = await this._auth.request(method, path, body);
@@ -1382,9 +1511,15 @@ var GeonicDB = class extends EventEmitter {
1382
1511
  const e = await res.json().catch(() => ({}));
1383
1512
  throw createErrorFromResponse(res.status, e, "Request failed: " + res.status);
1384
1513
  }
1514
+ if (res.status === 204) return null;
1515
+ if (res.headers.get("Content-Length") === "0") return null;
1385
1516
  const ct = res.headers.get("Content-Type") || "";
1386
- if (res.status === 204 || !ct) return null;
1387
- if (ct.indexOf("json") !== -1) return res.json();
1517
+ if (!ct) return null;
1518
+ if (ct.indexOf("json") !== -1) {
1519
+ const text = await res.text();
1520
+ if (!text) return null;
1521
+ return JSON.parse(text);
1522
+ }
1388
1523
  return res.text();
1389
1524
  }
1390
1525
  /**
@@ -1421,12 +1556,28 @@ if (typeof window !== "undefined") {
1421
1556
  window.GeonicDB = GeonicDB;
1422
1557
  }
1423
1558
  function buildEntitiesPath(params) {
1424
- if (!params) return "/ngsi-ld/v1/entities";
1425
- const parts = [];
1426
- if (params.type) parts.push("type=" + encodeURIComponent(params.type));
1427
- if (params.limit != null) parts.push("limit=" + params.limit);
1428
- if (params.offset != null) parts.push("offset=" + params.offset);
1429
- if (params.q) parts.push("q=" + encodeURIComponent(params.q));
1430
- return parts.length > 0 ? "/ngsi-ld/v1/entities?" + parts.join("&") : "/ngsi-ld/v1/entities";
1559
+ return buildPathWithParams(
1560
+ "/ngsi-ld/v1/entities",
1561
+ params
1562
+ );
1563
+ }
1564
+ function buildPathWithParams(path, params) {
1565
+ if (!params) return path;
1566
+ const sp = new URLSearchParams();
1567
+ for (const [key, value] of Object.entries(params)) {
1568
+ if (value === void 0 || value === null) continue;
1569
+ if (typeof value === "boolean") {
1570
+ sp.append(key, value ? "true" : "false");
1571
+ } else if (typeof value === "number") {
1572
+ sp.append(key, String(value));
1573
+ } else if (typeof value === "string") {
1574
+ if (value === "") continue;
1575
+ sp.append(key, value);
1576
+ } else {
1577
+ continue;
1578
+ }
1579
+ }
1580
+ const qs = sp.toString();
1581
+ return qs ? `${path}?${qs}` : path;
1431
1582
  }
1432
1583
  //# sourceMappingURL=geonicdb.cjs.map