@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 +73 -1
- package/geonicdb.cjs +377 -22
- package/geonicdb.cjs.map +3 -3
- package/geonicdb.d.ts +1 -0
- package/geonicdb.iife.js +377 -22
- package/geonicdb.iife.js.map +3 -3
- package/geonicdb.mjs +377 -22
- package/geonicdb.mjs.map +3 -3
- package/package.json +1 -1
package/geonicdb.d.ts
CHANGED
package/geonicdb.iife.js
CHANGED
|
@@ -72,8 +72,23 @@ var GeonicDBModule = (() => {
|
|
|
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 @@ var GeonicDBModule = (() => {
|
|
|
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 @@ var GeonicDBModule = (() => {
|
|
|
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 GeonicDBModule = (() => {
|
|
|
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 GeonicDBModule = (() => {
|
|
|
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 GeonicDBModule = (() => {
|
|
|
437
545
|
emitCacheEvent(name, payload) {
|
|
438
546
|
this._emitCacheEvent?.(name, payload);
|
|
439
547
|
}
|
|
440
|
-
/**
|
|
441
|
-
|
|
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 GeonicDBModule = (() => {
|
|
|
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 GeonicDBModule = (() => {
|
|
|
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 GeonicDBModule = (() => {
|
|
|
535
801
|
return this._tokenPromise;
|
|
536
802
|
}
|
|
537
803
|
async _refreshBearerToken() {
|
|
538
|
-
this._log("refreshing
|
|
804
|
+
this._log("refreshing token (type=%s)", this._tokenType);
|
|
539
805
|
try {
|
|
540
|
-
const
|
|
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
|
|
812
|
+
body
|
|
544
813
|
});
|
|
545
814
|
if (!res.ok) {
|
|
546
815
|
this._refreshToken = null;
|
|
@@ -551,7 +820,7 @@ var GeonicDBModule = (() => {
|
|
|
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 GeonicDBModule = (() => {
|
|
|
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 GeonicDBModule = (() => {
|
|
|
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 GeonicDBModule = (() => {
|
|
|
1099
1409
|
}
|
|
1100
1410
|
}
|
|
1101
1411
|
// --- Authentication ---
|
|
1102
|
-
/**
|
|
1103
|
-
|
|
1104
|
-
|
|
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).
|