pryv 3.12.1 → 3.14.2

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 CHANGED
@@ -603,10 +603,11 @@ The [authentication process](https://api.pryv.com/reference/#authenticate-your-a
603
603
  2. `INITIALIZED`: visuals assets are loaded, or when [polling](https://api.pryv.com/reference/#poll-request) concludes with **Result: Refused**
604
604
  3. `NEED_SIGNIN`: from the response of the [auth request](https://api.pryv.com/reference/#auth-request) through [polling](https://api.pryv.com/reference/#poll-request)
605
605
  4. `AUTHORIZED`: When [polling](https://api.pryv.com/reference/#poll-request) concludes with **Result: Accepted**
606
- 5. `SIGNOUT`: when the user triggers a deletion of the client-side authorization credentials, usually by clicking the button after being signed in
607
- 6. `ERROR`: see message for more information
606
+ 5. `SIGNOUT`: with the account menu, when the user confirms "Log out", just before the client-side authorization credentials are deleted. With `menu: false`, on the click itself, before the "Log out?" question: if the user cancels, the controller re-initializes from the stored credentials (`LOADING` then `AUTHORIZED`)
607
+ 6. `SWITCHING`: an account switch started (`{ from, to }`, `to` is `null` when the account is chosen in the sign-in popup); followed by `AUTHORIZED` for the new account, or for the previous one when the switch sign-in is refused. Listeners that ignore it see the usual `NEED_SIGNIN` then `AUTHORIZED` sequence
608
+ 7. `ERROR`: see message for more information
608
609
 
609
- You will need to provide a function to react depending on the state. The states `NEED_SIGNIN` and `AUTHORIZED` carry the same properties as the [auth process polling responses](https://api.pryv.com/reference/#poll-request). `LOADING`, `INITIALIZED` and `SIGNOUT` only have `status`. The `ERROR` state carries a `message` property.
610
+ You will need to provide a function to react depending on the state. The states `NEED_SIGNIN` and `AUTHORIZED` carry the same properties as the [auth process polling responses](https://api.pryv.com/reference/#poll-request). `LOADING`, `INITIALIZED` and `SIGNOUT` only have `status`. The `ERROR` state carries a `message` property, and an `error` (with an `id` such as `'unexpected-auth-return'` when available); a click on the button in `ERROR` starts over.
610
611
 
611
612
  ```js
612
613
  async onStateChange (state) {
@@ -634,11 +635,8 @@ async onStateChange (state) {
634
635
  });
635
636
  break;
636
637
  case AuthStates.SIGNOUT:
637
- const message = this.messages.SIGNOUT_CONFIRM ? this.messages.SIGNOUT_CONFIRM : 'Logout ?';
638
- if (confirm(message)) {
639
- this.deleteAuthorizationData();
640
- this.auth.init();
641
- }
638
+ // Emitted by auth.signOut() after the user confirmed in showMenu()
639
+ // (below); the controller clears the credentials itself.
642
640
  break;
643
641
  case AuthStates.ERROR:
644
642
  this.text = getErrorMessage(this, state.message);
@@ -661,12 +659,22 @@ The button actions should be handled by the [AuthController](src/Auth/AuthContro
661
659
  onClick () {
662
660
  this.auth.handleClick();
663
661
  }
662
+
663
+ // Optional: called on a click once signed in. Ask in your own dialog (never
664
+ // window.confirm), then log out through the controller. Return false to get
665
+ // the SIGNOUT state on the click instead.
666
+ showMenu () {
667
+ myDialog.open({ onLogout: () => this.auth.signOut() });
668
+ return true;
669
+ }
664
670
  ```
665
671
 
666
672
  ```js
667
673
  // AuthController.js
668
674
  async handleClick () {
669
675
  if (isAuthorized.call(this)) {
676
+ // a button implementing showMenu() opens its menu (logout through auth.signOut())
677
+ if (typeof this.loginButton?.showMenu === 'function' && this.loginButton.showMenu() !== false) return;
670
678
  this.state = { status: AuthStates.SIGNOUT };
671
679
  } else if (isInitialized.call(this)) {
672
680
  this.startAuthRequest();
@@ -679,6 +687,30 @@ async handleClick () {
679
687
  }
680
688
  ```
681
689
 
690
+ ##### Account menu
691
+
692
+ Clicking the default button once signed in opens a small account menu: the signed-in username, the service and the app id, **Manage my account** (opens the platform's account app in a new tab) and **Log out**. `SIGNOUT` is emitted once, when "Log out" is chosen.
693
+
694
+ - `authSettings.menu: false` restores the previous flow: `SIGNOUT` on click, then a plain "Log out?" confirmation (a built-in dialog, no longer `window.confirm()`).
695
+ - `authSettings.menu: { hide: ['account', 'info'] }` (or `{ account: false }`) hides entries: `'logout'`, `'account'`, `'switch'`, `'info'`.
696
+ - `authSettings.accountUrl` sets the account app root. Otherwise it is the service's `account` (`service/info`), then the auth page URL of the last sign-in without its trailing `/auth`; when none is known, "Manage my account" is not shown.
697
+ - The menu uses the `.pryv-menu*` CSS classes, which a service's button stylesheet can override; its texts come from the button messages (`MENU_TITLE`, `LOGOUT`, `MANAGE_ACCOUNT`, `APP`, `CLOSE`).
698
+
699
+ The controller exposes the same actions: `auth.signOut()`, `auth.openAccountApp()` and `auth.accountUrl()`. A custom button may implement `showMenu()` to show its own menu (return `false` to fall back to `SIGNOUT`).
700
+
701
+ ##### Several accounts and account switching
702
+
703
+ The button remembers the accounts signed in on the app (at most `authSettings.maxProfiles`, default 5; the least recently used is forgotten first). The default button keeps the active account in its usual cookie (`pryv-libjs-<appId>`, `apiEndpoint` + `username`, removed when no account is active) and the remembered accounts in a second one (`pryv-libjs-<appId>-profiles`), so a version without account switching never reads the list as a signed-in account. `getAuthorizationData()` returns both merged (`profiles` next to the active account); a custom button stores that object as it is.
704
+
705
+ - The menu lists them under "Use this app for". Choosing one activates it without a sign-in when its access is still valid (checked with `access-info`); an access that was revoked (for example by the end of an account delegation) is marked "no longer available" and asked for again through the sign-in popup.
706
+ - On platforms with account delegation (`features.delegation` in `service/info`), "Another account..." runs the auth request again with `actAs: 'allow'`: after signing in, the popup asks whom the access is for (the user's own account or an account they control). An access granted for a controlled account is shown as `kim (via parent)`, and the menu offers "Switch back to parent". The app receives an access on the controlled account, never the delegate's own credentials; `connection.accessInfo().delegation` tells it so authoritatively.
707
+ - "Log out" logs out of the active account and keeps the others (no account is signed in afterwards); "Log out of all accounts", shown when several are remembered, forgets them all.
708
+ - `authSettings.authRequest.actAs`: `'allow'` (the server default: the popup may offer the accounts the user controls), `'deny'` (never), or a username to preselect. An app that supplies its own fixed access `token` and lets users switch accounts ends up with the same token value on several accounts; use `actAs: 'deny'` to avoid it.
709
+
710
+ A switch that needs no sign-in, and the return to the previous account after a switch that did not complete, end in an `AUTHORIZED` state without a `key`: like the sign-in from stored credentials on page load, it carries the stored `username` and `apiEndpoint`.
711
+
712
+ Controller API: `auth.switchTo(username)` (`null`: the user's own account), `auth.addAccount()`, `auth.profiles()` (`[{ username, actingAs?, active, available }]`), `auth.currentProfile()` and `auth.signOut({ all: true })`.
713
+
682
714
  ##### Custom button usage
683
715
 
684
716
  You must then provide this class as follows:
@@ -700,6 +732,8 @@ For a more advanced scenario, you can check the default button implementation in
700
732
 
701
733
  There is a possibility that you would like to register the user in another page. You can find an example [here](https://github.com/pryv/lib-js/blob/master/examples/auth-with-redirection.html), and try it running [there](https://api.pryv.com/lib-js/examples/auth-with-redirection.html). Again, to run these examples locally, see below.
702
734
 
735
+ Set `authRequest.returnURL`: `'self#'` always redirects and comes back to the current page, `'auto#'` (the default) opens a popup on desktop and redirects on a phone or tablet, and a URL always redirects and comes back to that URL. Before leaving, the button keeps the auth request it started in `sessionStorage`; on the way back it finishes only that request, in the same tab and on the same origin, and any other return ends in `ERROR` with `error.id` `'unexpected-auth-return'` (or keeps the account the page is already signed in to). If your app starts the auth request itself (`Service.startAccessRequest`) and comes back to a page with the button, finish the sign-in yourself: poll the `poll` URL the request returned with `Service.pollAccessRequest(pollUrl)`. `Service.connectFromKey(key)` reaches the right core only within the page load (or Node process) that started the request, or after the button finished a redirect sign-in it started; after your own redirect (the page reloads) on a multi-core platform, pass the poll URL instead, since a key alone may reach a core that does not hold the request.
736
+
703
737
 
704
738
  ### Running examples locally
705
739
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pryv",
3
- "version": "3.12.1",
3
+ "version": "3.14.2",
4
4
  "description": "Pryv JavaScript library",
5
5
  "keywords": [
6
6
  "Pryv",
@@ -5,6 +5,10 @@
5
5
  const utils = require('../utils');
6
6
  const AuthStates = require('./AuthStates');
7
7
  const Messages = require('./LoginMessages');
8
+ const ProfileStore = require('./ProfileStore');
9
+ const handoff = require('../lib/handoff');
10
+ const pollUrls = require('../lib/pollUrls');
11
+ const PryvError = require('../lib/PryvError');
8
12
 
9
13
  /**
10
14
  * Controller for authentication flow
@@ -39,6 +43,11 @@ class AuthController {
39
43
  this.messages = Messages(this.languageCode);
40
44
 
41
45
  this.loginButton = loginButton;
46
+ // Incremented by every new auth request, sign-out and re-initialization:
47
+ // an older request's poll then no longer changes the state.
48
+ this._authFlowId = 0;
49
+ /** @type {Object|null} signed-in state to return to if a switch does not complete */
50
+ this._switchPrevious = null;
42
51
 
43
52
  function validateSettings (settings) {
44
53
  if (!settings) { throw new Error('settings cannot be null'); }
@@ -55,6 +64,18 @@ class AuthController {
55
64
  if (!settings.authRequest.requestedPermissions) {
56
65
  throw new Error('Missing settings.authRequest.requestedPermissions');
57
66
  }
67
+
68
+ // Delivery mode. Default to the one-time shared-secret hand-off so the
69
+ // token is never returned in the poll: a new core echoes
70
+ // `credentialHandoff` and delivers a `handoff` key (redeemed once here),
71
+ // an older core drops the field and falls back to inline delivery.
72
+ // Opt out with `authRequest.credentialHandoff = 'inline'`, which sends no
73
+ // field at all, so the request is byte-identical to the legacy one.
74
+ if (settings.authRequest.credentialHandoff === 'inline') {
75
+ delete settings.authRequest.credentialHandoff;
76
+ } else if (settings.authRequest.credentialHandoff == null) {
77
+ settings.authRequest.credentialHandoff = 'shared-secret';
78
+ }
58
79
  }
59
80
  }
60
81
 
@@ -63,6 +84,7 @@ class AuthController {
63
84
  * @returns {Promise<Service>} Promise resolving to the Service instance
64
85
  */
65
86
  async init () {
87
+ cancelAuthFlow(this);
66
88
  this.serviceInfo = this.service.infoSync();
67
89
  this.state = { status: AuthStates.LOADING };
68
90
  this.assets = await loadAssets(this);
@@ -99,6 +121,8 @@ class AuthController {
99
121
  * Stops poll for auth request
100
122
  */
101
123
  stopAuthRequest (msg) {
124
+ // its poll must not change the state any more
125
+ cancelAuthFlow(this);
102
126
  this.state = { status: AuthStates.ERROR, message: msg };
103
127
  }
104
128
 
@@ -108,9 +132,22 @@ class AuthController {
108
132
  */
109
133
  async handleClick () {
110
134
  if (isAuthorized.call(this)) {
135
+ // A button with an account menu opens it (logout happens from there,
136
+ // through `signOut()`); otherwise the legacy SIGNOUT state lets the
137
+ // button confirm the logout itself.
138
+ const loginButton = this.loginButton;
139
+ if (loginButton != null && typeof loginButton.showMenu === 'function' &&
140
+ loginButton.showMenu() !== false) {
141
+ return;
142
+ }
111
143
  this.state = { status: AuthStates.SIGNOUT };
112
144
  } else if (isInitialized.call(this)) {
113
145
  this.startAuthRequest();
146
+ } else if (this.state.status === AuthStates.SWITCHING) {
147
+ // a switch is running; its outcome arrives as a state change
148
+ } else if (this.state.status === AuthStates.ERROR) {
149
+ // start over (stored sign-in or the sign-in button) rather than stay inert
150
+ await this.init();
114
151
  } else if (isNeedSignIn.call(this)) {
115
152
  // reopen popup (HACK for now: set to private property to avoid self-assignment)
116
153
  this.state = this._state;
@@ -129,12 +166,198 @@ class AuthController {
129
166
  }
130
167
  }
131
168
 
169
+ /**
170
+ * Log out: emit SIGNOUT once, forget the active account and return to
171
+ * INITIALIZED. The other remembered accounts are kept (`all` forgets them
172
+ * too). This is the confirmed logout (no further confirmation).
173
+ * @param {Object} [options]
174
+ * @param {boolean} [options.all] - forget every remembered account
175
+ * @returns {Promise<void>}
176
+ */
177
+ async signOut (options) {
178
+ const all = options?.all === true;
179
+ cancelAuthFlow(this);
180
+ const store = this._readProfiles();
181
+ // during a switch, the account being left
182
+ const current = this.state?.username ?? this.state?.from;
183
+ this._signingOut = true;
184
+ try {
185
+ this.state = { status: AuthStates.SIGNOUT };
186
+ } finally {
187
+ this._signingOut = false;
188
+ }
189
+ // Drop any cached hand-off credential for this flow's key. Guard the
190
+ // key: on a cookie-autologin session there is no `_authFlowKey`, and
191
+ // clearing with `undefined` would wipe an unrelated concurrent flow.
192
+ if (this._authFlowKey != null) handoff.cacheClear(this._authFlowKey);
193
+ const others = current == null ? store.profiles : ProfileStore.remove(store, current).profiles;
194
+ const loginButton = this.loginButton;
195
+ if (!all && others.length > 0 && loginButton != null && typeof loginButton.saveAuthorizationData === 'function') {
196
+ loginButton.saveAuthorizationData(ProfileStore.write(Object.assign({}, store, { active: null, profiles: others })));
197
+ } else if (loginButton != null && typeof loginButton.deleteAuthorizationData === 'function') {
198
+ await loginButton.deleteAuthorizationData();
199
+ }
200
+ await this.init();
201
+ }
202
+
203
+ /**
204
+ * The accounts remembered for this app, most recently used first.
205
+ * @returns {Array<{username: string, actingAs?: {username: string, delegate: string}, active: boolean, available: boolean}>}
206
+ */
207
+ profiles () {
208
+ const current = this.currentProfile();
209
+ return this._readProfiles().profiles.map((p) => {
210
+ const out = { username: p.username, active: current != null && current.username === p.username, available: p.unavailable !== true };
211
+ if (p.actingAs != null) out.actingAs = p.actingAs;
212
+ return out;
213
+ });
214
+ }
215
+
216
+ /**
217
+ * The signed-in account, or null.
218
+ * @returns {{username: string, actingAs?: {username: string, delegate: string}}|null}
219
+ */
220
+ currentProfile () {
221
+ if (this.state?.status !== AuthStates.AUTHORIZED || this.state.profile == null) return null;
222
+ const out = { username: this.state.profile.username };
223
+ if (this.state.profile.actingAs != null) out.actingAs = this.state.profile.actingAs;
224
+ return out;
225
+ }
226
+
227
+ /**
228
+ * Switch to another account. `username` null means the signed-in person's
229
+ * own account (switch back). A remembered account whose access is still
230
+ * valid is activated without a sign-in; otherwise the auth request runs
231
+ * again, asking for that account (`actAs`). Emits SWITCHING first; ends in
232
+ * AUTHORIZED, or back on the previous account when the sign-in is refused.
233
+ * @param {string|null} username
234
+ * @returns {Promise<void>}
235
+ */
236
+ async switchTo (username) {
237
+ const store = this._readProfiles();
238
+ const current = this.currentProfile();
239
+ let target;
240
+ if (username == null) {
241
+ if (current != null && current.actingAs == null) return;
242
+ const delegate = current?.actingAs?.delegate;
243
+ // Acting for an account: back to the delegate's own account only, never
244
+ // to another remembered one. Not signed in: the most recent own account.
245
+ target = delegate != null
246
+ ? store.profiles.find((p) => p.username === delegate && p.actingAs == null)
247
+ : store.profiles.find((p) => p.actingAs == null);
248
+ } else {
249
+ if (current != null && current.username === username) return;
250
+ target = store.profiles.find((p) => p.username === username);
251
+ }
252
+ const toUsername = target?.username ?? username ?? null;
253
+ const previous = restorableState(this.state);
254
+ // a log out or re-initialization during the access check ends this switch
255
+ cancelAuthFlow(this);
256
+ const flowId = this._authFlowId;
257
+ this.state = { status: AuthStates.SWITCHING, from: current?.username ?? null, to: toUsername };
258
+
259
+ if (target != null && target.unavailable !== true) {
260
+ let info;
261
+ try {
262
+ info = await this._accessInfo(target.apiEndpoint);
263
+ } catch (e) {
264
+ if (this._authFlowId !== flowId) throw e;
265
+ // network failure: stay on the previous account
266
+ this.state = previous ?? { status: AuthStates.INITIALIZED, serviceInfo: this.serviceInfo };
267
+ throw e;
268
+ }
269
+ if (this._authFlowId !== flowId) return;
270
+ if (info != null && info.error == null) {
271
+ const profile = profileFromAccessInfo(target, info);
272
+ this.state = { status: AuthStates.AUTHORIZED, username: profile.username, apiEndpoint: profile.apiEndpoint, profile };
273
+ return;
274
+ }
275
+ if (!ACCESS_GONE_ERRORS.includes(info?.error?.id)) {
276
+ // any other answer (server error, rate limit) says nothing about the access
277
+ this.state = previous ?? { status: AuthStates.INITIALIZED, serviceInfo: this.serviceInfo };
278
+ throw new PryvError('Cannot check the access of ' + target.username + ': ' + (info?.error?.id ?? 'unexpected answer'), info?.error);
279
+ }
280
+ // revoked or expired (a detach revokes the accesses granted through it)
281
+ this._saveProfiles(ProfileStore.markUnavailable(this._readProfiles(), target.username));
282
+ }
283
+ // own account: a sign-in that offers no other account; otherwise ask for that one
284
+ await this.startAuthRequest({ actAs: username == null ? 'deny' : username }, previous);
285
+ }
286
+
287
+ /**
288
+ * Sign in to one more account (the popup may offer the accounts the person
289
+ * can act for); the remembered accounts are kept.
290
+ * @returns {Promise<void>}
291
+ */
292
+ async addAccount () {
293
+ const current = this.currentProfile();
294
+ const previous = restorableState(this.state);
295
+ this.state = { status: AuthStates.SWITCHING, from: current?.username ?? null, to: null };
296
+ await this.startAuthRequest({ actAs: 'allow' }, previous);
297
+ }
298
+
299
+ /**
300
+ * @private The access-info of a stored account (`{ error }` when refused).
301
+ * @param {string} apiEndpoint
302
+ */
303
+ async _accessInfo (apiEndpoint) {
304
+ // required here: Connection is not needed before the first switch
305
+ const Connection = require('../Connection');
306
+ return await new Connection(apiEndpoint).accessInfo(true);
307
+ }
308
+
309
+ /** @private */
310
+ _readProfiles () {
311
+ const loginButton = this.loginButton;
312
+ if (loginButton == null || typeof loginButton.getAuthorizationData !== 'function') return ProfileStore.read(null);
313
+ return ProfileStore.read(loginButton.getAuthorizationData());
314
+ }
315
+
316
+ /** @private */
317
+ _saveProfiles (store) {
318
+ const loginButton = this.loginButton;
319
+ if (loginButton == null || typeof loginButton.saveAuthorizationData !== 'function') return;
320
+ const data = ProfileStore.write(store);
321
+ if (data == null && typeof loginButton.deleteAuthorizationData === 'function') {
322
+ loginButton.deleteAuthorizationData();
323
+ } else if (data != null) {
324
+ loginButton.saveAuthorizationData(data);
325
+ }
326
+ }
327
+
328
+ /**
329
+ * URL of the account app (profile page) for this platform, or null when
330
+ * it cannot be determined. Resolution order: `settings.accountUrl`, then
331
+ * the service's `account`, then the auth page URL of the last auth
332
+ * request with its trailing `/auth` removed.
333
+ * @returns {string|null}
334
+ */
335
+ accountUrl () {
336
+ let base = this.settings.accountUrl || this.serviceInfo?.account || accountUrlFromAuthUrl(this._authUrl);
337
+ if (typeof base !== 'string' || base === '') return null;
338
+ base = base.replace(/\/+$/, '');
339
+ // @ts-ignore - Service keeps the URL it was created with
340
+ const serviceInfoUrl = this.service?._serviceInfoUrl;
341
+ return base + '/account/profile' +
342
+ (serviceInfoUrl ? '?pryvServiceInfoUrl=' + encodeURIComponent(serviceInfoUrl) : '');
343
+ }
344
+
345
+ /**
346
+ * Open the account app in a new tab.
347
+ * @returns {string|null} the URL opened, or null when unknown
348
+ */
349
+ openAccountApp () {
350
+ const url = this.accountUrl();
351
+ if (url != null) window.open(url, '_blank', 'noopener');
352
+ return url;
353
+ }
354
+
132
355
  /**
133
356
  * Compute the return URL for authentication redirect.
134
357
  * Used only in browser environments.
135
- * @param {string} [returnURL] - The return URL setting ('auto#', 'self#', or custom URL)
358
+ * @param {string|false} [returnURL] - The return URL setting ('auto#', 'self#', or custom URL)
136
359
  * @param {string} [windowLocationForTest] - Mock window.location.href for testing
137
- * @param {string|Navigator} [navigatorForTests] - Mock navigator for testing
360
+ * @param {string|Navigator} [navigatorForTests] - Mock navigator for testing (defaults to the browser's own)
138
361
  * @returns {string|boolean} The computed return URL, or false if using popup mode
139
362
  */
140
363
  getReturnURL (
@@ -143,6 +366,7 @@ class AuthController {
143
366
  navigatorForTests
144
367
  ) {
145
368
  const RETURN_URL_AUTO = 'auto';
369
+ const nav = navigatorForTests ?? globalThis.navigator;
146
370
 
147
371
  returnURL = returnURL || RETURN_URL_AUTO + '#';
148
372
 
@@ -154,11 +378,11 @@ class AuthController {
154
378
  }
155
379
  // auto mode for desktop
156
380
  if (returnUrlIsAuto(returnURL) &&
157
- !utils.browserIsMobileOrTablet(navigatorForTests)) {
381
+ !utils.browserIsMobileOrTablet(nav)) {
158
382
  return false;
159
383
  // auto mode for mobile or self
160
384
  } else if ((returnUrlIsAuto(returnURL) &&
161
- utils.browserIsMobileOrTablet(navigatorForTests)) ||
385
+ utils.browserIsMobileOrTablet(nav)) ||
162
386
  returnURL.indexOf('self') === 0) {
163
387
  // set self as return url?
164
388
  // eventually clean-up current url from previous pryv returnURL
@@ -174,16 +398,28 @@ class AuthController {
174
398
 
175
399
  /**
176
400
  * Start the authentication request and polling process
401
+ * @param {Object} [overrides] - auth request fields for this request only (e.g. `actAs`)
402
+ * @param {Object} [previous] - AUTHORIZED state to return to when this
403
+ * request (an account switch) does not end in AUTHORIZED
177
404
  * @returns {Promise<void>}
178
405
  * @see https://pryv.github.io/reference/#auth-request
179
406
  */
180
- async startAuthRequest () {
407
+ async startAuthRequest (overrides, previous) {
408
+ cancelAuthFlow(this);
409
+ const flowId = this._authFlowId;
410
+ this._switchPrevious = previous ?? null;
181
411
  // @ts-ignore - postAccess uses .call(this) for context
182
- this.state = await postAccess.call(this);
412
+ const requested = await postAccess.call(this);
413
+ if (this._authFlowId !== flowId) return; // replaced while posting
414
+ this.state = requested;
183
415
  // Remember the polling key so listeners on the terminal AUTHORIZED
184
416
  // state can be handed `{ key, serviceInfo? }` (the polling response
185
417
  // itself doesn't echo `key` back).
186
418
  this._authFlowKey = this.state?.key;
419
+ // an app's later connectFromKey(key) then polls the core holding the request
420
+ pollUrls.remember(this.state?.key, this.state?.poll);
421
+ // Kept to locate the account app when the service does not name it.
422
+ if (this.state?.authUrl) this._authUrl = this.state.authUrl;
187
423
 
188
424
  await doPolling.call(this);
189
425
 
@@ -194,14 +430,19 @@ class AuthController {
194
430
  // @ts-ignore - this is bound via .call()
195
431
  this.serviceInfo.access,
196
432
  // @ts-ignore - this is bound via .call()
197
- this.settings.authRequest
433
+ Object.assign({}, this.settings.authRequest, overrides)
198
434
  );
199
435
  if (!response.ok) {
200
- throw new Error('Access request failed: ' + JSON.stringify(body));
436
+ // The server's message, id and status; the body stays on `response`,
437
+ // never in the message (it echoes the request's permissions and data).
438
+ throw PryvError.fromApiResponse(response, body);
201
439
  }
202
440
  return body;
203
441
  } catch (e) {
204
- this.state = {
442
+ if (this._authFlowId !== flowId) throw e; // replaced while posting
443
+ const previous = this._switchPrevious;
444
+ this._switchPrevious = null;
445
+ this.state = previous ?? {
205
446
  status: AuthStates.ERROR,
206
447
  message: 'Requesting access',
207
448
  error: e
@@ -213,21 +454,62 @@ class AuthController {
213
454
  /** @this {AuthController} */
214
455
  async function doPolling () {
215
456
  // @ts-ignore - this is bound via .call()
216
- if (this.state?.status !== AuthStates.NEED_SIGNIN) {
457
+ if (this._authFlowId !== flowId || this.state?.status !== AuthStates.NEED_SIGNIN) {
217
458
  return;
218
459
  }
219
460
  // @ts-ignore - this is bound via .call()
220
461
  const pollResponse = await pollAccess(this.state?.poll);
462
+ // a newer request, a sign-out or a re-initialization replaced this one
463
+ // @ts-ignore - this is bound via .call()
464
+ if (this._authFlowId !== flowId) return;
221
465
 
222
466
  if (pollResponse.status === AuthStates.NEED_SIGNIN) {
223
467
  // @ts-ignore - this is bound via .call()
224
468
  setTimeout(await doPolling.bind(this), this.state?.poll_rate_ms);
225
469
  } else {
470
+ // Shared-secret delivery: the ACCEPTED body carries a one-time
471
+ // `handoff` key, not the token. Redeem it once here (caching under the
472
+ // poll key so a later connectFromKey reuses it) and rewrite the body to
473
+ // the legacy shape, so the cookie / LoginButton path and the external
474
+ // listener filter are untouched.
475
+ if (handoff.isHandoffBody(pollResponse)) {
476
+ try {
477
+ const entry = await handoff.resolveHandoff(pollResponse, this._authFlowKey);
478
+ pollResponse.apiEndpoint = entry.apiEndpoint;
479
+ pollResponse.token = entry.token;
480
+ pollResponse.username = entry.username;
481
+ delete pollResponse.handoff;
482
+ } catch (e) {
483
+ // @ts-ignore - this is bound via .call()
484
+ if (this._authFlowId !== flowId) return;
485
+ // @ts-ignore - this is bound via .call()
486
+ const previous = this._switchPrevious;
487
+ // @ts-ignore - this is bound via .call()
488
+ this._switchPrevious = null;
489
+ if (previous != null) console.warn('pryv: account switch did not complete (credential hand-off failed); keeping the previous account');
490
+ this.state = previous ?? { status: AuthStates.ERROR, message: 'Credential hand-off failed', error: e };
491
+ return;
492
+ }
493
+ // a newer request, a sign-out or a re-initialization replaced this one
494
+ // @ts-ignore - this is bound via .call()
495
+ if (this._authFlowId !== flowId) return;
496
+ }
226
497
  // Carry the key forward — listeners on the narrow public surface
227
498
  // need it, and the server doesn't echo it back on ACCEPTED.
228
499
  if (this._authFlowKey != null && pollResponse.key == null) {
229
500
  pollResponse.key = this._authFlowKey;
230
501
  }
502
+ const previous = this._switchPrevious;
503
+ this._switchPrevious = null;
504
+ if (pollResponse.status === AuthStates.AUTHORIZED) {
505
+ pollResponse.profile = ProfileStore.fromAccepted(pollResponse);
506
+ } else if (previous != null) {
507
+ // an account switch that did not complete: stay on the previous account
508
+ console.warn('pryv: account switch did not complete (' +
509
+ (pollResponse?.error?.id ?? pollResponse?.message ?? pollResponse?.status) + '); keeping the previous account');
510
+ this.state = previous;
511
+ return;
512
+ }
231
513
  this.state = pollResponse;
232
514
  }
233
515
 
@@ -307,16 +589,87 @@ function filterForExternalListener (state) {
307
589
  return out;
308
590
  }
309
591
 
592
+ /**
593
+ * The account app is served next to the auth page: strip a trailing `/auth`
594
+ * path segment (and the query) from the auth page URL. Null when the URL
595
+ * does not have that shape.
596
+ * @param {string} [authUrl]
597
+ * @returns {string|null}
598
+ */
599
+ function accountUrlFromAuthUrl (authUrl) {
600
+ if (typeof authUrl !== 'string') return null;
601
+ let url;
602
+ try { url = new URL(authUrl); } catch (e) { return null; }
603
+ const path = url.pathname.replace(/\/+$/, '');
604
+ if (!path.endsWith('/auth')) return null;
605
+ return url.origin + path.slice(0, -'/auth'.length);
606
+ }
607
+
310
608
  async function checkAutoLogin (authController) {
311
609
  const loginButton = authController.loginButton;
312
610
  if (loginButton == null) {
313
611
  return;
314
612
  }
315
613
 
316
- const storedCredentials = await loginButton.getAuthorizationData();
317
- if (storedCredentials != null) {
318
- authController.state = Object.assign({}, { status: AuthStates.AUTHORIZED }, storedCredentials);
614
+ let storedCredentials = await loginButton.getAuthorizationData();
615
+ if (storedCredentials == null) return;
616
+ // Forget the stored sign-ins that carry no token (3.13.0 saved some after a
617
+ // redirect return): they would sign in to a session the API refuses, or
618
+ // stay listed as available accounts.
619
+ const stored = ProfileStore.read(storedCredentials);
620
+ const tokenless = stored.profiles.filter((p) => !ProfileStore.carriesToken(p.apiEndpoint));
621
+ if (tokenless.length > 0) {
622
+ authController._saveProfiles(tokenless.reduce((s, p) => ProfileStore.remove(s, p.username), stored));
623
+ storedCredentials = await loginButton.getAuthorizationData();
624
+ if (storedCredentials == null) return;
625
+ }
626
+ if (typeof storedCredentials.authUrl === 'string') authController._authUrl = storedCredentials.authUrl;
627
+ if (Array.isArray(storedCredentials.profiles)) {
628
+ // Several remembered accounts: sign in to the active one, if any
629
+ const store = ProfileStore.read(storedCredentials);
630
+ if (store.active == null || !ProfileStore.carriesToken(store.active.apiEndpoint)) return;
631
+ const state = { status: AuthStates.AUTHORIZED, username: store.active.username, apiEndpoint: store.active.apiEndpoint, profile: store.active };
632
+ if (store.authUrl != null) state.authUrl = store.authUrl;
633
+ authController.state = state;
634
+ return;
635
+ }
636
+ // a button that cannot rewrite its storage still must not sign in to it
637
+ if (typeof storedCredentials.apiEndpoint === 'string' && !ProfileStore.carriesToken(storedCredentials.apiEndpoint)) return;
638
+ const state = Object.assign({}, { status: AuthStates.AUTHORIZED }, storedCredentials);
639
+ if (typeof state.username === 'string' && typeof state.apiEndpoint === 'string') state.profile = ProfileStore.profileOf(state);
640
+ authController.state = state;
641
+ }
642
+
643
+ /** A stored profile refreshed with what its access says about itself. */
644
+ function profileFromAccessInfo (stored, info) {
645
+ const profile = { username: stored.username, apiEndpoint: stored.apiEndpoint };
646
+ const d = info.delegation;
647
+ if (d != null && d.isDelegatedAccess === true && typeof d.delegate?.username === 'string') {
648
+ profile.actingAs = { username: stored.username, delegate: d.delegate.username };
319
649
  }
650
+ return profile;
651
+ }
652
+
653
+ /** API errors that mean a stored access is no longer usable. */
654
+ const ACCESS_GONE_ERRORS = ['invalid-access-token', 'forbidden'];
655
+
656
+ /**
657
+ * The signed-in state to return to when an account switch does not
658
+ * complete: the account as stored, without the `key` of its sign-in (that
659
+ * auth request is consumed), like a sign-in from stored credentials.
660
+ */
661
+ function restorableState (state) {
662
+ if (state?.status !== AuthStates.AUTHORIZED) return null;
663
+ const restored = { status: AuthStates.AUTHORIZED, username: state.username, apiEndpoint: state.apiEndpoint };
664
+ if (state.profile != null) restored.profile = state.profile;
665
+ if (state.authUrl != null) restored.authUrl = state.authUrl;
666
+ return restored;
667
+ }
668
+
669
+ /** Stop any auth request in progress: its poll no longer changes the state. */
670
+ function cancelAuthFlow (authController) {
671
+ authController._authFlowId = (authController._authFlowId || 0) + 1;
672
+ authController._switchPrevious = null;
320
673
  }
321
674
 
322
675
  // ------------------ ACTIONS ----------- //
@@ -4,7 +4,8 @@
4
4
  */
5
5
  /**
6
6
  * The possible auth states:
7
- * ERROR, LOADING, INITIALIZED, NEED_SIGNIN, AUTHORIZED, SIGNOUT, REFUSED
7
+ * ERROR, LOADING, INITIALIZED, NEED_SIGNIN, AUTHORIZED, SIGNOUT, REFUSED,
8
+ * SWITCHING (an account switch is running)
8
9
  * @readonly
9
10
  * @enum {string}
10
11
  * @memberof pryv.Browser
@@ -16,5 +17,6 @@ module.exports = {
16
17
  NEED_SIGNIN: 'NEED_SIGNIN',
17
18
  AUTHORIZED: 'ACCEPTED',
18
19
  SIGNOUT: 'SIGNOUT',
19
- REFUSED: 'REFUSED'
20
+ REFUSED: 'REFUSED',
21
+ SWITCHING: 'SWITCHING'
20
22
  };