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 +3 -1
- package/package.json +1 -1
- package/src/Auth/AuthController.js +33 -8
- package/src/Auth/ProfileStore.js +19 -1
- package/src/Auth/index.js +5 -2
- package/src/Browser/LoginButton.js +127 -17
- package/src/Connection.js +19 -13
- package/src/Service.js +27 -15
- package/src/index.d.ts +33 -7
- package/src/lib/pollUrls.js +71 -0
- package/src/utils.js +7 -1
- package/test/AuthController.test.js +111 -5
- package/test/Connection.apiOneError.test.js +105 -0
- package/test/LoginButton.test.js +3 -1
- package/test/LoginButton.urlCleanup.test.js +453 -14
- package/test/Service.accessRequest.test.js +4 -4
- package/test/Service.errorBodies.test.js +82 -0
- package/test/Service.mfa.test.js +4 -4
- package/test/Service.pollUrlByKey.test.js +114 -0
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
|
@@ -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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/Auth/ProfileStore.js
CHANGED
|
@@ -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 |
|
|
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
|
|
223
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
265
|
+
body = {
|
|
230
266
|
status: AuthStates.ERROR,
|
|
231
267
|
message: 'Cannot fetch result',
|
|
232
268
|
error: e
|
|
233
269
|
};
|
|
234
270
|
}
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
return
|
|
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
|
|
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}"
|
|
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
|
|
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
|
|
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
|
-
|
|
208
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
557
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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>;
|