@geolonia/geonicdb-sdk 0.8.0 → 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"
@@ -1065,9 +1183,26 @@ var GeonicDB = class extends EventEmitter {
1065
1183
  }
1066
1184
  }
1067
1185
  // --- Authentication ---
1068
- /** Login with email and password (Bearer JWT). */
1069
- async login(email, password) {
1070
- 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);
1071
1206
  }
1072
1207
  /**
1073
1208
  * Whether the SDK is currently operating in anonymous mode (no token held).