@geolonia/geonicdb-sdk 0.8.0 → 0.10.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,77 @@ 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
+
127
+ #### Persisting DPoP across page reloads
128
+
129
+ By default, a DPoP session is held in memory only. Because the private key
130
+ is generated as a **non-extractable** `CryptoKey`, it cannot be serialized
131
+ to `localStorage` — and reloading the page would lose it, forcing the user
132
+ to log in again.
133
+
134
+ Pass `{ dpop: true, dpopPersist: true }` to persist the session to
135
+ **IndexedDB**. The structured clone algorithm stores the `CryptoKey` as-is,
136
+ preserving `extractable: false`: the key survives reloads, but JavaScript
137
+ on the page still cannot read it back as JWK / PEM. Tokens leaked from any
138
+ other storage remain unusable without this origin's IndexedDB.
139
+
140
+ ```typescript
141
+ const db = new GeonicDB({ baseUrl, tenant });
142
+ await db.login('user@example.com', 'password', {
143
+ dpop: true,
144
+ dpopPersist: true,
145
+ });
146
+
147
+ // On the next page load:
148
+ const db2 = new GeonicDB({ baseUrl, tenant });
149
+ if (await db2.restoreSession()) {
150
+ // Session was rehydrated from IndexedDB — boot the app.
151
+ } else {
152
+ // No persisted session; show the login form.
153
+ }
154
+
155
+ // On logout, the persisted entry is cleared automatically.
156
+ await db2.logout();
157
+ ```
158
+
159
+ Notes:
160
+
161
+ - `dpopPersist: true` requires `dpop: true` (throws otherwise). The
162
+ persistence mechanism is for DPoP key pairs specifically; persisting
163
+ Bearer tokens is the caller's responsibility.
164
+ - The IndexedDB store is keyed by `tenant`, so multi-tenant apps in the
165
+ same origin keep separate sessions.
166
+ - Persistence is best-effort: a failed IDB write does not break login.
167
+ - iOS Safari may evict IndexedDB under ITP after 7 days of no user
168
+ interaction with the origin.
169
+
99
170
  ### External Token
100
171
 
101
172
  ```typescript
@@ -332,7 +403,8 @@ handle.stop();
332
403
 
333
404
  | Method | Parameters | Returns | Description |
334
405
  |--------|-----------|---------|-------------|
335
- | `login(email, password)` | `string, string` | `Promise<LoginResponse>` | Login with email and password (Bearer JWT). Upgrades the SDK out of anonymous mode |
406
+ | `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. Pass `{ dpop: true, dpopPersist: true }` to also persist the session to IndexedDB so it survives a page reload (#1230) |
407
+ | `restoreSession()` | — | `Promise<boolean>` | Rehydrate a DPoP session previously persisted via `login({ dpop: true, dpopPersist: true })`. Returns `true` if a session was found and loaded, `false` otherwise. Call at app startup before showing the login form |
336
408
  | `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 |
337
409
  | `logout()` | — | `void` | Clear all credentials and disconnect WebSocket. Reverts to anonymous mode if `anonymous: true` was set at construction |
338
410
  | `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
@@ -72,8 +72,23 @@ var EventEmitter = class {
72
72
  }
73
73
  };
74
74
 
75
+ // src/sdk/constants.ts
76
+ var DEFAULT_TOKEN_TTL_SEC = 3600;
77
+ var TOKEN_REFRESH_LEEWAY_MS = 12e4;
78
+ var TOKEN_REFRESH_MIN_MS = 1e4;
79
+ var RECONNECT_MAX_ATTEMPTS = 10;
80
+ var RECONNECT_BASE_MS = 1e3;
81
+ var RECONNECT_MAX_DELAY_MS = 3e4;
82
+ var SUB_PROTOCOL = "access_token";
83
+ var SDK_CACHE_MAX_ENTRIES_DEFAULT = 1e3;
84
+ var SDK_POLL_INTERVAL_MS_DEFAULT = 5e3;
85
+ var DPOP_IDB_NAME = "geonicdb-sdk";
86
+ var DPOP_IDB_STORE = "dpop-sessions";
87
+ var DPOP_IDB_VERSION = 1;
88
+
75
89
  // src/sdk/dpop.ts
76
90
  var dpopSupported = typeof crypto !== "undefined" && !!crypto.subtle && !!crypto.subtle.generateKey;
91
+ var dpopPersistenceSupported = typeof globalThis.indexedDB !== "undefined" && typeof structuredClone !== "undefined";
77
92
  function b64url(buf) {
78
93
  const arr = buf instanceof ArrayBuffer ? new Uint8Array(buf) : buf;
79
94
  let str = "";
@@ -130,6 +145,102 @@ async function createDPoPProof(keyPair, htm, htu, accessToken, nonce) {
130
145
  );
131
146
  return signingInput + "." + b64url(new Uint8Array(sigBuf));
132
147
  }
148
+ function openSessionDB() {
149
+ return new Promise((resolve, reject) => {
150
+ const req = globalThis.indexedDB.open(DPOP_IDB_NAME, DPOP_IDB_VERSION);
151
+ req.onupgradeneeded = () => {
152
+ const db = req.result;
153
+ if (!db.objectStoreNames.contains(DPOP_IDB_STORE)) {
154
+ db.createObjectStore(DPOP_IDB_STORE);
155
+ }
156
+ };
157
+ req.onsuccess = () => {
158
+ resolve(req.result);
159
+ };
160
+ req.onerror = () => {
161
+ reject(req.error ?? new Error("IndexedDB open failed"));
162
+ };
163
+ });
164
+ }
165
+ async function saveDPoPSession(session) {
166
+ if (!dpopPersistenceSupported) return false;
167
+ let db = null;
168
+ try {
169
+ db = await openSessionDB();
170
+ await new Promise((resolve, reject) => {
171
+ const tx = db.transaction(DPOP_IDB_STORE, "readwrite");
172
+ tx.objectStore(DPOP_IDB_STORE).put(session, session.tenant);
173
+ tx.oncomplete = () => {
174
+ resolve();
175
+ };
176
+ tx.onerror = () => {
177
+ reject(tx.error ?? new Error("IndexedDB put failed"));
178
+ };
179
+ tx.onabort = () => {
180
+ reject(tx.error ?? new Error("IndexedDB transaction aborted"));
181
+ };
182
+ });
183
+ return true;
184
+ } catch {
185
+ return false;
186
+ } finally {
187
+ db?.close();
188
+ }
189
+ }
190
+ async function loadDPoPSession(tenant) {
191
+ if (!dpopPersistenceSupported) return null;
192
+ let db = null;
193
+ try {
194
+ db = await openSessionDB();
195
+ const data = await new Promise((resolve, reject) => {
196
+ const tx = db.transaction(DPOP_IDB_STORE, "readonly");
197
+ const req = tx.objectStore(DPOP_IDB_STORE).get(tenant);
198
+ req.onsuccess = () => {
199
+ resolve(req.result);
200
+ };
201
+ req.onerror = () => {
202
+ reject(req.error ?? new Error("IndexedDB get failed"));
203
+ };
204
+ });
205
+ return validatePersistedSession(data);
206
+ } catch {
207
+ return null;
208
+ } finally {
209
+ db?.close();
210
+ }
211
+ }
212
+ async function clearDPoPSession(tenant) {
213
+ if (!dpopPersistenceSupported) return;
214
+ let db = null;
215
+ try {
216
+ db = await openSessionDB();
217
+ await new Promise((resolve, reject) => {
218
+ const tx = db.transaction(DPOP_IDB_STORE, "readwrite");
219
+ tx.objectStore(DPOP_IDB_STORE).delete(tenant);
220
+ tx.oncomplete = () => {
221
+ resolve();
222
+ };
223
+ tx.onerror = () => {
224
+ reject(tx.error ?? new Error("IndexedDB delete failed"));
225
+ };
226
+ tx.onabort = () => {
227
+ reject(tx.error ?? new Error("IndexedDB transaction aborted"));
228
+ };
229
+ });
230
+ } catch {
231
+ } finally {
232
+ db?.close();
233
+ }
234
+ }
235
+ function validatePersistedSession(data) {
236
+ if (!data || typeof data !== "object") return null;
237
+ const s = data;
238
+ const kp = s.keyPair;
239
+ if (typeof s.tenant !== "string" || typeof s.accessToken !== "string" || typeof s.refreshToken !== "string" || typeof s.expiresAt !== "number" || !kp || !kp.privateKey || !kp.publicJwk) {
240
+ return null;
241
+ }
242
+ return data;
243
+ }
133
244
 
134
245
  // src/sdk/pow.ts
135
246
  var BATCH_SIZE = 1e3;
@@ -173,17 +284,6 @@ async function solvePoW(challenge, difficulty) {
173
284
  return tryBatch();
174
285
  }
175
286
 
176
- // src/sdk/constants.ts
177
- var DEFAULT_TOKEN_TTL_SEC = 3600;
178
- var TOKEN_REFRESH_LEEWAY_MS = 12e4;
179
- var TOKEN_REFRESH_MIN_MS = 1e4;
180
- var RECONNECT_MAX_ATTEMPTS = 10;
181
- var RECONNECT_BASE_MS = 1e3;
182
- var RECONNECT_MAX_DELAY_MS = 3e4;
183
- var SUB_PROTOCOL = "access_token";
184
- var SDK_CACHE_MAX_ENTRIES_DEFAULT = 1e3;
185
- var SDK_POLL_INTERVAL_MS_DEFAULT = 5e3;
186
-
187
287
  // src/sdk/errors.ts
188
288
  var GeonicDBError = class extends Error {
189
289
  constructor(message, statusCode = 0) {
@@ -376,6 +476,14 @@ var _AuthManager = class _AuthManager {
376
476
  __publicField(this, "_dpopKeyPair", null);
377
477
  __publicField(this, "_dpopReady", null);
378
478
  __publicField(this, "_dpopNonce", null);
479
+ /**
480
+ * #1230: when true, every DPoP token mutation (login bind, refresh) is
481
+ * mirrored to IndexedDB so the session survives a page reload. Toggled
482
+ * by `login({ dpopPersist: true })` and `restoreSession()`.
483
+ */
484
+ __publicField(this, "_dpopPersist", false);
485
+ /** Email captured at login, persisted for UI display. */
486
+ __publicField(this, "_email", null);
379
487
  __publicField(this, "_tokenPromise", null);
380
488
  /** Callback to emit tokenRefresh events. Set by GeonicDB class. */
381
489
  __publicField(this, "onTokenRefresh", null);
@@ -402,9 +510,9 @@ var _AuthManager = class _AuthManager {
402
510
  this._anonymous = anonymous;
403
511
  if (!anonymous && dpopSupported) {
404
512
  this._dpopReady = generateDPoPKeyPair().then((kp) => {
405
- this._dpopKeyPair = kp;
513
+ if (!this._dpopKeyPair) this._dpopKeyPair = kp;
406
514
  }).catch(() => {
407
- this._dpopKeyPair = null;
515
+ if (!this._dpopKeyPair) this._dpopKeyPair = null;
408
516
  });
409
517
  }
410
518
  }
@@ -437,9 +545,23 @@ var _AuthManager = class _AuthManager {
437
545
  emitCacheEvent(name, payload) {
438
546
  this._emitCacheEvent?.(name, payload);
439
547
  }
440
- /** Login with email and password (Bearer JWT). */
441
- async login(email, password) {
548
+ /**
549
+ * Login with email and password (Bearer JWT).
550
+ *
551
+ * Pass `{ dpop: true }` to immediately exchange the Bearer session for a
552
+ * DPoP sender-constrained session via `/auth/dpop-bind` (RFC 9449). The
553
+ * returned tokens become unusable without this SDK instance's
554
+ * non-extractable private key, so a token leaked from storage (e.g.
555
+ * localStorage XSS) cannot be replayed from another origin.
556
+ */
557
+ async login(email, password, options) {
442
558
  this._log("login", email);
559
+ const wantsDpopPersist = options?.dpopPersist === true;
560
+ if (wantsDpopPersist && !options?.dpop) {
561
+ throw new AuthenticationError(
562
+ "dpopPersist: true requires dpop: true. Persisting Bearer tokens is the caller's responsibility (localStorage / sessionStorage)."
563
+ );
564
+ }
443
565
  const headers = {
444
566
  "Content-Type": "application/json"
445
567
  };
@@ -461,9 +583,81 @@ var _AuthManager = class _AuthManager {
461
583
  this._tokenExpiry = Date.now() + (data.expiresIn - 60) * 1e3;
462
584
  this._tokenType = "Bearer";
463
585
  this._refreshToken = data.refreshToken;
586
+ this._email = email;
587
+ this._dpopPersist = wantsDpopPersist;
464
588
  this._invalidateAuthScopedState();
589
+ if (options?.dpop) {
590
+ const bound = await this._bindDpopSession();
591
+ if (this._dpopPersist) {
592
+ await this._persistDpopSession();
593
+ }
594
+ return bound;
595
+ }
465
596
  return data;
466
597
  }
598
+ /**
599
+ * Exchange the current Bearer session for a DPoP-bound session via
600
+ * `POST /auth/dpop-bind` (RFC 9449). The server re-issues access and
601
+ * refresh tokens with `cnf.jkt` matching this SDK's DPoP key pair.
602
+ *
603
+ * Assumes `_token` / `_refreshToken` are already populated by a prior
604
+ * `login()` call (callers should not invoke this directly).
605
+ *
606
+ * @internal
607
+ */
608
+ async _bindDpopSession() {
609
+ if (!this._token) {
610
+ throw new AuthenticationError(
611
+ "Cannot bind DPoP session: no Bearer token. login() must succeed first."
612
+ );
613
+ }
614
+ if (this._dpopReady) await this._dpopReady;
615
+ if (!this._dpopKeyPair) {
616
+ throw new AuthenticationError(
617
+ "DPoP binding requires a generated DPoP key pair, but key generation was unavailable or failed. DPoP requires Web Crypto subtle.generateKey."
618
+ );
619
+ }
620
+ const bindUrl = this._baseUrl + "/auth/dpop-bind";
621
+ const accessToken = this._token;
622
+ const data = await this._doDpopBind(bindUrl, accessToken, this._dpopNonce, 0);
623
+ this._token = data.accessToken;
624
+ this._tokenExpiry = Date.now() + (data.expiresIn - 60) * 1e3;
625
+ this._tokenType = "DPoP";
626
+ this._refreshToken = data.refreshToken;
627
+ this._invalidateAuthScopedState();
628
+ return { ...data, tokenType: "DPoP" };
629
+ }
630
+ async _doDpopBind(bindUrl, accessToken, dpopNonce, retryCount) {
631
+ const dpopProof = await createDPoPProof(
632
+ this._dpopKeyPair,
633
+ "POST",
634
+ bindUrl,
635
+ accessToken,
636
+ dpopNonce
637
+ );
638
+ if (!dpopProof) {
639
+ throw new AuthenticationError("DPoP proof generation failed");
640
+ }
641
+ const headers = {
642
+ "Content-Type": "application/json",
643
+ Authorization: "Bearer " + accessToken,
644
+ DPoP: dpopProof
645
+ };
646
+ if (this._tenant) headers["NGSILD-Tenant"] = this._tenant;
647
+ const res = await fetch(bindUrl, { method: "POST", headers });
648
+ const serverNonce = res.headers.get("DPoP-Nonce");
649
+ if (serverNonce) this._dpopNonce = serverNonce;
650
+ if (res.status === 401 && serverNonce && serverNonce !== dpopNonce && retryCount < _AuthManager.MAX_DPOP_RETRIES) {
651
+ return this._doDpopBind(bindUrl, accessToken, serverNonce, retryCount + 1);
652
+ }
653
+ if (!res.ok) {
654
+ const e = await res.json().catch(() => ({}));
655
+ throw new AuthenticationError(
656
+ e.detail || e.description || "DPoP bind failed: " + res.status
657
+ );
658
+ }
659
+ return await res.json();
660
+ }
467
661
  /**
468
662
  * Set credentials externally (e.g. from a login API response).
469
663
  * When tokenType is 'Bearer' and refreshToken is provided, all subsequent
@@ -490,9 +684,81 @@ var _AuthManager = class _AuthManager {
490
684
  this._tokenExpiry = 0;
491
685
  this._refreshToken = null;
492
686
  this._tokenPromise = null;
687
+ this._email = null;
688
+ if (this._dpopPersist && this._tenant) {
689
+ void clearDPoPSession(this._tenant);
690
+ }
691
+ this._dpopPersist = false;
493
692
  this._anonymous = this._initialAnonymous;
494
693
  this._invalidateAuthScopedState();
495
694
  }
695
+ /**
696
+ * Restore a DPoP session previously persisted via `login({ dpop: true,
697
+ * dpopPersist: true })`. Returns true if a usable session was found and
698
+ * loaded into the SDK, false otherwise (no entry, IndexedDB unavailable,
699
+ * or the entry was for a different tenant).
700
+ *
701
+ * After a successful restore the SDK behaves as if `login()` had just
702
+ * succeeded: tokens are set, the DPoP key pair is rehydrated as a
703
+ * non-extractable `CryptoKey`, and subsequent requests automatically
704
+ * carry a fresh DPoP proof. Token refresh continues to flow through
705
+ * `/auth/refresh` and emits `tokenRefresh` events as usual.
706
+ *
707
+ * Token expiry is NOT validated here — the next `ensureToken()` call will
708
+ * trigger a refresh if the access token has expired (#1230). An expired
709
+ * refresh token will manifest as an `AuthenticationError` from that
710
+ * refresh attempt; consumers should treat such errors as "session ended,
711
+ * fall back to login()".
712
+ */
713
+ async restoreSession() {
714
+ if (this._initialAnonymous) return false;
715
+ if (!this._tenant) return false;
716
+ const persisted = await loadDPoPSession(this._tenant);
717
+ if (!persisted) return false;
718
+ if (persisted.tenant !== this._tenant) return false;
719
+ this._anonymous = false;
720
+ this._token = persisted.accessToken;
721
+ this._refreshToken = persisted.refreshToken;
722
+ this._tokenExpiry = persisted.expiresAt;
723
+ this._tokenType = "DPoP";
724
+ this._dpopKeyPair = persisted.keyPair;
725
+ this._dpopReady = Promise.resolve();
726
+ this._dpopNonce = null;
727
+ this._dpopPersist = true;
728
+ this._email = persisted.email ?? null;
729
+ this._tokenPromise = null;
730
+ this._invalidateAuthScopedState();
731
+ return true;
732
+ }
733
+ /**
734
+ * Whether this AuthManager currently has DPoP persistence enabled.
735
+ * Exposed for the `GeonicDB` wrapper to advertise behavior; internal
736
+ * consumers should read `_dpopPersist` directly.
737
+ */
738
+ isDpopPersisted() {
739
+ return this._dpopPersist;
740
+ }
741
+ /**
742
+ * Persist the current DPoP session to IndexedDB. Best-effort: a failed
743
+ * write is logged in debug mode but never propagated, since persistence
744
+ * is purely a UX optimization.
745
+ *
746
+ * @internal
747
+ */
748
+ async _persistDpopSession() {
749
+ if (!this._dpopPersist || !this._tenant || !this._dpopKeyPair || !this._token || !this._refreshToken) {
750
+ return;
751
+ }
752
+ const ok = await saveDPoPSession({
753
+ tenant: this._tenant,
754
+ accessToken: this._token,
755
+ refreshToken: this._refreshToken,
756
+ expiresAt: this._tokenExpiry,
757
+ email: this._email ?? void 0,
758
+ keyPair: this._dpopKeyPair
759
+ });
760
+ if (!ok) this._log("DPoP session persistence failed (IndexedDB unavailable?)");
761
+ }
496
762
  /**
497
763
  * Drop everything that may have been associated with the previous auth
498
764
  * context. Called on `login()` (after token assignment), `setCredentials()`,
@@ -535,12 +801,15 @@ var _AuthManager = class _AuthManager {
535
801
  return this._tokenPromise;
536
802
  }
537
803
  async _refreshBearerToken() {
538
- this._log("refreshing Bearer token");
804
+ this._log("refreshing token (type=%s)", this._tokenType);
539
805
  try {
540
- const res = await fetch(this._baseUrl + "/auth/refresh", {
806
+ const isDPoP = this._tokenType === "DPoP";
807
+ const refreshUrl = this._baseUrl + "/auth/refresh";
808
+ const body = JSON.stringify({ refreshToken: this._refreshToken });
809
+ const res = isDPoP ? await this._doDpopRefresh(refreshUrl, body, this._dpopNonce, 0) : await fetch(refreshUrl, {
541
810
  method: "POST",
542
811
  headers: { "Content-Type": "application/json" },
543
- body: JSON.stringify({ refreshToken: this._refreshToken })
812
+ body
544
813
  });
545
814
  if (!res.ok) {
546
815
  this._refreshToken = null;
@@ -551,7 +820,7 @@ var _AuthManager = class _AuthManager {
551
820
  const data = await res.json();
552
821
  this._token = data.accessToken;
553
822
  this._tokenExpiry = Date.now() + (data.expiresIn - 60) * 1e3;
554
- this._tokenType = "Bearer";
823
+ this._tokenType = data.tokenType ?? (isDPoP ? "DPoP" : "Bearer");
555
824
  this._refreshToken = data.refreshToken;
556
825
  this._tokenPromise = null;
557
826
  this.onTokenRefresh?.({
@@ -560,12 +829,51 @@ var _AuthManager = class _AuthManager {
560
829
  expiresIn: data.expiresIn,
561
830
  refreshToken: this._refreshToken
562
831
  });
832
+ if (this._dpopPersist) void this._persistDpopSession();
563
833
  return this._token;
564
834
  } catch (err) {
565
835
  this._tokenPromise = null;
566
836
  throw err;
567
837
  }
568
838
  }
839
+ /**
840
+ * Perform a DPoP-bound `/auth/refresh` call with the standard RFC 9449 §8
841
+ * `use_dpop_nonce` retry handshake.
842
+ *
843
+ * @internal
844
+ */
845
+ async _doDpopRefresh(refreshUrl, body, dpopNonce, retryCount) {
846
+ if (this._dpopReady) await this._dpopReady;
847
+ if (!this._dpopKeyPair) {
848
+ throw new AuthenticationError(
849
+ "DPoP-bound refresh requires a DPoP key pair, but key generation was unavailable."
850
+ );
851
+ }
852
+ const proof = await createDPoPProof(
853
+ this._dpopKeyPair,
854
+ "POST",
855
+ refreshUrl,
856
+ null,
857
+ dpopNonce
858
+ );
859
+ if (!proof) {
860
+ throw new AuthenticationError("DPoP proof generation failed");
861
+ }
862
+ const res = await fetch(refreshUrl, {
863
+ method: "POST",
864
+ headers: { "Content-Type": "application/json", DPoP: proof },
865
+ body
866
+ });
867
+ const serverNonce = res.headers.get("DPoP-Nonce");
868
+ if (serverNonce) this._dpopNonce = serverNonce;
869
+ if (res.status === 401 && serverNonce && serverNonce !== dpopNonce && retryCount < _AuthManager.MAX_DPOP_RETRIES) {
870
+ return this._doDpopRefresh(refreshUrl, body, serverNonce, retryCount + 1);
871
+ }
872
+ return res;
873
+ }
874
+ /* istanbul ignore next: PoW + DPoP token-exchange flow is exercised by E2E
875
+ against a live server. Unit-mocking each fetch + crypto step would not
876
+ verify the actual nonce/proof exchange. */
569
877
  async _acquireTokenViaPow() {
570
878
  try {
571
879
  if (this._dpopReady) await this._dpopReady;
@@ -602,6 +910,8 @@ var _AuthManager = class _AuthManager {
602
910
  throw err;
603
911
  }
604
912
  }
913
+ /* istanbul ignore next: DPoP nonce-retry handshake (RFC 9449 §8) — requires
914
+ a real server emitting `use_dpop_nonce` 400s. Exercised by E2E. */
605
915
  async _doTokenExchange(tokenUrl, tokenBody, dpopNonce, retryCount = 0) {
606
916
  const reqHeaders = {
607
917
  "Content-Type": "application/json"
@@ -1099,9 +1409,54 @@ var GeonicDB = class extends EventEmitter {
1099
1409
  }
1100
1410
  }
1101
1411
  // --- Authentication ---
1102
- /** Login with email and password (Bearer JWT). */
1103
- async login(email, password) {
1104
- return this._auth.login(email, password);
1412
+ /**
1413
+ * Login with email and password (Bearer JWT).
1414
+ *
1415
+ * Pass `{ dpop: true }` to immediately upgrade the session to a DPoP
1416
+ * sender-constrained token (RFC 9449). Recommended for browser apps that
1417
+ * persist tokens in storage exposed to XSS (localStorage / sessionStorage),
1418
+ * since DPoP-bound tokens are unusable without this SDK instance's
1419
+ * non-extractable private key.
1420
+ *
1421
+ * @example
1422
+ * ```ts
1423
+ * // Bearer (default, backward compatible)
1424
+ * await db.login('user@example.com', 'password');
1425
+ *
1426
+ * // DPoP-bound (recommended for SPAs)
1427
+ * await db.login('user@example.com', 'password', { dpop: true });
1428
+ *
1429
+ * // DPoP-bound + persisted across reloads (browser SPA UX)
1430
+ * await db.login('user@example.com', 'password', {
1431
+ * dpop: true,
1432
+ * dpopPersist: true,
1433
+ * });
1434
+ * ```
1435
+ */
1436
+ async login(email, password, options) {
1437
+ return this._auth.login(email, password, options);
1438
+ }
1439
+ /**
1440
+ * Rehydrate a DPoP session previously persisted via
1441
+ * `login({ dpop: true, dpopPersist: true })`.
1442
+ *
1443
+ * Returns true if a session for this tenant was found and loaded; false
1444
+ * if there was no entry, IndexedDB is unavailable, or the persisted
1445
+ * session was malformed. Call this at app startup before deciding
1446
+ * whether to render the login form.
1447
+ *
1448
+ * @example
1449
+ * ```ts
1450
+ * const db = new GeonicDB({ baseUrl, tenant });
1451
+ * if (await db.restoreSession()) {
1452
+ * // Tokens + DPoP private key recovered from IndexedDB — boot the app.
1453
+ * } else {
1454
+ * // No session; show the login form.
1455
+ * }
1456
+ * ```
1457
+ */
1458
+ async restoreSession() {
1459
+ return this._auth.restoreSession();
1105
1460
  }
1106
1461
  /**
1107
1462
  * Whether the SDK is currently operating in anonymous mode (no token held).