@geolonia/geonicdb-sdk 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -1
- package/geonicdb.cjs +144 -9
- package/geonicdb.cjs.map +2 -2
- package/geonicdb.d.ts +1 -0
- package/geonicdb.iife.js +144 -9
- package/geonicdb.iife.js.map +2 -2
- package/geonicdb.mjs +144 -9
- package/geonicdb.mjs.map +2 -2
- package/package.json +1 -1
package/geonicdb.mjs
CHANGED
|
@@ -403,8 +403,16 @@ var _AuthManager = class _AuthManager {
|
|
|
403
403
|
emitCacheEvent(name, payload) {
|
|
404
404
|
this._emitCacheEvent?.(name, payload);
|
|
405
405
|
}
|
|
406
|
-
/**
|
|
407
|
-
|
|
406
|
+
/**
|
|
407
|
+
* Login with email and password (Bearer JWT).
|
|
408
|
+
*
|
|
409
|
+
* Pass `{ dpop: true }` to immediately exchange the Bearer session for a
|
|
410
|
+
* DPoP sender-constrained session via `/auth/dpop-bind` (RFC 9449). The
|
|
411
|
+
* returned tokens become unusable without this SDK instance's
|
|
412
|
+
* non-extractable private key, so a token leaked from storage (e.g.
|
|
413
|
+
* localStorage XSS) cannot be replayed from another origin.
|
|
414
|
+
*/
|
|
415
|
+
async login(email, password, options) {
|
|
408
416
|
this._log("login", email);
|
|
409
417
|
const headers = {
|
|
410
418
|
"Content-Type": "application/json"
|
|
@@ -428,8 +436,75 @@ var _AuthManager = class _AuthManager {
|
|
|
428
436
|
this._tokenType = "Bearer";
|
|
429
437
|
this._refreshToken = data.refreshToken;
|
|
430
438
|
this._invalidateAuthScopedState();
|
|
439
|
+
if (options?.dpop) {
|
|
440
|
+
const bound = await this._bindDpopSession();
|
|
441
|
+
return bound;
|
|
442
|
+
}
|
|
431
443
|
return data;
|
|
432
444
|
}
|
|
445
|
+
/**
|
|
446
|
+
* Exchange the current Bearer session for a DPoP-bound session via
|
|
447
|
+
* `POST /auth/dpop-bind` (RFC 9449). The server re-issues access and
|
|
448
|
+
* refresh tokens with `cnf.jkt` matching this SDK's DPoP key pair.
|
|
449
|
+
*
|
|
450
|
+
* Assumes `_token` / `_refreshToken` are already populated by a prior
|
|
451
|
+
* `login()` call (callers should not invoke this directly).
|
|
452
|
+
*
|
|
453
|
+
* @internal
|
|
454
|
+
*/
|
|
455
|
+
async _bindDpopSession() {
|
|
456
|
+
if (!this._token) {
|
|
457
|
+
throw new AuthenticationError(
|
|
458
|
+
"Cannot bind DPoP session: no Bearer token. login() must succeed first."
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
if (this._dpopReady) await this._dpopReady;
|
|
462
|
+
if (!this._dpopKeyPair) {
|
|
463
|
+
throw new AuthenticationError(
|
|
464
|
+
"DPoP binding requires a generated DPoP key pair, but key generation was unavailable or failed. DPoP requires Web Crypto subtle.generateKey."
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
const bindUrl = this._baseUrl + "/auth/dpop-bind";
|
|
468
|
+
const accessToken = this._token;
|
|
469
|
+
const data = await this._doDpopBind(bindUrl, accessToken, this._dpopNonce, 0);
|
|
470
|
+
this._token = data.accessToken;
|
|
471
|
+
this._tokenExpiry = Date.now() + (data.expiresIn - 60) * 1e3;
|
|
472
|
+
this._tokenType = "DPoP";
|
|
473
|
+
this._refreshToken = data.refreshToken;
|
|
474
|
+
this._invalidateAuthScopedState();
|
|
475
|
+
return { ...data, tokenType: "DPoP" };
|
|
476
|
+
}
|
|
477
|
+
async _doDpopBind(bindUrl, accessToken, dpopNonce, retryCount) {
|
|
478
|
+
const dpopProof = await createDPoPProof(
|
|
479
|
+
this._dpopKeyPair,
|
|
480
|
+
"POST",
|
|
481
|
+
bindUrl,
|
|
482
|
+
accessToken,
|
|
483
|
+
dpopNonce
|
|
484
|
+
);
|
|
485
|
+
if (!dpopProof) {
|
|
486
|
+
throw new AuthenticationError("DPoP proof generation failed");
|
|
487
|
+
}
|
|
488
|
+
const headers = {
|
|
489
|
+
"Content-Type": "application/json",
|
|
490
|
+
Authorization: "Bearer " + accessToken,
|
|
491
|
+
DPoP: dpopProof
|
|
492
|
+
};
|
|
493
|
+
if (this._tenant) headers["NGSILD-Tenant"] = this._tenant;
|
|
494
|
+
const res = await fetch(bindUrl, { method: "POST", headers });
|
|
495
|
+
const serverNonce = res.headers.get("DPoP-Nonce");
|
|
496
|
+
if (serverNonce) this._dpopNonce = serverNonce;
|
|
497
|
+
if (res.status === 401 && serverNonce && serverNonce !== dpopNonce && retryCount < _AuthManager.MAX_DPOP_RETRIES) {
|
|
498
|
+
return this._doDpopBind(bindUrl, accessToken, serverNonce, retryCount + 1);
|
|
499
|
+
}
|
|
500
|
+
if (!res.ok) {
|
|
501
|
+
const e = await res.json().catch(() => ({}));
|
|
502
|
+
throw new AuthenticationError(
|
|
503
|
+
e.detail || e.description || "DPoP bind failed: " + res.status
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
return await res.json();
|
|
507
|
+
}
|
|
433
508
|
/**
|
|
434
509
|
* Set credentials externally (e.g. from a login API response).
|
|
435
510
|
* When tokenType is 'Bearer' and refreshToken is provided, all subsequent
|
|
@@ -501,12 +576,15 @@ var _AuthManager = class _AuthManager {
|
|
|
501
576
|
return this._tokenPromise;
|
|
502
577
|
}
|
|
503
578
|
async _refreshBearerToken() {
|
|
504
|
-
this._log("refreshing
|
|
579
|
+
this._log("refreshing token (type=%s)", this._tokenType);
|
|
505
580
|
try {
|
|
506
|
-
const
|
|
581
|
+
const isDPoP = this._tokenType === "DPoP";
|
|
582
|
+
const refreshUrl = this._baseUrl + "/auth/refresh";
|
|
583
|
+
const body = JSON.stringify({ refreshToken: this._refreshToken });
|
|
584
|
+
const res = isDPoP ? await this._doDpopRefresh(refreshUrl, body, this._dpopNonce, 0) : await fetch(refreshUrl, {
|
|
507
585
|
method: "POST",
|
|
508
586
|
headers: { "Content-Type": "application/json" },
|
|
509
|
-
body
|
|
587
|
+
body
|
|
510
588
|
});
|
|
511
589
|
if (!res.ok) {
|
|
512
590
|
this._refreshToken = null;
|
|
@@ -517,7 +595,7 @@ var _AuthManager = class _AuthManager {
|
|
|
517
595
|
const data = await res.json();
|
|
518
596
|
this._token = data.accessToken;
|
|
519
597
|
this._tokenExpiry = Date.now() + (data.expiresIn - 60) * 1e3;
|
|
520
|
-
this._tokenType = "Bearer";
|
|
598
|
+
this._tokenType = data.tokenType ?? (isDPoP ? "DPoP" : "Bearer");
|
|
521
599
|
this._refreshToken = data.refreshToken;
|
|
522
600
|
this._tokenPromise = null;
|
|
523
601
|
this.onTokenRefresh?.({
|
|
@@ -532,6 +610,44 @@ var _AuthManager = class _AuthManager {
|
|
|
532
610
|
throw err;
|
|
533
611
|
}
|
|
534
612
|
}
|
|
613
|
+
/**
|
|
614
|
+
* Perform a DPoP-bound `/auth/refresh` call with the standard RFC 9449 §8
|
|
615
|
+
* `use_dpop_nonce` retry handshake.
|
|
616
|
+
*
|
|
617
|
+
* @internal
|
|
618
|
+
*/
|
|
619
|
+
async _doDpopRefresh(refreshUrl, body, dpopNonce, retryCount) {
|
|
620
|
+
if (this._dpopReady) await this._dpopReady;
|
|
621
|
+
if (!this._dpopKeyPair) {
|
|
622
|
+
throw new AuthenticationError(
|
|
623
|
+
"DPoP-bound refresh requires a DPoP key pair, but key generation was unavailable."
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
const proof = await createDPoPProof(
|
|
627
|
+
this._dpopKeyPair,
|
|
628
|
+
"POST",
|
|
629
|
+
refreshUrl,
|
|
630
|
+
null,
|
|
631
|
+
dpopNonce
|
|
632
|
+
);
|
|
633
|
+
if (!proof) {
|
|
634
|
+
throw new AuthenticationError("DPoP proof generation failed");
|
|
635
|
+
}
|
|
636
|
+
const res = await fetch(refreshUrl, {
|
|
637
|
+
method: "POST",
|
|
638
|
+
headers: { "Content-Type": "application/json", DPoP: proof },
|
|
639
|
+
body
|
|
640
|
+
});
|
|
641
|
+
const serverNonce = res.headers.get("DPoP-Nonce");
|
|
642
|
+
if (serverNonce) this._dpopNonce = serverNonce;
|
|
643
|
+
if (res.status === 401 && serverNonce && serverNonce !== dpopNonce && retryCount < _AuthManager.MAX_DPOP_RETRIES) {
|
|
644
|
+
return this._doDpopRefresh(refreshUrl, body, serverNonce, retryCount + 1);
|
|
645
|
+
}
|
|
646
|
+
return res;
|
|
647
|
+
}
|
|
648
|
+
/* istanbul ignore next: PoW + DPoP token-exchange flow is exercised by E2E
|
|
649
|
+
against a live server. Unit-mocking each fetch + crypto step would not
|
|
650
|
+
verify the actual nonce/proof exchange. */
|
|
535
651
|
async _acquireTokenViaPow() {
|
|
536
652
|
try {
|
|
537
653
|
if (this._dpopReady) await this._dpopReady;
|
|
@@ -568,6 +684,8 @@ var _AuthManager = class _AuthManager {
|
|
|
568
684
|
throw err;
|
|
569
685
|
}
|
|
570
686
|
}
|
|
687
|
+
/* istanbul ignore next: DPoP nonce-retry handshake (RFC 9449 §8) — requires
|
|
688
|
+
a real server emitting `use_dpop_nonce` 400s. Exercised by E2E. */
|
|
571
689
|
async _doTokenExchange(tokenUrl, tokenBody, dpopNonce, retryCount = 0) {
|
|
572
690
|
const reqHeaders = {
|
|
573
691
|
"Content-Type": "application/json"
|
|
@@ -1065,9 +1183,26 @@ var GeonicDB = class extends EventEmitter {
|
|
|
1065
1183
|
}
|
|
1066
1184
|
}
|
|
1067
1185
|
// --- Authentication ---
|
|
1068
|
-
/**
|
|
1069
|
-
|
|
1070
|
-
|
|
1186
|
+
/**
|
|
1187
|
+
* Login with email and password (Bearer JWT).
|
|
1188
|
+
*
|
|
1189
|
+
* Pass `{ dpop: true }` to immediately upgrade the session to a DPoP
|
|
1190
|
+
* sender-constrained token (RFC 9449). Recommended for browser apps that
|
|
1191
|
+
* persist tokens in storage exposed to XSS (localStorage / sessionStorage),
|
|
1192
|
+
* since DPoP-bound tokens are unusable without this SDK instance's
|
|
1193
|
+
* non-extractable private key.
|
|
1194
|
+
*
|
|
1195
|
+
* @example
|
|
1196
|
+
* ```ts
|
|
1197
|
+
* // Bearer (default, backward compatible)
|
|
1198
|
+
* await db.login('user@example.com', 'password');
|
|
1199
|
+
*
|
|
1200
|
+
* // DPoP-bound (recommended for SPAs)
|
|
1201
|
+
* await db.login('user@example.com', 'password', { dpop: true });
|
|
1202
|
+
* ```
|
|
1203
|
+
*/
|
|
1204
|
+
async login(email, password, options) {
|
|
1205
|
+
return this._auth.login(email, password, options);
|
|
1071
1206
|
}
|
|
1072
1207
|
/**
|
|
1073
1208
|
* Whether the SDK is currently operating in anonymous mode (no token held).
|