oidc-caep-client 0.2.0 → 0.2.1-next.1
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/docs/spa-guide.md +12 -1
- package/package.json +1 -1
- package/src/caep/ssf-receiver.js +48 -8
- package/types/caep/ssf-receiver.d.ts +34 -3
package/docs/spa-guide.md
CHANGED
|
@@ -303,7 +303,7 @@ await receiver.addSubject(Subject.issSub(user.iss, user.sub)).catch(() => {}); /
|
|
|
303
303
|
`start()` either opens an existing stream or creates one:
|
|
304
304
|
|
|
305
305
|
- **Opening an existing stream:** pass its id as `streamId`. Keep the id, for example in `localStorage`, to reopen the same stream later.
|
|
306
|
-
- **Creating a stream:** this needs `ssf.manage`. If the transmitter already has a stream
|
|
306
|
+
- **Creating a stream:** this needs `ssf.manage`. If the transmitter refuses a new one because this person already has a stream (409) or as many as they may hold (403 — iya-sts's answer past `ssf.maxStreams`), an existing one is reused: the one whose `description` is this receiver's, or else the first.
|
|
307
307
|
|
|
308
308
|
`catchUp: true` handles the events queued while nobody was polling, before `start()` returns. Do this before the person uses the app, so a session revoked in the meantime is acted on first.
|
|
309
309
|
|
|
@@ -413,6 +413,17 @@ receiver.stop(); // stop polling; the stream keeps
|
|
|
413
413
|
await receiver.stop({ deleteStream: true }); // on sign-out, with ssf.manage: remove the stream
|
|
414
414
|
```
|
|
415
415
|
|
|
416
|
+
What happens to the stream in each case:
|
|
417
|
+
|
|
418
|
+
| When | What happens | What to do |
|
|
419
|
+
|---|---|---|
|
|
420
|
+
| The person signs out | Nothing, unless you delete it. | `stop({ deleteStream: true })` with `ssf.manage` (with `AccountManager`, from `onLeave`). With `ssf.read` only you can't delete it: stop polling. |
|
|
421
|
+
| The provider or a security event ends the session | The tokens are usually revoked already, so a delete is refused. | Stop polling; the transmitter times the stream out. |
|
|
422
|
+
| The tab is closed | Polling stops; the stream stays and keeps queueing events. | Keep the stream id (for example in `localStorage`) and pass it as `streamId` next time, with `catchUp: true`, so the events that arrived meanwhile are handled first. |
|
|
423
|
+
| The id is lost (another browser, cleared storage) | A new stream is created, and the old one is abandoned. | Nothing in the page: the transmitter removes an abandoned stream. iya-sts deletes a person's poll stream that nobody has polled for `ssf.personStreamInactivityTimeoutS` (a day by default). At the transmitter's limit, `start()` reuses an existing stream rather than failing. |
|
|
424
|
+
|
|
425
|
+
**`deleteOnPageHide: true`** deletes the stream, best effort, when the page is unloaded for good (`pagehide`, not when it enters the back/forward cache), with a `keepalive` request. It is off by default, and we suggest leaving it off: a stream kept between visits holds the events that arrive while no tab is open, which `catchUp` exists to deliver, and a browser may drop a request sent while a page unloads.
|
|
426
|
+
|
|
416
427
|
## 10. Sign out
|
|
417
428
|
|
|
418
429
|
```js
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oidc-caep-client",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1-next.1",
|
|
4
4
|
"description": "In-browser OpenID Connect public client (PKCE, PAR, DPoP, RFC 9207) with token caching/auto-refresh and a Shared Signals (SSF) / CAEP event receiver",
|
|
5
5
|
"homepage": "https://rcbj.github.io/oidc-caep-client/",
|
|
6
6
|
"repository": {
|
package/src/caep/ssf-receiver.js
CHANGED
|
@@ -105,6 +105,13 @@ const SSF_URL_MEMBERS = ['jwks_uri', 'configuration_endpoint', 'status_endpoint'
|
|
|
105
105
|
* Default 5. A SET that can't be checked for a reason that may pass (a network error, a signing
|
|
106
106
|
* key not yet in the JWKS) is never reported as an error: it is left for redelivery until it can.
|
|
107
107
|
* @property {boolean} [allowInsecureRequests] Allow non-TLS SSF URLs other than loopback. Default false.
|
|
108
|
+
* @property {boolean} [deleteOnPageHide] Delete the stream, best effort, when the page is unloaded for good
|
|
109
|
+
* (`pagehide` that is not entering the back/forward cache). Default false: a stream kept across
|
|
110
|
+
* visits holds the events that arrive while no tab is open, which `start({ catchUp: true })`
|
|
111
|
+
* delivers, and a browser may drop a request sent while a page unloads. Leave it off and let the
|
|
112
|
+
* transmitter time an abandoned stream out (iya-sts: `ssf.personStreamInactivityTimeoutS`).
|
|
113
|
+
* @property {{ addEventListener: Function, removeEventListener: Function }} [pageEvents] Where `pagehide`
|
|
114
|
+
* is listened for. Default `globalThis` (the window).
|
|
108
115
|
* @property {string[]} [trustedPollOrigins] Origins besides the transmitter's issuer and
|
|
109
116
|
* configuration_endpoint that the stream's poll endpoint_url may be at. The poll request carries
|
|
110
117
|
* the person's access token, so it is refused anywhere else.
|
|
@@ -153,6 +160,8 @@ export class SSFReceiver {
|
|
|
153
160
|
#pendingAcks = [];
|
|
154
161
|
#pendingErrs = {};
|
|
155
162
|
#attempts = new Map();
|
|
163
|
+
/** The `pagehide` listener `deleteOnPageHide` adds, while it is added. */
|
|
164
|
+
#onPageHide = undefined;
|
|
156
165
|
|
|
157
166
|
/** @param {SSFReceiverConfig} config */
|
|
158
167
|
constructor(config) {
|
|
@@ -326,8 +335,10 @@ export class SSFReceiver {
|
|
|
326
335
|
|
|
327
336
|
/**
|
|
328
337
|
* Discovers the transmitter, creates (or reuses) a stream, and starts polling when using poll delivery.
|
|
329
|
-
* If the transmitter
|
|
330
|
-
*
|
|
338
|
+
* If the transmitter refuses a new stream because this receiver already has one (HTTP 409) or has as many
|
|
339
|
+
* as it may hold (HTTP 403 — iya-sts's answer past `ssf.maxStreams`), an existing stream is reused and
|
|
340
|
+
* re-pointed at this receiver's delivery settings: the one whose `description` is this receiver's, or else
|
|
341
|
+
* the first.
|
|
331
342
|
*
|
|
332
343
|
* With `catchUp`, the SETs already queued on the stream are polled and dispatched before this resolves,
|
|
333
344
|
* e.g. so that a session revoked while the receiver was stopped is acted on before the session is used.
|
|
@@ -341,10 +352,10 @@ export class SSFReceiver {
|
|
|
341
352
|
try {
|
|
342
353
|
this.stream = await this.createStream();
|
|
343
354
|
} catch (err) {
|
|
344
|
-
if (err.status !== 409) throw err;
|
|
345
|
-
const existing = await this.listStreams();
|
|
355
|
+
if (err.status !== 409 && err.status !== 403) throw err;
|
|
356
|
+
const existing = await this.listStreams().catch(() => []);
|
|
346
357
|
if (!existing.length) throw err;
|
|
347
|
-
this.stream = existing[0];
|
|
358
|
+
this.stream = existing.find((s) => s?.description === this.#cfg.description) ?? existing[0];
|
|
348
359
|
this.stream = await this.updateStream({ delivery: this.#deliveryRequest(), events_requested: this.#cfg.eventsRequested });
|
|
349
360
|
}
|
|
350
361
|
} else {
|
|
@@ -352,14 +363,38 @@ export class SSFReceiver {
|
|
|
352
363
|
}
|
|
353
364
|
for (let more = catchUp; more;) ({ moreAvailable: more } = await this.poll());
|
|
354
365
|
this.#startPolling();
|
|
366
|
+
this.#watchPageHide();
|
|
355
367
|
return this.stream;
|
|
356
368
|
}
|
|
357
369
|
|
|
370
|
+
/** With `deleteOnPageHide`, listens for the page going away for good. */
|
|
371
|
+
#watchPageHide() {
|
|
372
|
+
const target = this.#cfg.pageEvents ?? globalThis;
|
|
373
|
+
if (!this.#cfg.deleteOnPageHide || this.#onPageHide || typeof target?.addEventListener !== 'function') return;
|
|
374
|
+
this.#onPageHide = (event) => {
|
|
375
|
+
// A page entering the back/forward cache may come back, polling where it stopped.
|
|
376
|
+
if (event?.persisted) return;
|
|
377
|
+
this.#polling = false;
|
|
378
|
+
clearTimeout(this.#pollTimer);
|
|
379
|
+
if (this.stream?.stream_id) {
|
|
380
|
+
this.deleteStream({ keepalive: true }).catch((err) => this.#emitError(err, { phase: 'stop' }));
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
target.addEventListener('pagehide', this.#onPageHide);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
#unwatchPageHide() {
|
|
387
|
+
if (!this.#onPageHide) return;
|
|
388
|
+
(this.#cfg.pageEvents ?? globalThis).removeEventListener?.('pagehide', this.#onPageHide);
|
|
389
|
+
this.#onPageHide = undefined;
|
|
390
|
+
}
|
|
391
|
+
|
|
358
392
|
/**
|
|
359
393
|
* Stops polling and optionally deletes the stream at the transmitter.
|
|
360
394
|
* @param {{ deleteStream?: boolean }} [opts]
|
|
361
395
|
*/
|
|
362
396
|
async stop({ deleteStream = false } = {}) {
|
|
397
|
+
this.#unwatchPageHide();
|
|
363
398
|
this.#polling = false;
|
|
364
399
|
clearTimeout(this.#pollTimer);
|
|
365
400
|
// A poll still running may finish handling SETs; their acknowledgements go in the flush below.
|
|
@@ -411,10 +446,14 @@ export class SSFReceiver {
|
|
|
411
446
|
return this.stream;
|
|
412
447
|
}
|
|
413
448
|
|
|
414
|
-
|
|
449
|
+
/**
|
|
450
|
+
* Deletes the stream at the transmitter.
|
|
451
|
+
* @param {{ keepalive?: boolean }} [opts] `keepalive`: let the request outlive the page (used on `pagehide`).
|
|
452
|
+
*/
|
|
453
|
+
async deleteStream({ keepalive = false } = {}) {
|
|
415
454
|
const url = new URL(this.#endpoint('configuration_endpoint'));
|
|
416
455
|
url.searchParams.set('stream_id', this.#streamId());
|
|
417
|
-
await this.#mgmt('DELETE', url);
|
|
456
|
+
await this.#mgmt('DELETE', url, undefined, { keepalive });
|
|
418
457
|
this.stream = undefined;
|
|
419
458
|
}
|
|
420
459
|
|
|
@@ -813,7 +852,7 @@ export class SSFReceiver {
|
|
|
813
852
|
return id;
|
|
814
853
|
}
|
|
815
854
|
|
|
816
|
-
async #mgmt(method, url, body) {
|
|
855
|
+
async #mgmt(method, url, body, { keepalive = false } = {}) {
|
|
817
856
|
const href = String(url);
|
|
818
857
|
const send = async (nonce) => {
|
|
819
858
|
const headers = { accept: 'application/json' };
|
|
@@ -829,6 +868,7 @@ export class SSFReceiver {
|
|
|
829
868
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
830
869
|
timeoutMs: this.#cfg.httpTimeoutMs,
|
|
831
870
|
fetch: this.#cfg.fetch,
|
|
871
|
+
...(keepalive ? { keepalive: true } : {}),
|
|
832
872
|
});
|
|
833
873
|
};
|
|
834
874
|
let res = await send();
|
|
@@ -185,6 +185,22 @@ export type SSFReceiverConfig = {
|
|
|
185
185
|
* Allow non-TLS SSF URLs other than loopback. Default false.
|
|
186
186
|
*/
|
|
187
187
|
allowInsecureRequests?: boolean;
|
|
188
|
+
/**
|
|
189
|
+
* Delete the stream, best effort, when the page is unloaded for good
|
|
190
|
+
* (`pagehide` that is not entering the back/forward cache). Default false: a stream kept across
|
|
191
|
+
* visits holds the events that arrive while no tab is open, which `start({ catchUp: true })`
|
|
192
|
+
* delivers, and a browser may drop a request sent while a page unloads. Leave it off and let the
|
|
193
|
+
* transmitter time an abandoned stream out (iya-sts: `ssf.personStreamInactivityTimeoutS`).
|
|
194
|
+
*/
|
|
195
|
+
deleteOnPageHide?: boolean;
|
|
196
|
+
/**
|
|
197
|
+
* Where `pagehide`
|
|
198
|
+
* is listened for. Default `globalThis` (the window).
|
|
199
|
+
*/
|
|
200
|
+
pageEvents?: {
|
|
201
|
+
addEventListener: Function;
|
|
202
|
+
removeEventListener: Function;
|
|
203
|
+
};
|
|
188
204
|
/**
|
|
189
205
|
* Origins besides the transmitter's issuer and
|
|
190
206
|
* configuration_endpoint that the stream's poll endpoint_url may be at. The poll request carries
|
|
@@ -234,6 +250,13 @@ export type SSFReceiverConfig = {
|
|
|
234
250
|
* Default 5. A SET that can't be checked for a reason that may pass (a network error, a signing
|
|
235
251
|
* key not yet in the JWKS) is never reported as an error: it is left for redelivery until it can.
|
|
236
252
|
* @property {boolean} [allowInsecureRequests] Allow non-TLS SSF URLs other than loopback. Default false.
|
|
253
|
+
* @property {boolean} [deleteOnPageHide] Delete the stream, best effort, when the page is unloaded for good
|
|
254
|
+
* (`pagehide` that is not entering the back/forward cache). Default false: a stream kept across
|
|
255
|
+
* visits holds the events that arrive while no tab is open, which `start({ catchUp: true })`
|
|
256
|
+
* delivers, and a browser may drop a request sent while a page unloads. Leave it off and let the
|
|
257
|
+
* transmitter time an abandoned stream out (iya-sts: `ssf.personStreamInactivityTimeoutS`).
|
|
258
|
+
* @property {{ addEventListener: Function, removeEventListener: Function }} [pageEvents] Where `pagehide`
|
|
259
|
+
* is listened for. Default `globalThis` (the window).
|
|
237
260
|
* @property {string[]} [trustedPollOrigins] Origins besides the transmitter's issuer and
|
|
238
261
|
* configuration_endpoint that the stream's poll endpoint_url may be at. The poll request carries
|
|
239
262
|
* the person's access token, so it is refused anywhere else.
|
|
@@ -359,8 +382,10 @@ export declare class SSFReceiver {
|
|
|
359
382
|
discover(): Promise<Record<string, any>>;
|
|
360
383
|
/**
|
|
361
384
|
* Discovers the transmitter, creates (or reuses) a stream, and starts polling when using poll delivery.
|
|
362
|
-
* If the transmitter
|
|
363
|
-
*
|
|
385
|
+
* If the transmitter refuses a new stream because this receiver already has one (HTTP 409) or has as many
|
|
386
|
+
* as it may hold (HTTP 403 — iya-sts's answer past `ssf.maxStreams`), an existing stream is reused and
|
|
387
|
+
* re-pointed at this receiver's delivery settings: the one whose `description` is this receiver's, or else
|
|
388
|
+
* the first.
|
|
364
389
|
*
|
|
365
390
|
* With `catchUp`, the SETs already queued on the stream are polled and dispatched before this resolves,
|
|
366
391
|
* e.g. so that a session revoked while the receiver was stopped is acted on before the session is used.
|
|
@@ -385,7 +410,13 @@ export declare class SSFReceiver {
|
|
|
385
410
|
updateStream(changes: any): Promise<Record<string, any>>;
|
|
386
411
|
/** PUT (replace) the stream configuration. */
|
|
387
412
|
replaceStream(config: any): Promise<Record<string, any>>;
|
|
388
|
-
|
|
413
|
+
/**
|
|
414
|
+
* Deletes the stream at the transmitter.
|
|
415
|
+
* @param {{ keepalive?: boolean }} [opts] `keepalive`: let the request outlive the page (used on `pagehide`).
|
|
416
|
+
*/
|
|
417
|
+
deleteStream({ keepalive }?: {
|
|
418
|
+
keepalive?: boolean;
|
|
419
|
+
}): Promise<void>;
|
|
389
420
|
/** @returns {Promise<{ stream_id: string, status: 'enabled'|'paused'|'disabled', reason?: string }>} */
|
|
390
421
|
getStatus(): Promise<{
|
|
391
422
|
stream_id: string;
|