pryv 3.13.0 → 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
@@ -607,7 +607,7 @@ The [authentication process](https://api.pryv.com/reference/#authenticate-your-a
607
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
608
  7. `ERROR`: see message for more information
609
609
 
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.
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.
611
611
 
612
612
  ```js
613
613
  async onStateChange (state) {
@@ -732,6 +732,8 @@ For a more advanced scenario, you can check the default button implementation in
732
732
 
733
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.
734
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
+
735
737
 
736
738
  ### Running examples locally
737
739
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pryv",
3
- "version": "3.13.0",
3
+ "version": "3.14.2",
4
4
  "description": "Pryv JavaScript library",
5
5
  "keywords": [
6
6
  "Pryv",
@@ -7,6 +7,8 @@ const AuthStates = require('./AuthStates');
7
7
  const Messages = require('./LoginMessages');
8
8
  const ProfileStore = require('./ProfileStore');
9
9
  const handoff = require('../lib/handoff');
10
+ const pollUrls = require('../lib/pollUrls');
11
+ const PryvError = require('../lib/PryvError');
10
12
 
11
13
  /**
12
14
  * Controller for authentication flow
@@ -143,6 +145,9 @@ class AuthController {
143
145
  this.startAuthRequest();
144
146
  } else if (this.state.status === AuthStates.SWITCHING) {
145
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();
146
151
  } else if (isNeedSignIn.call(this)) {
147
152
  // reopen popup (HACK for now: set to private property to avoid self-assignment)
148
153
  this.state = this._state;
@@ -270,7 +275,7 @@ class AuthController {
270
275
  if (!ACCESS_GONE_ERRORS.includes(info?.error?.id)) {
271
276
  // any other answer (server error, rate limit) says nothing about the access
272
277
  this.state = previous ?? { status: AuthStates.INITIALIZED, serviceInfo: this.serviceInfo };
273
- throw new Error('Cannot check the access of ' + target.username + ': ' + JSON.stringify(info?.error));
278
+ throw new PryvError('Cannot check the access of ' + target.username + ': ' + (info?.error?.id ?? 'unexpected answer'), info?.error);
274
279
  }
275
280
  // revoked or expired (a detach revokes the accesses granted through it)
276
281
  this._saveProfiles(ProfileStore.markUnavailable(this._readProfiles(), target.username));
@@ -350,9 +355,9 @@ class AuthController {
350
355
  /**
351
356
  * Compute the return URL for authentication redirect.
352
357
  * Used only in browser environments.
353
- * @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)
354
359
  * @param {string} [windowLocationForTest] - Mock window.location.href for testing
355
- * @param {string|Navigator} [navigatorForTests] - Mock navigator for testing
360
+ * @param {string|Navigator} [navigatorForTests] - Mock navigator for testing (defaults to the browser's own)
356
361
  * @returns {string|boolean} The computed return URL, or false if using popup mode
357
362
  */
358
363
  getReturnURL (
@@ -361,6 +366,7 @@ class AuthController {
361
366
  navigatorForTests
362
367
  ) {
363
368
  const RETURN_URL_AUTO = 'auto';
369
+ const nav = navigatorForTests ?? globalThis.navigator;
364
370
 
365
371
  returnURL = returnURL || RETURN_URL_AUTO + '#';
366
372
 
@@ -372,11 +378,11 @@ class AuthController {
372
378
  }
373
379
  // auto mode for desktop
374
380
  if (returnUrlIsAuto(returnURL) &&
375
- !utils.browserIsMobileOrTablet(navigatorForTests)) {
381
+ !utils.browserIsMobileOrTablet(nav)) {
376
382
  return false;
377
383
  // auto mode for mobile or self
378
384
  } else if ((returnUrlIsAuto(returnURL) &&
379
- utils.browserIsMobileOrTablet(navigatorForTests)) ||
385
+ utils.browserIsMobileOrTablet(nav)) ||
380
386
  returnURL.indexOf('self') === 0) {
381
387
  // set self as return url?
382
388
  // eventually clean-up current url from previous pryv returnURL
@@ -410,6 +416,8 @@ class AuthController {
410
416
  // state can be handed `{ key, serviceInfo? }` (the polling response
411
417
  // itself doesn't echo `key` back).
412
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);
413
421
  // Kept to locate the account app when the service does not name it.
414
422
  if (this.state?.authUrl) this._authUrl = this.state.authUrl;
415
423
 
@@ -425,7 +433,9 @@ class AuthController {
425
433
  Object.assign({}, this.settings.authRequest, overrides)
426
434
  );
427
435
  if (!response.ok) {
428
- 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);
429
439
  }
430
440
  return body;
431
441
  } catch (e) {
@@ -476,6 +486,7 @@ class AuthController {
476
486
  const previous = this._switchPrevious;
477
487
  // @ts-ignore - this is bound via .call()
478
488
  this._switchPrevious = null;
489
+ if (previous != null) console.warn('pryv: account switch did not complete (credential hand-off failed); keeping the previous account');
479
490
  this.state = previous ?? { status: AuthStates.ERROR, message: 'Credential hand-off failed', error: e };
480
491
  return;
481
492
  }
@@ -494,6 +505,8 @@ class AuthController {
494
505
  pollResponse.profile = ProfileStore.fromAccepted(pollResponse);
495
506
  } else if (previous != null) {
496
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');
497
510
  this.state = previous;
498
511
  return;
499
512
  }
@@ -598,18 +611,30 @@ async function checkAutoLogin (authController) {
598
611
  return;
599
612
  }
600
613
 
601
- const storedCredentials = await loginButton.getAuthorizationData();
614
+ let storedCredentials = await loginButton.getAuthorizationData();
602
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
+ }
603
626
  if (typeof storedCredentials.authUrl === 'string') authController._authUrl = storedCredentials.authUrl;
604
627
  if (Array.isArray(storedCredentials.profiles)) {
605
628
  // Several remembered accounts: sign in to the active one, if any
606
629
  const store = ProfileStore.read(storedCredentials);
607
- if (store.active == null) return;
630
+ if (store.active == null || !ProfileStore.carriesToken(store.active.apiEndpoint)) return;
608
631
  const state = { status: AuthStates.AUTHORIZED, username: store.active.username, apiEndpoint: store.active.apiEndpoint, profile: store.active };
609
632
  if (store.authUrl != null) state.authUrl = store.authUrl;
610
633
  authController.state = state;
611
634
  return;
612
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;
613
638
  const state = Object.assign({}, { status: AuthStates.AUTHORIZED }, storedCredentials);
614
639
  if (typeof state.username === 'string' && typeof state.apiEndpoint === 'string') state.profile = ProfileStore.profileOf(state);
615
640
  authController.state = state;
@@ -23,9 +23,27 @@ module.exports = {
23
23
  remove,
24
24
  markUnavailable,
25
25
  profileOf,
26
- fromAccepted
26
+ fromAccepted,
27
+ carriesToken
27
28
  };
28
29
 
30
+ const utils = require('../utils');
31
+
32
+ /**
33
+ * Whether an apiEndpoint carries a token. A stored sign-in without one
34
+ * cannot call the API (3.13.0 saved such sign-ins after a redirect return,
35
+ * the credential hand-off not being redeemed).
36
+ * @param {string} apiEndpoint
37
+ * @returns {boolean}
38
+ */
39
+ function carriesToken (apiEndpoint) {
40
+ try {
41
+ return Boolean(utils.extractTokenAndAPIEndpoint(apiEndpoint).token);
42
+ } catch (e) {
43
+ return false;
44
+ }
45
+ }
46
+
29
47
  /**
30
48
  * Parse stored authorization data (any version) into
31
49
  * `{ active, profiles, authUrl }`; `active` is `profiles[0]` or null.
package/src/Auth/index.js CHANGED
@@ -26,11 +26,14 @@ module.exports = {
26
26
  * @param {string} [settings.authRequest.languageCode] Language code, as per LoginButton Messages: 'en', 'fr
27
27
  * @param {string} settings.authRequest.requestingAppId Application id, ex: 'my-app'
28
28
  * @param {Object} settings.authRequest.requestedPermissions
29
- * @param {string | boolean} settings.authRequest.returnURL : false, // set this if you don't want a popup
29
+ * @param {string | false} [settings.authRequest.returnURL] 'auto#' (default, also when unset or false):
30
+ * popup on desktop, redirect on a phone or tablet; 'self#': always redirect back to this page;
31
+ * a URL: always redirect back to that URL. Must end with '#', '?' or '&'.
32
+ * @param {string} [settings.authRequest.authUrl] Your own auth page for this request; honoured only
33
+ * when it matches the platform's `access:trustedAuthUrls`
30
34
  * @param {string} [settings.authRequest.referer] To track registration source
31
35
  * @param {string} settings.spanButtonID set and <span> id in DOM to insert default login button or null for custom
32
36
  * @param {Function} settings.onStateChange
33
- * @param {string} [settings.returnURL] Set to "self#" to disable popup and force using the same page
34
37
  * @param {string} serviceInfoUrl
35
38
  * @param {Object} [serviceCustomizations] override properties of serviceInfoUrl
36
39
  * @returns {Promise<Service>}
@@ -7,6 +7,8 @@ const AuthStates = require('../Auth/AuthStates');
7
7
  const AuthController = require('../Auth/AuthController');
8
8
  const ProfileStore = require('../Auth/ProfileStore');
9
9
  const Messages = require('../Auth/LoginMessages');
10
+ const handoff = require('../lib/handoff');
11
+ const pollUrls = require('../lib/pollUrls');
10
12
  const utils = require('../utils');
11
13
 
12
14
  /* global location */
@@ -63,6 +65,10 @@ class LoginButton {
63
65
  case AuthStates.NEED_SIGNIN: {
64
66
  const loginUrl = state.authUrl || state.url; // url is deprecated
65
67
  if (this.authSettings.authRequest.returnURL) { // open on same page (no Popup)
68
+ // Remember the request this page started: on the way back, only
69
+ // its key is accepted and only its (server-issued) poll URL is
70
+ // fetched (see finishAuthProcessAfterRedirection).
71
+ writeAuthFlow(this._cookieKey, { key: state.key, poll: state.poll, authUrl: loginUrl });
66
72
  location.href = loginUrl;
67
73
  return;
68
74
  } else {
@@ -73,6 +79,9 @@ class LoginButton {
73
79
  case AuthStates.AUTHORIZED: {
74
80
  const profile = state.profile || ProfileStore.fromAccepted(state);
75
81
  this.text = profileLabel(this, profile);
82
+ // Never remember a sign-in that cannot call the API (it would be
83
+ // restored on every page load).
84
+ if (!ProfileStore.carriesToken(profile?.apiEndpoint)) break;
76
85
  const store = ProfileStore.read(this.getAuthorizationData());
77
86
  // Kept to locate the account app after a reload (see AuthController.accountUrl).
78
87
  const authUrl = withoutQuery(state.authUrl || this.auth?._authUrl) || store.authUrl;
@@ -219,46 +228,147 @@ class LoginButton {
219
228
  // 3. Check if there is a pryvKey / pryvPoll (or legacy prYvkey /
220
229
  // prYvpoll) as result of "out of page login"
221
230
  const url = window.location.href;
222
- const pollUrl = retrievePollUrl(url);
223
- if (pollUrl !== null) {
231
+ const key = retrieveKey(url);
232
+ if (key !== null) {
233
+ // Already signed in (from the stored sign-in): a return that does not
234
+ // end in a new sign-in (an account switch refused or failed, a stray
235
+ // link) leaves that account in place, as the popup path does for a
236
+ // switch. The state is then left as it is (no second AUTHORIZED).
237
+ const signedIn = authController.state?.status === AuthStates.AUTHORIZED;
238
+ // Only finish the auth request this page started (kept across the
239
+ // redirect by onStateChange), and poll the URL the server gave for
240
+ // it: a link carrying another key or poll URL must not sign the page
241
+ // in (to a foreign host, or to someone else's account).
242
+ const flow = readAuthFlow(this._cookieKey);
243
+ if (flow == null || flow.key !== key || typeof flow.poll !== 'string') {
244
+ console.warn('pryv: ignoring a sign-in return for a request this page did not start');
245
+ if (!signedIn) {
246
+ authController.state = {
247
+ status: AuthStates.ERROR,
248
+ message: 'Sign-in return does not match a sign-in started on this page',
249
+ error: { id: 'unexpected-auth-return' }
250
+ };
251
+ }
252
+ cleanUrl();
253
+ return;
254
+ }
255
+ clearAuthFlow(this._cookieKey);
256
+ const pollUrl = flow.poll;
257
+ // an app's connectFromKey(key) on this page then polls the same core
258
+ pollUrls.remember(key, pollUrl);
259
+ // the flow of this sign-in: a sign-out clears its cached credential
260
+ authController._authFlowKey = key;
261
+ let response, body;
224
262
  try {
225
- const { body } = await utils.fetchGet(pollUrl);
226
- if (body?.status === AuthStates.AUTHORIZED && typeof body.username === 'string') body.profile = ProfileStore.fromAccepted(body);
227
- authController.state = body;
263
+ ({ response, body } = await utils.fetchGet(pollUrl));
228
264
  } catch (e) {
229
- authController.state = {
265
+ body = {
230
266
  status: AuthStates.ERROR,
231
267
  message: 'Cannot fetch result',
232
268
  error: e
233
269
  };
234
270
  }
235
- // These params are one-shot; leaving them in the visible URL puts
236
- // stale auth state into bookmarks / copied links.
271
+ if (response?.status === 403 && body?.status === 'REFUSED') {
272
+ // refused on the auth page: back to the sign-in button (as the popup path)
273
+ body = { status: AuthStates.INITIALIZED, serviceInfo: authController.serviceInfo };
274
+ } else if (body?.status == null) {
275
+ // unknown or expired key, or no answer the button can show
276
+ body = { status: AuthStates.ERROR, message: 'Cannot fetch result', error: body?.error ?? body };
277
+ }
278
+ // Shared-secret delivery: the ACCEPTED body carries a one-time
279
+ // `handoff` key, not the token. Redeem it exactly as the polling path
280
+ // does, under the flow key, and never report a token-less AUTHORIZED.
281
+ // Unlike the polling path, the state keeps its legacy shape (no `key`,
282
+ // credentials included), which existing redirect apps read.
283
+ if (handoff.isHandoffBody(body)) {
284
+ try {
285
+ const entry = await handoff.resolveHandoff(body, key);
286
+ body.apiEndpoint = entry.apiEndpoint;
287
+ body.token = entry.token;
288
+ body.username = entry.username;
289
+ delete body.handoff;
290
+ } catch (e) {
291
+ body = { status: AuthStates.ERROR, message: 'Credential hand-off failed', error: e };
292
+ }
293
+ }
294
+ if (body?.status === AuthStates.AUTHORIZED && typeof body.username === 'string') {
295
+ body.profile = ProfileStore.fromAccepted(body);
296
+ // the auth page of this sign-in locates the account app
297
+ if (typeof flow.authUrl === 'string') authController._authUrl = flow.authUrl;
298
+ } else if (signedIn) {
299
+ // refused or failed: stay on the account already signed in
300
+ console.warn('pryv: sign-in by redirection did not complete (' +
301
+ (body?.error?.id ?? body?.message ?? body?.status) + '); keeping the signed-in account');
302
+ cleanUrl();
303
+ return;
304
+ }
305
+ authController.state = body;
306
+ cleanUrl();
307
+ } else if (utils.cleanURLFromPrYvParams(url) !== url) {
308
+ // leftover one-shot params without a key (e.g. only `prYvstatus`)
309
+ cleanUrl();
310
+ }
311
+
312
+ // These params are one-shot; leaving them in the visible URL puts
313
+ // stale auth state into bookmarks / copied links.
314
+ function cleanUrl () {
237
315
  if (window.history && typeof window.history.replaceState === 'function') {
238
316
  window.history.replaceState(null, '', utils.cleanURLFromPrYvParams(url));
239
317
  }
240
318
  }
241
319
 
242
- function retrievePollUrl (url) {
320
+ /** The key of the returning auth request, or null when the URL is not a return. */
321
+ function retrieveKey (url) {
243
322
  // Modern lowercase form (pryvKey / pryvPoll) is preferred; the
244
323
  // capital-Y form (prYvkey / prYvpoll) is accepted for back-compat
245
324
  // with apps emitting the legacy URL contract — see
246
325
  // [DEPRECATED] notes on cleanURLFromPrYvParams.
247
326
  const params = utils.getQueryParamsFromURL(url);
248
- let pollUrl = null;
249
327
  const key = params.pryvKey || params.prYvkey;
250
- if (key) {
251
- pollUrl = authController.serviceInfo.access + key;
252
- }
328
+ if (key) return key;
253
329
  const poll = params.pryvPoll || params.prYvpoll;
254
- if (poll) {
255
- pollUrl = poll;
256
- }
257
- return pollUrl;
330
+ // the poll URL ends with the key (`<access>/<key>`); only the key is
331
+ // used, the poll URL fetched is the one stored when the flow started
332
+ if (poll) return poll.split(/[?#]/)[0].split('/').filter(Boolean).pop() || null;
333
+ return null;
258
334
  }
259
335
  }
260
336
  }
261
337
 
338
+ // ---- the auth request started by this page, kept across the redirect ----
339
+ // sessionStorage: per tab and origin, gone when the tab closes, readable by
340
+ // no other origin. Any failure (storage blocked) reads as "no flow".
341
+
342
+ function authFlowStorageKey (cookieKey) {
343
+ return cookieKey + '-authflow';
344
+ }
345
+
346
+ function writeAuthFlow (cookieKey, flow) {
347
+ try {
348
+ window.sessionStorage.setItem(authFlowStorageKey(cookieKey), JSON.stringify(flow));
349
+ } catch (e) {
350
+ // the return will then be refused, as for any unknown request
351
+ }
352
+ }
353
+
354
+ function readAuthFlow (cookieKey) {
355
+ try {
356
+ const raw = window.sessionStorage.getItem(authFlowStorageKey(cookieKey));
357
+ const flow = raw == null ? null : JSON.parse(raw);
358
+ return flow != null && typeof flow === 'object' ? flow : null;
359
+ } catch (e) {
360
+ return null;
361
+ }
362
+ }
363
+
364
+ function clearAuthFlow (cookieKey) {
365
+ try {
366
+ window.sessionStorage.removeItem(authFlowStorageKey(cookieKey));
367
+ } catch (e) {
368
+ // nothing stored
369
+ }
370
+ }
371
+
262
372
  module.exports = LoginButton;
263
373
 
264
374
  async function startLoginScreen (loginButton, authUrl) {
package/src/Connection.js CHANGED
@@ -116,7 +116,10 @@ class Connection {
116
116
  * @param {Object|Array} [params={}] - The params associated with this method
117
117
  * @param {string} [expectedKey] - If given, returns the value of this key or throws an error if not present
118
118
  * @returns {Promise<Object>} Promise resolving to the API result or the value of expectedKey
119
- * @throws {Error} If .error is present in the response or expectedKey is missing
119
+ * @throws {Error} If .error is present in the response or expectedKey is missing.
120
+ * The message names the method and the server's error id and message; the full
121
+ * error (or result) is on `innerObject`. The call's params are never in the message:
122
+ * they can carry passwords and tokens, and error messages end up in logs and on screen.
120
123
  */
121
124
  async apiOne (method, params = {}, expectedKey) {
122
125
  const result = await this.api([{ method, params }]);
@@ -125,11 +128,18 @@ class Connection {
125
128
  result[0].error ||
126
129
  (expectedKey != null && result[0][expectedKey] == null)
127
130
  ) {
128
- const innerObject = result[0]?.error || result;
131
+ const error = result[0]?.error;
132
+ const innerObject = error || result;
133
+ let reason;
134
+ if (error) {
135
+ reason = [error.id, error.message].filter((v) => v != null && v !== '').join(': ') || 'error answer';
136
+ } else if (result[0] == null) {
137
+ reason = 'no result';
138
+ } else {
139
+ reason = `"${expectedKey}" missing in result`;
140
+ }
129
141
  throw new PryvError(
130
- `Error for api method: "${method}" with params: ${JSON.stringify(
131
- params
132
- )} >> Result: ${JSON.stringify(innerObject)}"`,
142
+ `Error for api method: "${method}" >> ${reason}`,
133
143
  innerObject
134
144
  );
135
145
  }
@@ -197,17 +207,13 @@ class Connection {
197
207
  });
198
208
  }
199
209
  const resRequest = await callHandler(thisBatch);
200
- // result checks
210
+ // result checks: the answer rides on `innerObject`, never in the message
211
+ // (results can hold tokens, e.g. from accesses.*)
201
212
  if (!resRequest || !Array.isArray(resRequest.results)) {
202
- throw new Error(
203
- 'API call result is not an Array: ' + JSON.stringify(resRequest)
204
- );
213
+ throw new PryvError('API call result is not an Array', resRequest);
205
214
  }
206
215
  if (resRequest.results.length !== thisBatch.length) {
207
- throw new Error(
208
- 'API call result Array does not match request: ' +
209
- JSON.stringify(resRequest)
210
- );
216
+ throw new PryvError('API call result Array does not match request', resRequest);
211
217
  }
212
218
 
213
219
  // eventually call handleResult
package/src/Service.js CHANGED
@@ -6,6 +6,7 @@ const utils = require('./utils.js');
6
6
  const PryvError = require('./lib/PryvError.js');
7
7
  const MfaRequiredError = require('./lib/MfaRequiredError.js');
8
8
  const handoff = require('./lib/handoff.js');
9
+ const pollUrls = require('./lib/pollUrls.js');
9
10
  // Connection is required at the end of this file to allow circular requires.
10
11
  const Assets = require('./ServiceAssets.js');
11
12
 
@@ -204,9 +205,8 @@ class Service {
204
205
  }
205
206
 
206
207
  if (!body || !body.token) {
207
- throw new PryvError(
208
- 'Invalid login response: ' + JSON.stringify(body)
209
- );
208
+ // The answer rides on `innerObject`, never in the message (it may hold secrets).
209
+ throw new PryvError('Invalid login response: no token', body);
210
210
  }
211
211
  return new Connection(
212
212
  Service.buildAPIEndpoint(await this.info(), username, body.token),
@@ -255,9 +255,7 @@ class Service {
255
255
  });
256
256
  if (!response.ok) throw PryvError.fromApiResponse(response, body);
257
257
  if (!body || !body.token) {
258
- throw new PryvError(
259
- 'mfa.verify did not return a token: ' + JSON.stringify(body)
260
- );
258
+ throw new PryvError('mfa.verify did not return a token', body);
261
259
  }
262
260
  return new Connection(
263
261
  Service.buildAPIEndpoint(await this.info(), userId, body.token),
@@ -488,7 +486,9 @@ class Service {
488
486
  * @param {string[]} [authRequest.consent.optIn] - ids offered NOT
489
487
  * pre-selected, so the user has to choose them.
490
488
  * @param {string} [authRequest.languageCode='en']
491
- * @param {string|boolean} [authRequest.returnUrl]
489
+ * @param {string} [authRequest.returnURL] - URL the auth page returns
490
+ * to after the decision, sent as is (the 'auto#' / 'self#' shortcuts
491
+ * are resolved only by the sign-in button, not here).
492
492
  * @param {string} [authRequest.referer]
493
493
  * @param {Object} [authRequest.clientData]
494
494
  * @param {string} [authRequest.deviceName]
@@ -511,9 +511,7 @@ class Service {
511
511
  );
512
512
  if (!response.ok) throw PryvError.fromApiResponse(response, body);
513
513
  if (!body || !body.key || !body.poll) {
514
- throw new PryvError(
515
- 'Invalid access-request response: ' + JSON.stringify(body)
516
- );
514
+ throw new PryvError('Invalid access-request response: no key or poll', body);
517
515
  }
518
516
  const envelope = {
519
517
  key: body.key,
@@ -526,15 +524,21 @@ class Service {
526
524
  // all-or-nothing, which a caller may want to know before showing the
527
525
  // approve link.
528
526
  if (body.consent != null) envelope.consent = body.consent;
527
+ // polling by key (pollAccessRequest, connectFromKey) then reaches the
528
+ // core that holds the request
529
+ pollUrls.remember(envelope.key, envelope.poll);
529
530
  return envelope;
530
531
  }
531
532
 
532
533
  /**
533
534
  * Poll an in-progress access request once. Accepts either:
534
- * - a `key` returned by `startAccessRequest` (poll URL is built from
535
+ * - a `key` returned by `startAccessRequest` (polls the poll URL the
536
+ * server issued for it when this process started the request, else
535
537
  * `serviceInfo.access + key`)
536
538
  * - a full poll URL (use as-is — recommended, since the server-issued
537
- * URL is canonical and may include a different subdomain).
539
+ * URL is canonical and may point at a specific core: on a multi-core
540
+ * platform `access + key` can reach a core that does not know the
541
+ * request).
538
542
  *
539
543
  * Returns the raw body. Inspect `body.status` to drive the flow:
540
544
  * - `'NEED_SIGNIN'` → user has not interacted yet; keep polling.
@@ -553,8 +557,14 @@ class Service {
553
557
  }
554
558
  let pollUrl = keyOrPollUrl;
555
559
  if (!/^https?:\/\//.test(keyOrPollUrl)) {
556
- const serviceInfo = await this.info();
557
- pollUrl = serviceInfo.access + keyOrPollUrl;
560
+ // the core-specific URL the server issued, when this process started
561
+ // the request; `access + key` may reach another core on a multi-core
562
+ // platform
563
+ pollUrl = pollUrls.lookup(keyOrPollUrl);
564
+ if (pollUrl == null) {
565
+ const serviceInfo = await this.info();
566
+ pollUrl = serviceInfo.access + keyOrPollUrl;
567
+ }
558
568
  }
559
569
  const { response, body } = await utils.fetchGet(pollUrl);
560
570
  // 403 with status=REFUSED is the canonical "user declined" terminal
@@ -575,7 +585,9 @@ class Service {
575
585
  * `key` returned by the auth-flow (not the underlying token /
576
586
  * apiEndpoint), and uses this method to build a working `Connection`.
577
587
  *
578
- * The implementation polls `<access>/<key>` once; the call MUST be
588
+ * The implementation polls the request once (at the server-issued poll
589
+ * URL when this process started it, else `<access>/<key>`, which may
590
+ * miss the request on a multi-core platform); the call MUST be
579
591
  * made while the access request is still readable in the ACCEPTED
580
592
  * state. Servers keep a decided request only for a short retention
581
593
  * window after it is first polled (default 2 minutes, operator setting
package/src/index.d.ts CHANGED
@@ -160,7 +160,8 @@ declare module 'pryv' {
160
160
  */
161
161
  export class PryvError extends globalThis.Error {
162
162
  constructor(message: string, innerObject?: globalThis.Error | object);
163
- name: 'PryvError';
163
+ /** `'PryvError'`, or the subclass name (`'MfaRequiredError'`, ...). */
164
+ name: string;
164
165
  innerObject?: globalThis.Error | object;
165
166
  id?: string;
166
167
  status?: number;
@@ -891,10 +892,17 @@ declare module 'pryv' {
891
892
  /** Echoed only by a core that understood `authRequest.consent`. */
892
893
  consent?: AuthRequestConsentForm;
893
894
  }>;
895
+ /**
896
+ * Poll an access request once, by its full poll URL (recommended) or its
897
+ * `key`. A key started in this process polls the poll URL the server issued
898
+ * for it (the core holding the request); an unknown key polls
899
+ * `access + key`, which may miss the request on a multi-core platform.
900
+ */
894
901
  pollAccessRequest(keyOrPollUrl: string): Promise<any>;
895
902
  /**
896
903
  * Resolve an auth-flow polling `key` (from {@link Service.startAccessRequest})
897
- * into a working {@link Connection}. Polls the access request once; throws a
904
+ * into a working {@link Connection}. Polls the access request once (as
905
+ * {@link Service.pollAccessRequest} does with a key); throws a
898
906
  * {@link PryvError} unless the access is `ACCEPTED`.
899
907
  */
900
908
  connectFromKey(key: string): Promise<Connection>;
@@ -1018,7 +1026,7 @@ declare module 'pryv' {
1018
1026
  }>;
1019
1027
  consent?: AuthRequestConsentForm;
1020
1028
  requestingAppId: string;
1021
- returnUrl?: string | null;
1029
+ returnURL?: string | null;
1022
1030
  serviceInfo?: ServiceInfo;
1023
1031
  };
1024
1032
  ACCEPTED: {
@@ -1032,6 +1040,10 @@ declare module 'pryv' {
1032
1040
  * `connectFromKey`). Absent when the account comes from stored
1033
1041
  * credentials: page load, an account switch without sign-in, or the
1034
1042
  * return to the previous account after a switch that did not complete.
1043
+ * Also absent on the return from a sign-in by redirection (`returnURL`,
1044
+ * including `'auto#'` on a phone or tablet): that state carries
1045
+ * `apiEndpoint` with its token instead. Handle both:
1046
+ * `state.key ? connectFromKey(state.key) : new Connection(state.apiEndpoint)`.
1035
1047
  */
1036
1048
  key?: string;
1037
1049
  /** Display hint posted by the auth page for a grant on a controlled account; `accessInfo().delegation` is authoritative. */
@@ -1099,7 +1111,6 @@ declare module 'pryv' {
1099
1111
  export type AuthSettings = {
1100
1112
  spanButtonID?: string;
1101
1113
  onStateChange?: (state: StateChange<States>) => void;
1102
- returnURL?: string;
1103
1114
  /**
1104
1115
  * Account menu shown when the signed-in button is clicked (default: on).
1105
1116
  * `false` restores the plain logout confirmation.
@@ -1114,7 +1125,22 @@ declare module 'pryv' {
1114
1125
  languageCode?: string;
1115
1126
  requestedPermissions: AuthRequestedPermission[];
1116
1127
  consent?: AuthRequestConsent;
1117
- returnUrl?: string | boolean;
1128
+ /**
1129
+ * Where the sign-in happens: `'auto#'` (default, also when unset or
1130
+ * `false`) opens a popup on desktop and redirects on a phone or tablet;
1131
+ * `'self#'` always redirects and comes back to the current page; a URL
1132
+ * always redirects and comes back to that URL. Must end with `#`, `?`
1133
+ * or `&`.
1134
+ */
1135
+ returnURL?: string | false;
1136
+ /**
1137
+ * Your own auth page for this request (query parameters allowed, e.g. a
1138
+ * `username` hint or `backUrl` / `backLabel`). The platform honours it
1139
+ * only when it matches one of its `access:trustedAuthUrls` entries and
1140
+ * refuses the request otherwise; unset, the platform's default auth page
1141
+ * is used.
1142
+ */
1143
+ authUrl?: string;
1118
1144
  referer?: string;
1119
1145
  clientData?: KeyValue;
1120
1146
  deviceName?: string;
@@ -1248,9 +1274,9 @@ declare module 'pryv' {
1248
1274
  stopAuthRequest(msg: string): void;
1249
1275
  handleClick(): Promise<void>;
1250
1276
  getReturnURL(
1251
- returnURL?: string,
1277
+ returnURL?: string | false,
1252
1278
  windowLocationForTest?: string,
1253
- navigatorForTests?: string,
1279
+ navigatorForTests?: string | Navigator,
1254
1280
  ): string | boolean;
1255
1281
  /** `overrides`: auth request fields for this request only; `previous`: state to return to if an account switch does not complete. */
1256
1282
  startAuthRequest(overrides?: Partial<AuthSettings['authRequest']>, previous?: AuthStatePayload): Promise<AuthRequestResponse>;