@geolonia/geonicdb-sdk 0.10.1 → 0.11.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 +30 -44
- package/geonicdb.cjs +66 -54
- package/geonicdb.cjs.map +2 -2
- package/geonicdb.d.ts +22 -45
- package/geonicdb.iife.js +66 -54
- package/geonicdb.iife.js.map +2 -2
- package/geonicdb.mjs +66 -54
- package/geonicdb.mjs.map +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -96,58 +96,39 @@ await db.login('user@example.com', 'password');
|
|
|
96
96
|
// Token refresh is automatic via refresh token.
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
be replayed from another origin.
|
|
99
|
+
Sessions are **DPoP sender-constrained automatically** (RFC 9449). After
|
|
100
|
+
`POST /auth/login` succeeds, the SDK transparently exchanges the Bearer
|
|
101
|
+
session for a DPoP-bound one, generating a non-extractable ECDSA P-256 key
|
|
102
|
+
pair via Web Crypto and binding the access / refresh tokens to it. A token
|
|
103
|
+
leaked from storage (e.g. localStorage XSS) therefore cannot be replayed
|
|
104
|
+
from another origin. There is no DPoP option to set — you never deal with
|
|
105
|
+
DPoP directly.
|
|
107
106
|
|
|
108
107
|
```typescript
|
|
109
|
-
const result = await db.login('user@example.com', 'password'
|
|
110
|
-
// result.tokenType === 'DPoP'
|
|
108
|
+
const result = await db.login('user@example.com', 'password');
|
|
109
|
+
// result.tokenType === 'DPoP' when Web Crypto is available.
|
|
111
110
|
// Subsequent requests use `Authorization: DPoP <token>` + per-request proof.
|
|
112
|
-
//
|
|
113
|
-
localStorage.setItem('accessToken', result.accessToken);
|
|
114
|
-
localStorage.setItem('refreshToken', result.refreshToken);
|
|
111
|
+
// The bind step and the RFC 9449 §8 `use_dpop_nonce` handshake are internal.
|
|
115
112
|
```
|
|
116
113
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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.
|
|
114
|
+
On runtimes without Web Crypto (`subtle.generateKey`, e.g. a non-HTTPS
|
|
115
|
+
context), the session cannot be sender-constrained: the SDK silently keeps
|
|
116
|
+
the plain Bearer session and **login still succeeds** (`tokenType === 'Bearer'`).
|
|
126
117
|
|
|
127
|
-
####
|
|
118
|
+
#### Keeping the user signed in across page reloads
|
|
128
119
|
|
|
129
|
-
By default
|
|
130
|
-
|
|
131
|
-
|
|
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.
|
|
120
|
+
By default a session is held in memory only — reloading the page loses it
|
|
121
|
+
and forces the user to log in again. Pass `{ rememberSession: true }` to keep
|
|
122
|
+
the user signed in:
|
|
139
123
|
|
|
140
124
|
```typescript
|
|
141
125
|
const db = new GeonicDB({ baseUrl, tenant });
|
|
142
|
-
await db.login('user@example.com', 'password', {
|
|
143
|
-
dpop: true,
|
|
144
|
-
dpopPersist: true,
|
|
145
|
-
});
|
|
126
|
+
await db.login('user@example.com', 'password', { rememberSession: true });
|
|
146
127
|
|
|
147
128
|
// On the next page load:
|
|
148
129
|
const db2 = new GeonicDB({ baseUrl, tenant });
|
|
149
130
|
if (await db2.restoreSession()) {
|
|
150
|
-
// Session was rehydrated
|
|
131
|
+
// Session was rehydrated — boot the app.
|
|
151
132
|
} else {
|
|
152
133
|
// No persisted session; show the login form.
|
|
153
134
|
}
|
|
@@ -156,14 +137,19 @@ if (await db2.restoreSession()) {
|
|
|
156
137
|
await db2.logout();
|
|
157
138
|
```
|
|
158
139
|
|
|
140
|
+
Under the hood, the session is persisted to **IndexedDB**. The structured
|
|
141
|
+
clone algorithm stores the DPoP `CryptoKey` as-is, preserving
|
|
142
|
+
`extractable: false`: the key survives reloads, but JavaScript on the page
|
|
143
|
+
still cannot read it back as JWK / PEM. Tokens leaked from any other storage
|
|
144
|
+
remain unusable without this origin's IndexedDB.
|
|
145
|
+
|
|
159
146
|
Notes:
|
|
160
147
|
|
|
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
148
|
- The IndexedDB store is keyed by `tenant`, so multi-tenant apps in the
|
|
165
149
|
same origin keep separate sessions.
|
|
166
|
-
- Persistence is best-effort: a failed IDB write
|
|
150
|
+
- Persistence is best-effort: a failed IDB write (or no Web Crypto, so the
|
|
151
|
+
session stays Bearer) does not break login — it just means no rehydrate.
|
|
152
|
+
- Opt-in by design: shared-device flows should leave `rememberSession` off.
|
|
167
153
|
- iOS Safari may evict IndexedDB under ITP after 7 days of no user
|
|
168
154
|
interaction with the origin.
|
|
169
155
|
|
|
@@ -403,8 +389,8 @@ handle.stop();
|
|
|
403
389
|
|
|
404
390
|
| Method | Parameters | Returns | Description |
|
|
405
391
|
|--------|-----------|---------|-------------|
|
|
406
|
-
| `login(email, password, options?)` | `string, string, LoginOptions?` | `Promise<LoginResponse>` | Login with email and password
|
|
407
|
-
| `restoreSession()` | — | `Promise<boolean>` | Rehydrate a
|
|
392
|
+
| `login(email, password, options?)` | `string, string, LoginOptions?` | `Promise<LoginResponse>` | Login with email and password. Upgrades the SDK out of anonymous mode. The session is DPoP sender-constrained (RFC 9449) **automatically and transparently** — no option to set; silently falls back to Bearer when Web Crypto is unavailable. Pass `{ rememberSession: true }` to keep the user signed in across page reloads (persisted to IndexedDB) |
|
|
393
|
+
| `restoreSession()` | — | `Promise<boolean>` | Rehydrate a session previously persisted via `login({ rememberSession: true })`. Returns `true` if a session was found and loaded, `false` otherwise. Call at app startup before showing the login form |
|
|
408
394
|
| `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 |
|
|
409
395
|
| `logout()` | — | `void` | Clear all credentials and disconnect WebSocket. Reverts to anonymous mode if `anonymous: true` was set at construction |
|
|
410
396
|
| `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
|
@@ -477,11 +477,12 @@ var _AuthManager = class _AuthManager {
|
|
|
477
477
|
__publicField(this, "_dpopReady", null);
|
|
478
478
|
__publicField(this, "_dpopNonce", null);
|
|
479
479
|
/**
|
|
480
|
-
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
480
|
+
* When true, every DPoP token mutation (login bind, refresh) is mirrored to
|
|
481
|
+
* IndexedDB so the session survives a page reload (#1230). Toggled by
|
|
482
|
+
* `login(email, password, { rememberSession: true })` and
|
|
483
|
+
* `restoreSession()` (#1244).
|
|
483
484
|
*/
|
|
484
|
-
__publicField(this, "
|
|
485
|
+
__publicField(this, "_rememberSession", false);
|
|
485
486
|
/** Email captured at login, persisted for UI display. */
|
|
486
487
|
__publicField(this, "_email", null);
|
|
487
488
|
__publicField(this, "_tokenPromise", null);
|
|
@@ -546,22 +547,22 @@ var _AuthManager = class _AuthManager {
|
|
|
546
547
|
this._emitCacheEvent?.(name, payload);
|
|
547
548
|
}
|
|
548
549
|
/**
|
|
549
|
-
* Login with email and password
|
|
550
|
+
* Login with email and password.
|
|
550
551
|
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
*
|
|
552
|
+
* #1244: DPoP (RFC 9449) is transparent and always-on — there is no `dpop`
|
|
553
|
+
* option to set. After the Bearer login succeeds, the SDK automatically
|
|
554
|
+
* exchanges it for a DPoP sender-constrained session so a token leaked from
|
|
555
|
+
* storage (e.g. localStorage XSS) cannot be replayed from another origin.
|
|
556
|
+
*
|
|
557
|
+
* When the runtime lacks Web Crypto (`crypto.subtle.generateKey`, e.g. a
|
|
558
|
+
* non-HTTPS context), DPoP cannot be used and the SDK silently keeps the
|
|
559
|
+
* Bearer session — login still succeeds. Callers never deal with DPoP
|
|
560
|
+
* directly; pass `{ rememberSession: true }` only to keep the user signed in
|
|
561
|
+
* across reloads.
|
|
556
562
|
*/
|
|
557
563
|
async login(email, password, options) {
|
|
558
564
|
this._log("login", email);
|
|
559
|
-
const
|
|
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
|
-
}
|
|
565
|
+
const wantsRemember = options?.rememberSession === true;
|
|
565
566
|
const headers = {
|
|
566
567
|
"Content-Type": "application/json"
|
|
567
568
|
};
|
|
@@ -584,14 +585,30 @@ var _AuthManager = class _AuthManager {
|
|
|
584
585
|
this._tokenType = "Bearer";
|
|
585
586
|
this._refreshToken = data.refreshToken;
|
|
586
587
|
this._email = email;
|
|
587
|
-
this.
|
|
588
|
+
this._rememberSession = wantsRemember;
|
|
588
589
|
this._invalidateAuthScopedState();
|
|
589
|
-
if (
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
590
|
+
if (!wantsRemember && this._tenant) {
|
|
591
|
+
void clearDPoPSession(this._tenant);
|
|
592
|
+
}
|
|
593
|
+
if (this._dpopReady) await this._dpopReady;
|
|
594
|
+
if (this._dpopKeyPair) {
|
|
595
|
+
try {
|
|
596
|
+
const bound = await this._bindDpopSession();
|
|
597
|
+
if (this._rememberSession) {
|
|
598
|
+
await this._persistDpopSession();
|
|
599
|
+
}
|
|
600
|
+
return bound;
|
|
601
|
+
} catch (err) {
|
|
602
|
+
this._token = null;
|
|
603
|
+
this._tokenExpiry = 0;
|
|
604
|
+
this._tokenType = "Bearer";
|
|
605
|
+
this._refreshToken = null;
|
|
606
|
+
this._tokenPromise = null;
|
|
607
|
+
this._rememberSession = false;
|
|
608
|
+
this._email = null;
|
|
609
|
+
this._invalidateAuthScopedState();
|
|
610
|
+
throw err;
|
|
593
611
|
}
|
|
594
|
-
return bound;
|
|
595
612
|
}
|
|
596
613
|
return data;
|
|
597
614
|
}
|
|
@@ -685,18 +702,19 @@ var _AuthManager = class _AuthManager {
|
|
|
685
702
|
this._refreshToken = null;
|
|
686
703
|
this._tokenPromise = null;
|
|
687
704
|
this._email = null;
|
|
688
|
-
if (this.
|
|
705
|
+
if (this._tenant) {
|
|
689
706
|
void clearDPoPSession(this._tenant);
|
|
690
707
|
}
|
|
691
|
-
this.
|
|
708
|
+
this._rememberSession = false;
|
|
692
709
|
this._anonymous = this._initialAnonymous;
|
|
693
710
|
this._invalidateAuthScopedState();
|
|
694
711
|
}
|
|
695
712
|
/**
|
|
696
|
-
* Restore a
|
|
697
|
-
*
|
|
698
|
-
*
|
|
699
|
-
*
|
|
713
|
+
* Restore a session previously persisted via
|
|
714
|
+
* `login(email, password, { rememberSession: true })`. Returns true if a
|
|
715
|
+
* usable session was found and loaded into the
|
|
716
|
+
* SDK, false otherwise (no entry, IndexedDB unavailable, or the entry was
|
|
717
|
+
* for a different tenant).
|
|
700
718
|
*
|
|
701
719
|
* After a successful restore the SDK behaves as if `login()` had just
|
|
702
720
|
* succeeded: tokens are set, the DPoP key pair is rehydrated as a
|
|
@@ -724,19 +742,19 @@ var _AuthManager = class _AuthManager {
|
|
|
724
742
|
this._dpopKeyPair = persisted.keyPair;
|
|
725
743
|
this._dpopReady = Promise.resolve();
|
|
726
744
|
this._dpopNonce = null;
|
|
727
|
-
this.
|
|
745
|
+
this._rememberSession = true;
|
|
728
746
|
this._email = persisted.email ?? null;
|
|
729
747
|
this._tokenPromise = null;
|
|
730
748
|
this._invalidateAuthScopedState();
|
|
731
749
|
return true;
|
|
732
750
|
}
|
|
733
751
|
/**
|
|
734
|
-
* Whether this AuthManager currently
|
|
735
|
-
* Exposed for the `GeonicDB` wrapper
|
|
736
|
-
* consumers should read `
|
|
752
|
+
* Whether this AuthManager currently keeps the session persisted across
|
|
753
|
+
* reloads (`rememberSession`). Exposed for the `GeonicDB` wrapper; internal
|
|
754
|
+
* consumers should read `_rememberSession` directly.
|
|
737
755
|
*/
|
|
738
|
-
|
|
739
|
-
return this.
|
|
756
|
+
isSessionRemembered() {
|
|
757
|
+
return this._rememberSession;
|
|
740
758
|
}
|
|
741
759
|
/**
|
|
742
760
|
* Persist the current DPoP session to IndexedDB. Best-effort: a failed
|
|
@@ -746,7 +764,7 @@ var _AuthManager = class _AuthManager {
|
|
|
746
764
|
* @internal
|
|
747
765
|
*/
|
|
748
766
|
async _persistDpopSession() {
|
|
749
|
-
if (!this.
|
|
767
|
+
if (!this._rememberSession || !this._tenant || !this._dpopKeyPair || !this._token || !this._refreshToken) {
|
|
750
768
|
return;
|
|
751
769
|
}
|
|
752
770
|
const ok = await saveDPoPSession({
|
|
@@ -829,7 +847,7 @@ var _AuthManager = class _AuthManager {
|
|
|
829
847
|
expiresIn: data.expiresIn,
|
|
830
848
|
refreshToken: this._refreshToken
|
|
831
849
|
});
|
|
832
|
-
if (this.
|
|
850
|
+
if (this._rememberSession) void this._persistDpopSession();
|
|
833
851
|
return this._token;
|
|
834
852
|
} catch (err) {
|
|
835
853
|
this._tokenPromise = null;
|
|
@@ -1410,35 +1428,29 @@ var GeonicDB = class extends EventEmitter {
|
|
|
1410
1428
|
}
|
|
1411
1429
|
// --- Authentication ---
|
|
1412
1430
|
/**
|
|
1413
|
-
* Login with email and password
|
|
1431
|
+
* Login with email and password.
|
|
1414
1432
|
*
|
|
1415
|
-
*
|
|
1416
|
-
*
|
|
1417
|
-
*
|
|
1418
|
-
*
|
|
1419
|
-
*
|
|
1433
|
+
* The session is sender-constrained with DPoP (RFC 9449) automatically and
|
|
1434
|
+
* transparently — there is no DPoP option to set. A token leaked from
|
|
1435
|
+
* storage (e.g. localStorage XSS) is therefore unusable from another origin.
|
|
1436
|
+
* On runtimes without Web Crypto, the SDK silently keeps a plain Bearer
|
|
1437
|
+
* session and login still succeeds.
|
|
1420
1438
|
*
|
|
1421
1439
|
* @example
|
|
1422
1440
|
* ```ts
|
|
1423
|
-
* //
|
|
1441
|
+
* // Standard login — DPoP applied transparently when supported.
|
|
1424
1442
|
* await db.login('user@example.com', 'password');
|
|
1425
1443
|
*
|
|
1426
|
-
* //
|
|
1427
|
-
* await db.login('user@example.com', 'password', {
|
|
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
|
-
* });
|
|
1444
|
+
* // Keep the user signed in across page reloads (browser SPA UX).
|
|
1445
|
+
* await db.login('user@example.com', 'password', { rememberSession: true });
|
|
1434
1446
|
* ```
|
|
1435
1447
|
*/
|
|
1436
1448
|
async login(email, password, options) {
|
|
1437
1449
|
return this._auth.login(email, password, options);
|
|
1438
1450
|
}
|
|
1439
1451
|
/**
|
|
1440
|
-
* Rehydrate a
|
|
1441
|
-
* `login(
|
|
1452
|
+
* Rehydrate a session previously persisted via
|
|
1453
|
+
* `login(email, password, { rememberSession: true })`.
|
|
1442
1454
|
*
|
|
1443
1455
|
* Returns true if a session for this tenant was found and loaded; false
|
|
1444
1456
|
* if there was no entry, IndexedDB is unavailable, or the persisted
|
|
@@ -1449,7 +1461,7 @@ var GeonicDB = class extends EventEmitter {
|
|
|
1449
1461
|
* ```ts
|
|
1450
1462
|
* const db = new GeonicDB({ baseUrl, tenant });
|
|
1451
1463
|
* if (await db.restoreSession()) {
|
|
1452
|
-
* //
|
|
1464
|
+
* // Session (tokens + non-extractable key) recovered — boot the app.
|
|
1453
1465
|
* } else {
|
|
1454
1466
|
* // No session; show the login form.
|
|
1455
1467
|
* }
|