@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.mjs
CHANGED
|
@@ -38,8 +38,23 @@ var EventEmitter = class {
|
|
|
38
38
|
}
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
+
// src/sdk/constants.ts
|
|
42
|
+
var DEFAULT_TOKEN_TTL_SEC = 3600;
|
|
43
|
+
var TOKEN_REFRESH_LEEWAY_MS = 12e4;
|
|
44
|
+
var TOKEN_REFRESH_MIN_MS = 1e4;
|
|
45
|
+
var RECONNECT_MAX_ATTEMPTS = 10;
|
|
46
|
+
var RECONNECT_BASE_MS = 1e3;
|
|
47
|
+
var RECONNECT_MAX_DELAY_MS = 3e4;
|
|
48
|
+
var SUB_PROTOCOL = "access_token";
|
|
49
|
+
var SDK_CACHE_MAX_ENTRIES_DEFAULT = 1e3;
|
|
50
|
+
var SDK_POLL_INTERVAL_MS_DEFAULT = 5e3;
|
|
51
|
+
var DPOP_IDB_NAME = "geonicdb-sdk";
|
|
52
|
+
var DPOP_IDB_STORE = "dpop-sessions";
|
|
53
|
+
var DPOP_IDB_VERSION = 1;
|
|
54
|
+
|
|
41
55
|
// src/sdk/dpop.ts
|
|
42
56
|
var dpopSupported = typeof crypto !== "undefined" && !!crypto.subtle && !!crypto.subtle.generateKey;
|
|
57
|
+
var dpopPersistenceSupported = typeof globalThis.indexedDB !== "undefined" && typeof structuredClone !== "undefined";
|
|
43
58
|
function b64url(buf) {
|
|
44
59
|
const arr = buf instanceof ArrayBuffer ? new Uint8Array(buf) : buf;
|
|
45
60
|
let str = "";
|
|
@@ -96,6 +111,102 @@ async function createDPoPProof(keyPair, htm, htu, accessToken, nonce) {
|
|
|
96
111
|
);
|
|
97
112
|
return signingInput + "." + b64url(new Uint8Array(sigBuf));
|
|
98
113
|
}
|
|
114
|
+
function openSessionDB() {
|
|
115
|
+
return new Promise((resolve, reject) => {
|
|
116
|
+
const req = globalThis.indexedDB.open(DPOP_IDB_NAME, DPOP_IDB_VERSION);
|
|
117
|
+
req.onupgradeneeded = () => {
|
|
118
|
+
const db = req.result;
|
|
119
|
+
if (!db.objectStoreNames.contains(DPOP_IDB_STORE)) {
|
|
120
|
+
db.createObjectStore(DPOP_IDB_STORE);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
req.onsuccess = () => {
|
|
124
|
+
resolve(req.result);
|
|
125
|
+
};
|
|
126
|
+
req.onerror = () => {
|
|
127
|
+
reject(req.error ?? new Error("IndexedDB open failed"));
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
async function saveDPoPSession(session) {
|
|
132
|
+
if (!dpopPersistenceSupported) return false;
|
|
133
|
+
let db = null;
|
|
134
|
+
try {
|
|
135
|
+
db = await openSessionDB();
|
|
136
|
+
await new Promise((resolve, reject) => {
|
|
137
|
+
const tx = db.transaction(DPOP_IDB_STORE, "readwrite");
|
|
138
|
+
tx.objectStore(DPOP_IDB_STORE).put(session, session.tenant);
|
|
139
|
+
tx.oncomplete = () => {
|
|
140
|
+
resolve();
|
|
141
|
+
};
|
|
142
|
+
tx.onerror = () => {
|
|
143
|
+
reject(tx.error ?? new Error("IndexedDB put failed"));
|
|
144
|
+
};
|
|
145
|
+
tx.onabort = () => {
|
|
146
|
+
reject(tx.error ?? new Error("IndexedDB transaction aborted"));
|
|
147
|
+
};
|
|
148
|
+
});
|
|
149
|
+
return true;
|
|
150
|
+
} catch {
|
|
151
|
+
return false;
|
|
152
|
+
} finally {
|
|
153
|
+
db?.close();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async function loadDPoPSession(tenant) {
|
|
157
|
+
if (!dpopPersistenceSupported) return null;
|
|
158
|
+
let db = null;
|
|
159
|
+
try {
|
|
160
|
+
db = await openSessionDB();
|
|
161
|
+
const data = await new Promise((resolve, reject) => {
|
|
162
|
+
const tx = db.transaction(DPOP_IDB_STORE, "readonly");
|
|
163
|
+
const req = tx.objectStore(DPOP_IDB_STORE).get(tenant);
|
|
164
|
+
req.onsuccess = () => {
|
|
165
|
+
resolve(req.result);
|
|
166
|
+
};
|
|
167
|
+
req.onerror = () => {
|
|
168
|
+
reject(req.error ?? new Error("IndexedDB get failed"));
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
return validatePersistedSession(data);
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
174
|
+
} finally {
|
|
175
|
+
db?.close();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
async function clearDPoPSession(tenant) {
|
|
179
|
+
if (!dpopPersistenceSupported) return;
|
|
180
|
+
let db = null;
|
|
181
|
+
try {
|
|
182
|
+
db = await openSessionDB();
|
|
183
|
+
await new Promise((resolve, reject) => {
|
|
184
|
+
const tx = db.transaction(DPOP_IDB_STORE, "readwrite");
|
|
185
|
+
tx.objectStore(DPOP_IDB_STORE).delete(tenant);
|
|
186
|
+
tx.oncomplete = () => {
|
|
187
|
+
resolve();
|
|
188
|
+
};
|
|
189
|
+
tx.onerror = () => {
|
|
190
|
+
reject(tx.error ?? new Error("IndexedDB delete failed"));
|
|
191
|
+
};
|
|
192
|
+
tx.onabort = () => {
|
|
193
|
+
reject(tx.error ?? new Error("IndexedDB transaction aborted"));
|
|
194
|
+
};
|
|
195
|
+
});
|
|
196
|
+
} catch {
|
|
197
|
+
} finally {
|
|
198
|
+
db?.close();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function validatePersistedSession(data) {
|
|
202
|
+
if (!data || typeof data !== "object") return null;
|
|
203
|
+
const s = data;
|
|
204
|
+
const kp = s.keyPair;
|
|
205
|
+
if (typeof s.tenant !== "string" || typeof s.accessToken !== "string" || typeof s.refreshToken !== "string" || typeof s.expiresAt !== "number" || !kp || !kp.privateKey || !kp.publicJwk) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
return data;
|
|
209
|
+
}
|
|
99
210
|
|
|
100
211
|
// src/sdk/pow.ts
|
|
101
212
|
var BATCH_SIZE = 1e3;
|
|
@@ -139,17 +250,6 @@ async function solvePoW(challenge, difficulty) {
|
|
|
139
250
|
return tryBatch();
|
|
140
251
|
}
|
|
141
252
|
|
|
142
|
-
// src/sdk/constants.ts
|
|
143
|
-
var DEFAULT_TOKEN_TTL_SEC = 3600;
|
|
144
|
-
var TOKEN_REFRESH_LEEWAY_MS = 12e4;
|
|
145
|
-
var TOKEN_REFRESH_MIN_MS = 1e4;
|
|
146
|
-
var RECONNECT_MAX_ATTEMPTS = 10;
|
|
147
|
-
var RECONNECT_BASE_MS = 1e3;
|
|
148
|
-
var RECONNECT_MAX_DELAY_MS = 3e4;
|
|
149
|
-
var SUB_PROTOCOL = "access_token";
|
|
150
|
-
var SDK_CACHE_MAX_ENTRIES_DEFAULT = 1e3;
|
|
151
|
-
var SDK_POLL_INTERVAL_MS_DEFAULT = 5e3;
|
|
152
|
-
|
|
153
253
|
// src/sdk/errors.ts
|
|
154
254
|
var GeonicDBError = class extends Error {
|
|
155
255
|
constructor(message, statusCode = 0) {
|
|
@@ -342,6 +442,14 @@ var _AuthManager = class _AuthManager {
|
|
|
342
442
|
__publicField(this, "_dpopKeyPair", null);
|
|
343
443
|
__publicField(this, "_dpopReady", null);
|
|
344
444
|
__publicField(this, "_dpopNonce", null);
|
|
445
|
+
/**
|
|
446
|
+
* #1230: when true, every DPoP token mutation (login bind, refresh) is
|
|
447
|
+
* mirrored to IndexedDB so the session survives a page reload. Toggled
|
|
448
|
+
* by `login({ dpopPersist: true })` and `restoreSession()`.
|
|
449
|
+
*/
|
|
450
|
+
__publicField(this, "_dpopPersist", false);
|
|
451
|
+
/** Email captured at login, persisted for UI display. */
|
|
452
|
+
__publicField(this, "_email", null);
|
|
345
453
|
__publicField(this, "_tokenPromise", null);
|
|
346
454
|
/** Callback to emit tokenRefresh events. Set by GeonicDB class. */
|
|
347
455
|
__publicField(this, "onTokenRefresh", null);
|
|
@@ -368,9 +476,9 @@ var _AuthManager = class _AuthManager {
|
|
|
368
476
|
this._anonymous = anonymous;
|
|
369
477
|
if (!anonymous && dpopSupported) {
|
|
370
478
|
this._dpopReady = generateDPoPKeyPair().then((kp) => {
|
|
371
|
-
this._dpopKeyPair = kp;
|
|
479
|
+
if (!this._dpopKeyPair) this._dpopKeyPair = kp;
|
|
372
480
|
}).catch(() => {
|
|
373
|
-
this._dpopKeyPair = null;
|
|
481
|
+
if (!this._dpopKeyPair) this._dpopKeyPair = null;
|
|
374
482
|
});
|
|
375
483
|
}
|
|
376
484
|
}
|
|
@@ -403,9 +511,23 @@ var _AuthManager = class _AuthManager {
|
|
|
403
511
|
emitCacheEvent(name, payload) {
|
|
404
512
|
this._emitCacheEvent?.(name, payload);
|
|
405
513
|
}
|
|
406
|
-
/**
|
|
407
|
-
|
|
514
|
+
/**
|
|
515
|
+
* Login with email and password (Bearer JWT).
|
|
516
|
+
*
|
|
517
|
+
* Pass `{ dpop: true }` to immediately exchange the Bearer session for a
|
|
518
|
+
* DPoP sender-constrained session via `/auth/dpop-bind` (RFC 9449). The
|
|
519
|
+
* returned tokens become unusable without this SDK instance's
|
|
520
|
+
* non-extractable private key, so a token leaked from storage (e.g.
|
|
521
|
+
* localStorage XSS) cannot be replayed from another origin.
|
|
522
|
+
*/
|
|
523
|
+
async login(email, password, options) {
|
|
408
524
|
this._log("login", email);
|
|
525
|
+
const wantsDpopPersist = options?.dpopPersist === true;
|
|
526
|
+
if (wantsDpopPersist && !options?.dpop) {
|
|
527
|
+
throw new AuthenticationError(
|
|
528
|
+
"dpopPersist: true requires dpop: true. Persisting Bearer tokens is the caller's responsibility (localStorage / sessionStorage)."
|
|
529
|
+
);
|
|
530
|
+
}
|
|
409
531
|
const headers = {
|
|
410
532
|
"Content-Type": "application/json"
|
|
411
533
|
};
|
|
@@ -427,9 +549,81 @@ var _AuthManager = class _AuthManager {
|
|
|
427
549
|
this._tokenExpiry = Date.now() + (data.expiresIn - 60) * 1e3;
|
|
428
550
|
this._tokenType = "Bearer";
|
|
429
551
|
this._refreshToken = data.refreshToken;
|
|
552
|
+
this._email = email;
|
|
553
|
+
this._dpopPersist = wantsDpopPersist;
|
|
430
554
|
this._invalidateAuthScopedState();
|
|
555
|
+
if (options?.dpop) {
|
|
556
|
+
const bound = await this._bindDpopSession();
|
|
557
|
+
if (this._dpopPersist) {
|
|
558
|
+
await this._persistDpopSession();
|
|
559
|
+
}
|
|
560
|
+
return bound;
|
|
561
|
+
}
|
|
431
562
|
return data;
|
|
432
563
|
}
|
|
564
|
+
/**
|
|
565
|
+
* Exchange the current Bearer session for a DPoP-bound session via
|
|
566
|
+
* `POST /auth/dpop-bind` (RFC 9449). The server re-issues access and
|
|
567
|
+
* refresh tokens with `cnf.jkt` matching this SDK's DPoP key pair.
|
|
568
|
+
*
|
|
569
|
+
* Assumes `_token` / `_refreshToken` are already populated by a prior
|
|
570
|
+
* `login()` call (callers should not invoke this directly).
|
|
571
|
+
*
|
|
572
|
+
* @internal
|
|
573
|
+
*/
|
|
574
|
+
async _bindDpopSession() {
|
|
575
|
+
if (!this._token) {
|
|
576
|
+
throw new AuthenticationError(
|
|
577
|
+
"Cannot bind DPoP session: no Bearer token. login() must succeed first."
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
if (this._dpopReady) await this._dpopReady;
|
|
581
|
+
if (!this._dpopKeyPair) {
|
|
582
|
+
throw new AuthenticationError(
|
|
583
|
+
"DPoP binding requires a generated DPoP key pair, but key generation was unavailable or failed. DPoP requires Web Crypto subtle.generateKey."
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
const bindUrl = this._baseUrl + "/auth/dpop-bind";
|
|
587
|
+
const accessToken = this._token;
|
|
588
|
+
const data = await this._doDpopBind(bindUrl, accessToken, this._dpopNonce, 0);
|
|
589
|
+
this._token = data.accessToken;
|
|
590
|
+
this._tokenExpiry = Date.now() + (data.expiresIn - 60) * 1e3;
|
|
591
|
+
this._tokenType = "DPoP";
|
|
592
|
+
this._refreshToken = data.refreshToken;
|
|
593
|
+
this._invalidateAuthScopedState();
|
|
594
|
+
return { ...data, tokenType: "DPoP" };
|
|
595
|
+
}
|
|
596
|
+
async _doDpopBind(bindUrl, accessToken, dpopNonce, retryCount) {
|
|
597
|
+
const dpopProof = await createDPoPProof(
|
|
598
|
+
this._dpopKeyPair,
|
|
599
|
+
"POST",
|
|
600
|
+
bindUrl,
|
|
601
|
+
accessToken,
|
|
602
|
+
dpopNonce
|
|
603
|
+
);
|
|
604
|
+
if (!dpopProof) {
|
|
605
|
+
throw new AuthenticationError("DPoP proof generation failed");
|
|
606
|
+
}
|
|
607
|
+
const headers = {
|
|
608
|
+
"Content-Type": "application/json",
|
|
609
|
+
Authorization: "Bearer " + accessToken,
|
|
610
|
+
DPoP: dpopProof
|
|
611
|
+
};
|
|
612
|
+
if (this._tenant) headers["NGSILD-Tenant"] = this._tenant;
|
|
613
|
+
const res = await fetch(bindUrl, { method: "POST", headers });
|
|
614
|
+
const serverNonce = res.headers.get("DPoP-Nonce");
|
|
615
|
+
if (serverNonce) this._dpopNonce = serverNonce;
|
|
616
|
+
if (res.status === 401 && serverNonce && serverNonce !== dpopNonce && retryCount < _AuthManager.MAX_DPOP_RETRIES) {
|
|
617
|
+
return this._doDpopBind(bindUrl, accessToken, serverNonce, retryCount + 1);
|
|
618
|
+
}
|
|
619
|
+
if (!res.ok) {
|
|
620
|
+
const e = await res.json().catch(() => ({}));
|
|
621
|
+
throw new AuthenticationError(
|
|
622
|
+
e.detail || e.description || "DPoP bind failed: " + res.status
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
return await res.json();
|
|
626
|
+
}
|
|
433
627
|
/**
|
|
434
628
|
* Set credentials externally (e.g. from a login API response).
|
|
435
629
|
* When tokenType is 'Bearer' and refreshToken is provided, all subsequent
|
|
@@ -456,9 +650,81 @@ var _AuthManager = class _AuthManager {
|
|
|
456
650
|
this._tokenExpiry = 0;
|
|
457
651
|
this._refreshToken = null;
|
|
458
652
|
this._tokenPromise = null;
|
|
653
|
+
this._email = null;
|
|
654
|
+
if (this._dpopPersist && this._tenant) {
|
|
655
|
+
void clearDPoPSession(this._tenant);
|
|
656
|
+
}
|
|
657
|
+
this._dpopPersist = false;
|
|
459
658
|
this._anonymous = this._initialAnonymous;
|
|
460
659
|
this._invalidateAuthScopedState();
|
|
461
660
|
}
|
|
661
|
+
/**
|
|
662
|
+
* Restore a DPoP session previously persisted via `login({ dpop: true,
|
|
663
|
+
* dpopPersist: true })`. Returns true if a usable session was found and
|
|
664
|
+
* loaded into the SDK, false otherwise (no entry, IndexedDB unavailable,
|
|
665
|
+
* or the entry was for a different tenant).
|
|
666
|
+
*
|
|
667
|
+
* After a successful restore the SDK behaves as if `login()` had just
|
|
668
|
+
* succeeded: tokens are set, the DPoP key pair is rehydrated as a
|
|
669
|
+
* non-extractable `CryptoKey`, and subsequent requests automatically
|
|
670
|
+
* carry a fresh DPoP proof. Token refresh continues to flow through
|
|
671
|
+
* `/auth/refresh` and emits `tokenRefresh` events as usual.
|
|
672
|
+
*
|
|
673
|
+
* Token expiry is NOT validated here — the next `ensureToken()` call will
|
|
674
|
+
* trigger a refresh if the access token has expired (#1230). An expired
|
|
675
|
+
* refresh token will manifest as an `AuthenticationError` from that
|
|
676
|
+
* refresh attempt; consumers should treat such errors as "session ended,
|
|
677
|
+
* fall back to login()".
|
|
678
|
+
*/
|
|
679
|
+
async restoreSession() {
|
|
680
|
+
if (this._initialAnonymous) return false;
|
|
681
|
+
if (!this._tenant) return false;
|
|
682
|
+
const persisted = await loadDPoPSession(this._tenant);
|
|
683
|
+
if (!persisted) return false;
|
|
684
|
+
if (persisted.tenant !== this._tenant) return false;
|
|
685
|
+
this._anonymous = false;
|
|
686
|
+
this._token = persisted.accessToken;
|
|
687
|
+
this._refreshToken = persisted.refreshToken;
|
|
688
|
+
this._tokenExpiry = persisted.expiresAt;
|
|
689
|
+
this._tokenType = "DPoP";
|
|
690
|
+
this._dpopKeyPair = persisted.keyPair;
|
|
691
|
+
this._dpopReady = Promise.resolve();
|
|
692
|
+
this._dpopNonce = null;
|
|
693
|
+
this._dpopPersist = true;
|
|
694
|
+
this._email = persisted.email ?? null;
|
|
695
|
+
this._tokenPromise = null;
|
|
696
|
+
this._invalidateAuthScopedState();
|
|
697
|
+
return true;
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Whether this AuthManager currently has DPoP persistence enabled.
|
|
701
|
+
* Exposed for the `GeonicDB` wrapper to advertise behavior; internal
|
|
702
|
+
* consumers should read `_dpopPersist` directly.
|
|
703
|
+
*/
|
|
704
|
+
isDpopPersisted() {
|
|
705
|
+
return this._dpopPersist;
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Persist the current DPoP session to IndexedDB. Best-effort: a failed
|
|
709
|
+
* write is logged in debug mode but never propagated, since persistence
|
|
710
|
+
* is purely a UX optimization.
|
|
711
|
+
*
|
|
712
|
+
* @internal
|
|
713
|
+
*/
|
|
714
|
+
async _persistDpopSession() {
|
|
715
|
+
if (!this._dpopPersist || !this._tenant || !this._dpopKeyPair || !this._token || !this._refreshToken) {
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const ok = await saveDPoPSession({
|
|
719
|
+
tenant: this._tenant,
|
|
720
|
+
accessToken: this._token,
|
|
721
|
+
refreshToken: this._refreshToken,
|
|
722
|
+
expiresAt: this._tokenExpiry,
|
|
723
|
+
email: this._email ?? void 0,
|
|
724
|
+
keyPair: this._dpopKeyPair
|
|
725
|
+
});
|
|
726
|
+
if (!ok) this._log("DPoP session persistence failed (IndexedDB unavailable?)");
|
|
727
|
+
}
|
|
462
728
|
/**
|
|
463
729
|
* Drop everything that may have been associated with the previous auth
|
|
464
730
|
* context. Called on `login()` (after token assignment), `setCredentials()`,
|
|
@@ -501,12 +767,15 @@ var _AuthManager = class _AuthManager {
|
|
|
501
767
|
return this._tokenPromise;
|
|
502
768
|
}
|
|
503
769
|
async _refreshBearerToken() {
|
|
504
|
-
this._log("refreshing
|
|
770
|
+
this._log("refreshing token (type=%s)", this._tokenType);
|
|
505
771
|
try {
|
|
506
|
-
const
|
|
772
|
+
const isDPoP = this._tokenType === "DPoP";
|
|
773
|
+
const refreshUrl = this._baseUrl + "/auth/refresh";
|
|
774
|
+
const body = JSON.stringify({ refreshToken: this._refreshToken });
|
|
775
|
+
const res = isDPoP ? await this._doDpopRefresh(refreshUrl, body, this._dpopNonce, 0) : await fetch(refreshUrl, {
|
|
507
776
|
method: "POST",
|
|
508
777
|
headers: { "Content-Type": "application/json" },
|
|
509
|
-
body
|
|
778
|
+
body
|
|
510
779
|
});
|
|
511
780
|
if (!res.ok) {
|
|
512
781
|
this._refreshToken = null;
|
|
@@ -517,7 +786,7 @@ var _AuthManager = class _AuthManager {
|
|
|
517
786
|
const data = await res.json();
|
|
518
787
|
this._token = data.accessToken;
|
|
519
788
|
this._tokenExpiry = Date.now() + (data.expiresIn - 60) * 1e3;
|
|
520
|
-
this._tokenType = "Bearer";
|
|
789
|
+
this._tokenType = data.tokenType ?? (isDPoP ? "DPoP" : "Bearer");
|
|
521
790
|
this._refreshToken = data.refreshToken;
|
|
522
791
|
this._tokenPromise = null;
|
|
523
792
|
this.onTokenRefresh?.({
|
|
@@ -526,12 +795,51 @@ var _AuthManager = class _AuthManager {
|
|
|
526
795
|
expiresIn: data.expiresIn,
|
|
527
796
|
refreshToken: this._refreshToken
|
|
528
797
|
});
|
|
798
|
+
if (this._dpopPersist) void this._persistDpopSession();
|
|
529
799
|
return this._token;
|
|
530
800
|
} catch (err) {
|
|
531
801
|
this._tokenPromise = null;
|
|
532
802
|
throw err;
|
|
533
803
|
}
|
|
534
804
|
}
|
|
805
|
+
/**
|
|
806
|
+
* Perform a DPoP-bound `/auth/refresh` call with the standard RFC 9449 §8
|
|
807
|
+
* `use_dpop_nonce` retry handshake.
|
|
808
|
+
*
|
|
809
|
+
* @internal
|
|
810
|
+
*/
|
|
811
|
+
async _doDpopRefresh(refreshUrl, body, dpopNonce, retryCount) {
|
|
812
|
+
if (this._dpopReady) await this._dpopReady;
|
|
813
|
+
if (!this._dpopKeyPair) {
|
|
814
|
+
throw new AuthenticationError(
|
|
815
|
+
"DPoP-bound refresh requires a DPoP key pair, but key generation was unavailable."
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
const proof = await createDPoPProof(
|
|
819
|
+
this._dpopKeyPair,
|
|
820
|
+
"POST",
|
|
821
|
+
refreshUrl,
|
|
822
|
+
null,
|
|
823
|
+
dpopNonce
|
|
824
|
+
);
|
|
825
|
+
if (!proof) {
|
|
826
|
+
throw new AuthenticationError("DPoP proof generation failed");
|
|
827
|
+
}
|
|
828
|
+
const res = await fetch(refreshUrl, {
|
|
829
|
+
method: "POST",
|
|
830
|
+
headers: { "Content-Type": "application/json", DPoP: proof },
|
|
831
|
+
body
|
|
832
|
+
});
|
|
833
|
+
const serverNonce = res.headers.get("DPoP-Nonce");
|
|
834
|
+
if (serverNonce) this._dpopNonce = serverNonce;
|
|
835
|
+
if (res.status === 401 && serverNonce && serverNonce !== dpopNonce && retryCount < _AuthManager.MAX_DPOP_RETRIES) {
|
|
836
|
+
return this._doDpopRefresh(refreshUrl, body, serverNonce, retryCount + 1);
|
|
837
|
+
}
|
|
838
|
+
return res;
|
|
839
|
+
}
|
|
840
|
+
/* istanbul ignore next: PoW + DPoP token-exchange flow is exercised by E2E
|
|
841
|
+
against a live server. Unit-mocking each fetch + crypto step would not
|
|
842
|
+
verify the actual nonce/proof exchange. */
|
|
535
843
|
async _acquireTokenViaPow() {
|
|
536
844
|
try {
|
|
537
845
|
if (this._dpopReady) await this._dpopReady;
|
|
@@ -568,6 +876,8 @@ var _AuthManager = class _AuthManager {
|
|
|
568
876
|
throw err;
|
|
569
877
|
}
|
|
570
878
|
}
|
|
879
|
+
/* istanbul ignore next: DPoP nonce-retry handshake (RFC 9449 §8) — requires
|
|
880
|
+
a real server emitting `use_dpop_nonce` 400s. Exercised by E2E. */
|
|
571
881
|
async _doTokenExchange(tokenUrl, tokenBody, dpopNonce, retryCount = 0) {
|
|
572
882
|
const reqHeaders = {
|
|
573
883
|
"Content-Type": "application/json"
|
|
@@ -1065,9 +1375,54 @@ var GeonicDB = class extends EventEmitter {
|
|
|
1065
1375
|
}
|
|
1066
1376
|
}
|
|
1067
1377
|
// --- Authentication ---
|
|
1068
|
-
/**
|
|
1069
|
-
|
|
1070
|
-
|
|
1378
|
+
/**
|
|
1379
|
+
* Login with email and password (Bearer JWT).
|
|
1380
|
+
*
|
|
1381
|
+
* Pass `{ dpop: true }` to immediately upgrade the session to a DPoP
|
|
1382
|
+
* sender-constrained token (RFC 9449). Recommended for browser apps that
|
|
1383
|
+
* persist tokens in storage exposed to XSS (localStorage / sessionStorage),
|
|
1384
|
+
* since DPoP-bound tokens are unusable without this SDK instance's
|
|
1385
|
+
* non-extractable private key.
|
|
1386
|
+
*
|
|
1387
|
+
* @example
|
|
1388
|
+
* ```ts
|
|
1389
|
+
* // Bearer (default, backward compatible)
|
|
1390
|
+
* await db.login('user@example.com', 'password');
|
|
1391
|
+
*
|
|
1392
|
+
* // DPoP-bound (recommended for SPAs)
|
|
1393
|
+
* await db.login('user@example.com', 'password', { dpop: true });
|
|
1394
|
+
*
|
|
1395
|
+
* // DPoP-bound + persisted across reloads (browser SPA UX)
|
|
1396
|
+
* await db.login('user@example.com', 'password', {
|
|
1397
|
+
* dpop: true,
|
|
1398
|
+
* dpopPersist: true,
|
|
1399
|
+
* });
|
|
1400
|
+
* ```
|
|
1401
|
+
*/
|
|
1402
|
+
async login(email, password, options) {
|
|
1403
|
+
return this._auth.login(email, password, options);
|
|
1404
|
+
}
|
|
1405
|
+
/**
|
|
1406
|
+
* Rehydrate a DPoP session previously persisted via
|
|
1407
|
+
* `login({ dpop: true, dpopPersist: true })`.
|
|
1408
|
+
*
|
|
1409
|
+
* Returns true if a session for this tenant was found and loaded; false
|
|
1410
|
+
* if there was no entry, IndexedDB is unavailable, or the persisted
|
|
1411
|
+
* session was malformed. Call this at app startup before deciding
|
|
1412
|
+
* whether to render the login form.
|
|
1413
|
+
*
|
|
1414
|
+
* @example
|
|
1415
|
+
* ```ts
|
|
1416
|
+
* const db = new GeonicDB({ baseUrl, tenant });
|
|
1417
|
+
* if (await db.restoreSession()) {
|
|
1418
|
+
* // Tokens + DPoP private key recovered from IndexedDB — boot the app.
|
|
1419
|
+
* } else {
|
|
1420
|
+
* // No session; show the login form.
|
|
1421
|
+
* }
|
|
1422
|
+
* ```
|
|
1423
|
+
*/
|
|
1424
|
+
async restoreSession() {
|
|
1425
|
+
return this._auth.restoreSession();
|
|
1071
1426
|
}
|
|
1072
1427
|
/**
|
|
1073
1428
|
* Whether the SDK is currently operating in anonymous mode (no token held).
|