@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/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
|
|
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
|
-
/**
|
|
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 _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
|
|
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 _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
|
-
/**
|
|
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).
|