pryv 3.13.0 → 3.15.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 CHANGED
@@ -131,6 +131,13 @@ Here is an implementation of the [Pryv.io authentication process](https://api.pr
131
131
  optIn: ['location'] // offered UNticked; the user opts in
132
132
  // a permission in neither list is optional and shown pre-selected
133
133
  },
134
+ // optional (core 2.0.0-rc.32+): cross-account messaging invites the
135
+ // user answers in the same sign-in (1 to 8). The ACCEPTED state then
136
+ // carries `cmcInvites`, one outcome per invite ({ acceptEventId, ... },
137
+ // { declined: true } or { reason }), a hint: the requester learns the
138
+ // truth from its own inbox. Declining a mandatory one refuses the sign-in
139
+ // (REFUSED, reasonId 'REFUSED_MANDATORY_CONSENT'). Ignored by older cores.
140
+ // cmcInvites: [{ capabilityUrl: invite.capabilityUrl, mandatory: true }],
134
141
  clientData: {
135
142
  'app-web-auth:description': {
136
143
  'type': 'note/txt', 'content': 'This is a consent message.'
@@ -607,7 +614,7 @@ The [authentication process](https://api.pryv.com/reference/#authenticate-your-a
607
614
  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
615
  7. `ERROR`: see message for more information
609
616
 
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.
617
+ 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
618
 
612
619
  ```js
613
620
  async onStateChange (state) {
@@ -732,6 +739,8 @@ For a more advanced scenario, you can check the default button implementation in
732
739
 
733
740
  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
741
 
742
+ 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.
743
+
735
744
 
736
745
  ### Running examples locally
737
746
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pryv",
3
- "version": "3.13.0",
3
+ "version": "3.15.0",
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,23 @@ 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
+ // Same for consent invites: echoed only by a core that understood them.
528
+ if (body.cmcInvites != null) envelope.cmcInvites = body.cmcInvites;
529
+ // polling by key (pollAccessRequest, connectFromKey) then reaches the
530
+ // core that holds the request
531
+ pollUrls.remember(envelope.key, envelope.poll);
529
532
  return envelope;
530
533
  }
531
534
 
532
535
  /**
533
536
  * Poll an in-progress access request once. Accepts either:
534
- * - a `key` returned by `startAccessRequest` (poll URL is built from
537
+ * - a `key` returned by `startAccessRequest` (polls the poll URL the
538
+ * server issued for it when this process started the request, else
535
539
  * `serviceInfo.access + key`)
536
540
  * - a full poll URL (use as-is — recommended, since the server-issued
537
- * URL is canonical and may include a different subdomain).
541
+ * URL is canonical and may point at a specific core: on a multi-core
542
+ * platform `access + key` can reach a core that does not know the
543
+ * request).
538
544
  *
539
545
  * Returns the raw body. Inspect `body.status` to drive the flow:
540
546
  * - `'NEED_SIGNIN'` → user has not interacted yet; keep polling.
@@ -553,8 +559,14 @@ class Service {
553
559
  }
554
560
  let pollUrl = keyOrPollUrl;
555
561
  if (!/^https?:\/\//.test(keyOrPollUrl)) {
556
- const serviceInfo = await this.info();
557
- pollUrl = serviceInfo.access + keyOrPollUrl;
562
+ // the core-specific URL the server issued, when this process started
563
+ // the request; `access + key` may reach another core on a multi-core
564
+ // platform
565
+ pollUrl = pollUrls.lookup(keyOrPollUrl);
566
+ if (pollUrl == null) {
567
+ const serviceInfo = await this.info();
568
+ pollUrl = serviceInfo.access + keyOrPollUrl;
569
+ }
558
570
  }
559
571
  const { response, body } = await utils.fetchGet(pollUrl);
560
572
  // 403 with status=REFUSED is the canonical "user declined" terminal
@@ -575,7 +587,9 @@ class Service {
575
587
  * `key` returned by the auth-flow (not the underlying token /
576
588
  * apiEndpoint), and uses this method to build a working `Connection`.
577
589
  *
578
- * The implementation polls `<access>/<key>` once; the call MUST be
590
+ * The implementation polls the request once (at the server-issued poll
591
+ * URL when this process started it, else `<access>/<key>`, which may
592
+ * miss the request on a multi-core platform); the call MUST be
579
593
  * made while the access request is still readable in the ACCEPTED
580
594
  * state. Servers keep a decided request only for a short retention
581
595
  * window after it is first polled (default 2 minutes, operator setting