pryv 3.15.0 → 3.16.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 +6 -5
- package/package.json +1 -1
- package/src/Auth/AuthController.js +70 -13
- package/src/Auth/pollOutcome.js +37 -0
- package/src/Browser/LoginButton.js +25 -8
- package/src/Connection.js +2 -2
- package/src/Service.js +9 -3
- package/src/globals.d.ts +4 -5
- package/src/index.d.ts +46 -3
- package/test/AuthController.pollOutcome.test.js +404 -0
- package/test/LoginButton.urlCleanup.test.js +67 -4
- package/test/Service.accessRequestConsent.test.js +26 -0
- package/test/typings/acceptedState.ts +26 -0
- package/test/typings.test.js +38 -0
package/README.md
CHANGED
|
@@ -607,14 +607,15 @@ getAuthorizationData () {
|
|
|
607
607
|
The [authentication process](https://api.pryv.com/reference/#authenticate-your-app) implementation on the frontend can go through the following states:
|
|
608
608
|
|
|
609
609
|
1. `LOADING`: while the visual assets are loading
|
|
610
|
-
2. `INITIALIZED`: visuals assets are loaded, or
|
|
610
|
+
2. `INITIALIZED`: visuals assets are loaded, or right after `REFUSED` (the button is back to its sign-in state)
|
|
611
611
|
3. `NEED_SIGNIN`: from the response of the [auth request](https://api.pryv.com/reference/#auth-request) through [polling](https://api.pryv.com/reference/#poll-request)
|
|
612
612
|
4. `AUTHORIZED`: When [polling](https://api.pryv.com/reference/#poll-request) concludes with **Result: Accepted**
|
|
613
|
-
5. `
|
|
614
|
-
6. `
|
|
615
|
-
7. `
|
|
613
|
+
5. `REFUSED`: when [polling](https://api.pryv.com/reference/#poll-request) concludes with **Result: Refused**; carries the auth page's `reasonId` (e.g. `'REFUSED_BY_USER'`, or `'REFUSED_MANDATORY_CONSENT'` when a mandatory consent was declined), its `message` and `serviceInfo`, and is followed by `INITIALIZED`. A refused account switch returns to the previous account instead (see `SWITCHING`)
|
|
614
|
+
6. `SIGNOUT`: with the account menu, when the user confirms "Log out", just before the client-side authorization credentials are deleted. With `menu: false`, on the click itself, before the "Log out?" question: if the user cancels, the controller re-initializes from the stored credentials (`LOADING` then `AUTHORIZED`)
|
|
615
|
+
7. `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
|
|
616
|
+
8. `ERROR`: see message for more information
|
|
616
617
|
|
|
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.
|
|
618
|
+
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), except that an `AUTHORIZED` reached through the popup hands the app `{ status, id, key, serviceInfo?, cmcInvites?, delegation? }` (credentials through `connectFromKey(key)`). `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.
|
|
618
619
|
|
|
619
620
|
```js
|
|
620
621
|
async onStateChange (state) {
|
package/package.json
CHANGED
|
@@ -9,6 +9,7 @@ const ProfileStore = require('./ProfileStore');
|
|
|
9
9
|
const handoff = require('../lib/handoff');
|
|
10
10
|
const pollUrls = require('../lib/pollUrls');
|
|
11
11
|
const PryvError = require('../lib/PryvError');
|
|
12
|
+
const { refusedState, unreadableAnswerState } = require('./pollOutcome');
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Controller for authentication flow
|
|
@@ -26,7 +27,8 @@ class AuthController {
|
|
|
26
27
|
validateSettings.call(this, settings);
|
|
27
28
|
|
|
28
29
|
this.stateChangeListeners = [];
|
|
29
|
-
// External `onStateChange` callers only see
|
|
30
|
+
// External `onStateChange` callers only see
|
|
31
|
+
// `{ status, id, key, serviceInfo?, cmcInvites?, delegation? }`
|
|
30
32
|
// on AUTHORIZED — credentials (`username`, `token`, `apiEndpoint`) stay
|
|
31
33
|
// inside the lib. Internal listeners (e.g. LoginButton, for cookie
|
|
32
34
|
// autologin) get the full unfiltered state.
|
|
@@ -142,12 +144,35 @@ class AuthController {
|
|
|
142
144
|
}
|
|
143
145
|
this.state = { status: AuthStates.SIGNOUT };
|
|
144
146
|
} else if (isInitialized.call(this)) {
|
|
145
|
-
|
|
147
|
+
// Not awaited (the click returns at once), so the rejection is handled
|
|
148
|
+
// here. A failed request is already the state (ERROR, or the previous
|
|
149
|
+
// account). A failure in the first poll round leaves NEED_SIGNIN: it
|
|
150
|
+
// becomes ERROR, as in the later rounds (a click-started request is
|
|
151
|
+
// never an account switch). Any other failure is logged rather than lost.
|
|
152
|
+
const started = this.startAuthRequest();
|
|
153
|
+
// startAuthRequest begins a new flow synchronously: this is its id
|
|
154
|
+
const flowId = this._authFlowId;
|
|
155
|
+
started.catch((e) => {
|
|
156
|
+
const status = this.state?.status;
|
|
157
|
+
if (status === AuthStates.NEED_SIGNIN && this._authFlowId === flowId) {
|
|
158
|
+
this.state = { status: AuthStates.ERROR, message: 'Error while polling for auth request', error: e };
|
|
159
|
+
} else if (status !== AuthStates.ERROR && status !== AuthStates.AUTHORIZED) {
|
|
160
|
+
console.warn('pryv: sign-in request failed', e);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
146
163
|
} else if (this.state.status === AuthStates.SWITCHING) {
|
|
147
164
|
// a switch is running; its outcome arrives as a state change
|
|
148
165
|
} else if (this.state.status === AuthStates.ERROR) {
|
|
149
166
|
// start over (stored sign-in or the sign-in button) rather than stay inert
|
|
150
|
-
|
|
167
|
+
try {
|
|
168
|
+
await this.init();
|
|
169
|
+
} catch (e) {
|
|
170
|
+
// failed before reaching a usable state: show it, as a failed request does
|
|
171
|
+
if (this.state.status === AuthStates.LOADING) {
|
|
172
|
+
this.state = { status: AuthStates.ERROR, message: 'Initializing', error: e };
|
|
173
|
+
}
|
|
174
|
+
throw e;
|
|
175
|
+
}
|
|
151
176
|
} else if (isNeedSignIn.call(this)) {
|
|
152
177
|
// reopen popup (HACK for now: set to private property to avoid self-assignment)
|
|
153
178
|
this.state = this._state;
|
|
@@ -159,7 +184,9 @@ class AuthController {
|
|
|
159
184
|
return this.state.status === AuthStates.AUTHORIZED;
|
|
160
185
|
}
|
|
161
186
|
function isInitialized () {
|
|
162
|
-
|
|
187
|
+
// REFUSED is followed by INITIALIZED in the same dispatch: a click
|
|
188
|
+
// from a listener in between is a click on the reset button
|
|
189
|
+
return this.state.status === AuthStates.INITIALIZED || this.state.status === AuthStates.REFUSED;
|
|
163
190
|
}
|
|
164
191
|
function isNeedSignIn () {
|
|
165
192
|
return this.state.status === AuthStates.NEED_SIGNIN;
|
|
@@ -426,11 +453,15 @@ class AuthController {
|
|
|
426
453
|
/** @this {AuthController} */
|
|
427
454
|
async function postAccess () {
|
|
428
455
|
try {
|
|
456
|
+
// @ts-ignore - this is bound via .call()
|
|
457
|
+
const request = Object.assign({}, this.settings.authRequest, overrides);
|
|
458
|
+
// A core refuses `actAsManagedOnly` with `actAs: 'deny'`, which a
|
|
459
|
+
// switch back to the signed-in account sends.
|
|
460
|
+
if (request.actAs === 'deny') delete request.actAsManagedOnly;
|
|
429
461
|
const { response, body } = await utils.fetchPost(
|
|
430
462
|
// @ts-ignore - this is bound via .call()
|
|
431
463
|
this.serviceInfo.access,
|
|
432
|
-
|
|
433
|
-
Object.assign({}, this.settings.authRequest, overrides)
|
|
464
|
+
request
|
|
434
465
|
);
|
|
435
466
|
if (!response.ok) {
|
|
436
467
|
// The server's message, id and status; the body stays on `response`,
|
|
@@ -464,8 +495,17 @@ class AuthController {
|
|
|
464
495
|
if (this._authFlowId !== flowId) return;
|
|
465
496
|
|
|
466
497
|
if (pollResponse.status === AuthStates.NEED_SIGNIN) {
|
|
467
|
-
//
|
|
468
|
-
|
|
498
|
+
// A later round has no caller to reject to: its failure becomes the
|
|
499
|
+
// state, as a poll that cannot reach the server does.
|
|
500
|
+
setTimeout(() => {
|
|
501
|
+
doPolling.call(this).catch((e) => {
|
|
502
|
+
if (this._authFlowId !== flowId) return;
|
|
503
|
+
const previous = this._switchPrevious;
|
|
504
|
+
this._switchPrevious = null;
|
|
505
|
+
if (previous != null) console.warn('pryv: account switch did not complete (polling failed); keeping the previous account');
|
|
506
|
+
this.state = previous ?? { status: AuthStates.ERROR, message: 'Error while polling for auth request', error: e };
|
|
507
|
+
});
|
|
508
|
+
}, this.state?.poll_rate_ms);
|
|
469
509
|
} else {
|
|
470
510
|
// Shared-secret delivery: the ACCEPTED body carries a one-time
|
|
471
511
|
// `handoff` key, not the token. Redeem it once here (caching under the
|
|
@@ -509,16 +549,26 @@ class AuthController {
|
|
|
509
549
|
(pollResponse?.error?.id ?? pollResponse?.message ?? pollResponse?.status) + '); keeping the previous account');
|
|
510
550
|
this.state = previous;
|
|
511
551
|
return;
|
|
552
|
+
} else if (pollResponse.status === AuthStates.REFUSED) {
|
|
553
|
+
// Refused on the auth page (403): tell the listeners why, then
|
|
554
|
+
// return to the sign-in button.
|
|
555
|
+
this.state = refusedState(pollResponse, this.serviceInfo);
|
|
556
|
+
// a listener started over (new request, sign-out, re-initialization)
|
|
557
|
+
// @ts-ignore - this is bound via .call()
|
|
558
|
+
if (this._authFlowId !== flowId) return;
|
|
559
|
+
this.state = { status: AuthStates.INITIALIZED, serviceInfo: this.serviceInfo };
|
|
560
|
+
return;
|
|
512
561
|
}
|
|
513
562
|
this.state = pollResponse;
|
|
514
563
|
}
|
|
515
564
|
|
|
516
565
|
async function pollAccess (pollUrl) {
|
|
517
566
|
try {
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
567
|
+
// a REFUSED answer (403) is handled by the caller like any other body
|
|
568
|
+
const { body } = await utils.fetchGet(pollUrl);
|
|
569
|
+
// unknown or expired key, a server error body, no body: ERROR, as
|
|
570
|
+
// the redirect path (never a state without a status)
|
|
571
|
+
if (body?.status == null) return unreadableAnswerState(body);
|
|
522
572
|
return body;
|
|
523
573
|
} catch (e) {
|
|
524
574
|
return { status: AuthStates.ERROR, message: 'Error while polling for auth request', error: e };
|
|
@@ -559,7 +609,8 @@ class AuthController {
|
|
|
559
609
|
|
|
560
610
|
/**
|
|
561
611
|
* Narrow the state passed to *external* `onStateChange` callers so the
|
|
562
|
-
* calling app sees only `{ status, id, key, serviceInfo
|
|
612
|
+
* calling app sees only `{ status, id, key, serviceInfo?, cmcInvites?,
|
|
613
|
+
* delegation? }` on the
|
|
563
614
|
* terminal AUTHORIZED state reached through the auth-flow polling path.
|
|
564
615
|
* `username` / `token` / `apiEndpoint` are kept inside the lib; the
|
|
565
616
|
* calling app uses `pryv.connectFromKey(key, serviceInfoUrl)` to obtain
|
|
@@ -570,6 +621,10 @@ class AuthController {
|
|
|
570
621
|
* existing pages that build a `Connection` directly from the restored
|
|
571
622
|
* state on page load keep working.
|
|
572
623
|
*
|
|
624
|
+
* `cmcInvites` (the auth page's outcome for each invite: event and access
|
|
625
|
+
* ids, or why not) and `delegation` (a display hint) carry no credential
|
|
626
|
+
* and are kept when present.
|
|
627
|
+
*
|
|
573
628
|
* Non-AUTHORIZED states pass through unchanged so error messages /
|
|
574
629
|
* loading flags / etc. still reach the listener.
|
|
575
630
|
*
|
|
@@ -586,6 +641,8 @@ function filterForExternalListener (state) {
|
|
|
586
641
|
}
|
|
587
642
|
const out = { status: state.status, id: state.id, key: state.key };
|
|
588
643
|
if (state.serviceInfo != null) out.serviceInfo = state.serviceInfo;
|
|
644
|
+
if (state.cmcInvites != null) out.cmcInvites = state.cmcInvites;
|
|
645
|
+
if (state.delegation != null) out.delegation = state.delegation;
|
|
589
646
|
return out;
|
|
590
647
|
}
|
|
591
648
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
const AuthStates = require('./AuthStates');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* States built from the answer to an auth-request poll, shared by the popup
|
|
9
|
+
* (AuthController) and the redirect (LoginButton) paths so both emit the
|
|
10
|
+
* same shapes.
|
|
11
|
+
*/
|
|
12
|
+
module.exports = {
|
|
13
|
+
refusedState,
|
|
14
|
+
unreadableAnswerState
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The REFUSED state for a refused auth request: the core's `reasonId`
|
|
19
|
+
* (e.g. `REFUSED_BY_USER`, `REFUSED_MANDATORY_CONSENT`) and `message`, with
|
|
20
|
+
* the service info. Nothing else of the answer is kept.
|
|
21
|
+
* @param {Object} body - the poll answer (`status: 'REFUSED'`)
|
|
22
|
+
* @param {Object} [serviceInfo]
|
|
23
|
+
* @returns {Object}
|
|
24
|
+
*/
|
|
25
|
+
function refusedState (body, serviceInfo) {
|
|
26
|
+
return { status: AuthStates.REFUSED, reasonId: body?.reasonId, message: body?.message, serviceInfo };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The ERROR state for a poll answer without a `status` (unknown or expired
|
|
31
|
+
* key, a server error body, no body): nothing the sign-in button can show.
|
|
32
|
+
* @param {Object} [body] - the poll answer
|
|
33
|
+
* @returns {Object}
|
|
34
|
+
*/
|
|
35
|
+
function unreadableAnswerState (body) {
|
|
36
|
+
return { status: AuthStates.ERROR, message: 'Cannot fetch result', error: body?.error ?? body };
|
|
37
|
+
}
|
|
@@ -6,6 +6,7 @@ const Cookies = require('./CookieUtils');
|
|
|
6
6
|
const AuthStates = require('../Auth/AuthStates');
|
|
7
7
|
const AuthController = require('../Auth/AuthController');
|
|
8
8
|
const ProfileStore = require('../Auth/ProfileStore');
|
|
9
|
+
const { refusedState, unreadableAnswerState } = require('../Auth/pollOutcome');
|
|
9
10
|
const Messages = require('../Auth/LoginMessages');
|
|
10
11
|
const handoff = require('../lib/handoff');
|
|
11
12
|
const pollUrls = require('../lib/pollUrls');
|
|
@@ -51,7 +52,11 @@ class LoginButton {
|
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
onClick () {
|
|
54
|
-
|
|
55
|
+
// A click handler has no caller to reject to: a failure is shown as the
|
|
56
|
+
// ERROR state; anything else is logged rather than left unhandled.
|
|
57
|
+
Promise.resolve(this.auth.handleClick()).catch((e) => {
|
|
58
|
+
if (this.auth.state?.status !== AuthStates.ERROR) console.warn('pryv: sign-in button click failed', e);
|
|
59
|
+
});
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
async onStateChange (state) {
|
|
@@ -107,6 +112,9 @@ class LoginButton {
|
|
|
107
112
|
case AuthStates.ERROR:
|
|
108
113
|
this.text = getErrorMessage(this, state.message);
|
|
109
114
|
break;
|
|
115
|
+
case AuthStates.REFUSED:
|
|
116
|
+
// followed by INITIALIZED, which resets the button
|
|
117
|
+
break;
|
|
110
118
|
default:
|
|
111
119
|
console.log('WARNING Unhandled state for Login: ' + state.status);
|
|
112
120
|
}
|
|
@@ -258,9 +266,9 @@ class LoginButton {
|
|
|
258
266
|
pollUrls.remember(key, pollUrl);
|
|
259
267
|
// the flow of this sign-in: a sign-out clears its cached credential
|
|
260
268
|
authController._authFlowKey = key;
|
|
261
|
-
let
|
|
269
|
+
let body;
|
|
262
270
|
try {
|
|
263
|
-
({
|
|
271
|
+
({ body } = await utils.fetchGet(pollUrl));
|
|
264
272
|
} catch (e) {
|
|
265
273
|
body = {
|
|
266
274
|
status: AuthStates.ERROR,
|
|
@@ -268,12 +276,21 @@ class LoginButton {
|
|
|
268
276
|
error: e
|
|
269
277
|
};
|
|
270
278
|
}
|
|
271
|
-
if (
|
|
272
|
-
//
|
|
273
|
-
|
|
274
|
-
|
|
279
|
+
if (body?.status === AuthStates.REFUSED && !signedIn) {
|
|
280
|
+
// Refused on the auth page (403): tell the listeners why, then back
|
|
281
|
+
// to the sign-in button (as the popup path).
|
|
282
|
+
const flowId = authController._authFlowId;
|
|
283
|
+
authController.state = refusedState(body, authController.serviceInfo);
|
|
284
|
+
// unless a listener started over (new request, sign-out, re-initialization)
|
|
285
|
+
if (authController._authFlowId === flowId) {
|
|
286
|
+
authController.state = { status: AuthStates.INITIALIZED, serviceInfo: authController.serviceInfo };
|
|
287
|
+
}
|
|
288
|
+
cleanUrl();
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (body?.status == null) {
|
|
275
292
|
// unknown or expired key, or no answer the button can show
|
|
276
|
-
body =
|
|
293
|
+
body = unreadableAnswerState(body);
|
|
277
294
|
}
|
|
278
295
|
// Shared-secret delivery: the ACCEPTED body carries a one-time
|
|
279
296
|
// `handoff` key, not the token. Redeem it exactly as the polling path
|
package/src/Connection.js
CHANGED
|
@@ -411,7 +411,7 @@ class Connection {
|
|
|
411
411
|
/**
|
|
412
412
|
* Create an event with attached file
|
|
413
413
|
* NODE.jS ONLY
|
|
414
|
-
* @param {
|
|
414
|
+
* @param {PryvEvent} event
|
|
415
415
|
* @param {string} filePath
|
|
416
416
|
*/
|
|
417
417
|
async createEventWithFile (event, filePath) {
|
|
@@ -458,7 +458,7 @@ class Connection {
|
|
|
458
458
|
/**
|
|
459
459
|
* Create an event with attached formData
|
|
460
460
|
* !! BROWSER ONLY
|
|
461
|
-
* @param {
|
|
461
|
+
* @param {PryvEvent} event
|
|
462
462
|
* @param {FormData} formData https://developer.mozilla.org/en-US/docs/Web/API/FormData/FormData
|
|
463
463
|
*/
|
|
464
464
|
async createEventWithFormData (event, formData) {
|
package/src/Service.js
CHANGED
|
@@ -493,9 +493,12 @@ class Service {
|
|
|
493
493
|
* @param {Object} [authRequest.clientData]
|
|
494
494
|
* @param {string} [authRequest.deviceName]
|
|
495
495
|
* @param {number} [authRequest.expireAfter]
|
|
496
|
-
* @
|
|
497
|
-
*
|
|
498
|
-
*
|
|
496
|
+
* @param {boolean} [authRequest.actAsManagedOnly] - true: the access must
|
|
497
|
+
* be granted for an account the user manages; requires `actAs: 'allow'`
|
|
498
|
+
* or a username.
|
|
499
|
+
* @returns {Promise<{ key: string, authUrl: string, poll: string, pollRateMs: number, consent?: Object, cmcInvites?: Array<Object>, actAsManagedOnly?: true }>}
|
|
500
|
+
* `consent`, `cmcInvites` and `actAsManagedOnly` are echoed back only by
|
|
501
|
+
* a core that understood them, which is how you detect support.
|
|
499
502
|
* @throws {PryvError} on non-2xx
|
|
500
503
|
*/
|
|
501
504
|
async startAccessRequest (authRequest) {
|
|
@@ -526,6 +529,9 @@ class Service {
|
|
|
526
529
|
if (body.consent != null) envelope.consent = body.consent;
|
|
527
530
|
// Same for consent invites: echoed only by a core that understood them.
|
|
528
531
|
if (body.cmcInvites != null) envelope.cmcInvites = body.cmcInvites;
|
|
532
|
+
// Echoed only by a core that understood `actAsManagedOnly`: an older core
|
|
533
|
+
// drops the field, and the auth page then behaves per `actAs` alone.
|
|
534
|
+
if (body.actAsManagedOnly === true) envelope.actAsManagedOnly = true;
|
|
529
535
|
// polling by key (pollAccessRequest, connectFromKey) then reaches the
|
|
530
536
|
// core that holds the request
|
|
531
537
|
pollUrls.remember(envelope.key, envelope.poll);
|
package/src/globals.d.ts
CHANGED
|
@@ -11,10 +11,8 @@ declare global {
|
|
|
11
11
|
type TokenAndEndpoint = PryvModule.TokenAndAPIEndpoint;
|
|
12
12
|
|
|
13
13
|
// Core classes (for JSDoc @param {Connection} etc.)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
_chunkedBatchCall?: any;
|
|
17
|
-
}
|
|
14
|
+
// `socket` comes from the @pryv/socket.io augmentation of `Connection`
|
|
15
|
+
type Connection = PryvModule.Connection;
|
|
18
16
|
type Service = PryvModule.Service;
|
|
19
17
|
type ServiceInfo = PryvModule.ServiceInfo;
|
|
20
18
|
type ServiceAssets = PryvModule.ServiceAssets;
|
|
@@ -32,7 +30,8 @@ declare global {
|
|
|
32
30
|
type AccessInfo = PryvModule.AccessInfo & { error?: PryvModule.Error };
|
|
33
31
|
type HFSeriesAddResult = PryvModule.HFSeriesAddResult;
|
|
34
32
|
type APICall = PryvModule.APICall;
|
|
35
|
-
|
|
33
|
+
// not `Event`: that name is the DOM event (lib.dom)
|
|
34
|
+
type PryvEvent = PryvModule.Event;
|
|
36
35
|
type Stream = PryvModule.Stream;
|
|
37
36
|
|
|
38
37
|
// Method call type for socket.io
|
package/src/index.d.ts
CHANGED
|
@@ -893,6 +893,11 @@ declare module 'pryv' {
|
|
|
893
893
|
consent?: AuthRequestConsentForm;
|
|
894
894
|
/** Echoed only by a core that understood `authRequest.cmcInvites`. */
|
|
895
895
|
cmcInvites?: AuthCmcInvite[];
|
|
896
|
+
/**
|
|
897
|
+
* Echoed (`true`) only by a core that understood
|
|
898
|
+
* `authRequest.actAsManagedOnly`; absent otherwise.
|
|
899
|
+
*/
|
|
900
|
+
actAsManagedOnly?: true;
|
|
896
901
|
}>;
|
|
897
902
|
/**
|
|
898
903
|
* Poll an access request once, by its full poll URL (recommended) or its
|
|
@@ -995,6 +1000,13 @@ declare module 'pryv' {
|
|
|
995
1000
|
capabilityUrl: string;
|
|
996
1001
|
mandatory?: boolean;
|
|
997
1002
|
for?: 'self' | 'target';
|
|
1003
|
+
/**
|
|
1004
|
+
* Name of the data grant the user mints when accepting the invite (1 to
|
|
1005
|
+
* 256 characters); unset, the grant takes the platform's default name.
|
|
1006
|
+
* Requires a core that supports it: an older core refuses the whole
|
|
1007
|
+
* request with `400 invalid-parameters`.
|
|
1008
|
+
*/
|
|
1009
|
+
accessName?: string;
|
|
998
1010
|
};
|
|
999
1011
|
|
|
1000
1012
|
/** An invite as the core echoes it back, with its defaults filled in. */
|
|
@@ -1002,6 +1014,8 @@ declare module 'pryv' {
|
|
|
1002
1014
|
capabilityUrl: string;
|
|
1003
1015
|
mandatory: boolean;
|
|
1004
1016
|
for: 'self' | 'target';
|
|
1017
|
+
/** Present only when the invite was sent with an `accessName`. */
|
|
1018
|
+
accessName?: string;
|
|
1005
1019
|
};
|
|
1006
1020
|
|
|
1007
1021
|
/**
|
|
@@ -1058,14 +1072,23 @@ declare module 'pryv' {
|
|
|
1058
1072
|
consent?: AuthRequestConsentForm;
|
|
1059
1073
|
/** Present only when the request carried `cmcInvites` the core understood. */
|
|
1060
1074
|
cmcInvites?: AuthCmcInvite[];
|
|
1075
|
+
/** Present only when the request carried `actAsManagedOnly: true` and the core understood it. */
|
|
1076
|
+
actAsManagedOnly?: true;
|
|
1061
1077
|
requestingAppId: string;
|
|
1062
1078
|
returnURL?: string | null;
|
|
1063
1079
|
serviceInfo?: ServiceInfo;
|
|
1064
1080
|
};
|
|
1065
1081
|
ACCEPTED: {
|
|
1066
1082
|
serviceInfo?: ServiceInfo;
|
|
1067
|
-
|
|
1068
|
-
|
|
1083
|
+
/**
|
|
1084
|
+
* Present only when the account comes from stored credentials or from
|
|
1085
|
+
* the return of a sign-in by redirection (the states without `key`).
|
|
1086
|
+
* The state an `onStateChange` listener receives after a popup sign-in
|
|
1087
|
+
* carries `key` instead: use `connectFromKey(key, serviceInfoUrl)`.
|
|
1088
|
+
*/
|
|
1089
|
+
apiEndpoint?: string;
|
|
1090
|
+
/** Present only when `apiEndpoint` is (see above). */
|
|
1091
|
+
username?: string;
|
|
1069
1092
|
/** Present on inline delivery; absent when a one-time `handoff` key is used. */
|
|
1070
1093
|
token?: string;
|
|
1071
1094
|
/**
|
|
@@ -1097,8 +1120,16 @@ declare module 'pryv' {
|
|
|
1097
1120
|
/** null: the account is chosen in the sign-in popup */
|
|
1098
1121
|
to: string | null;
|
|
1099
1122
|
};
|
|
1123
|
+
/**
|
|
1124
|
+
* The auth request was refused on the auth page; followed by
|
|
1125
|
+
* INITIALIZED (the sign-in button resets).
|
|
1126
|
+
*/
|
|
1100
1127
|
REFUSED: {
|
|
1101
|
-
|
|
1128
|
+
/**
|
|
1129
|
+
* Why, as set by the auth page, e.g. `'REFUSED_BY_USER'`,
|
|
1130
|
+
* `'REFUSED_MANDATORY_CONSENT'` or `'MANDATORY_CONSENT_FAILED'`.
|
|
1131
|
+
*/
|
|
1132
|
+
reasonId?: string;
|
|
1102
1133
|
message?: string;
|
|
1103
1134
|
serviceInfo?: ServiceInfo;
|
|
1104
1135
|
};
|
|
@@ -1125,6 +1156,7 @@ declare module 'pryv' {
|
|
|
1125
1156
|
requestedPermissions: AuthRequestedPermission[];
|
|
1126
1157
|
consent?: AuthRequestConsentForm;
|
|
1127
1158
|
cmcInvites?: AuthCmcInvite[];
|
|
1159
|
+
actAsManagedOnly?: true;
|
|
1128
1160
|
lang?: string;
|
|
1129
1161
|
returnURL?: string;
|
|
1130
1162
|
clientData?: KeyValue;
|
|
@@ -1202,6 +1234,17 @@ declare module 'pryv' {
|
|
|
1202
1234
|
* 'deny', or the username to preselect.
|
|
1203
1235
|
*/
|
|
1204
1236
|
actAs?: 'allow' | 'deny' | string;
|
|
1237
|
+
/**
|
|
1238
|
+
* `true`: the access must be granted for an account the user manages
|
|
1239
|
+
* through account delegation, never for the signed-in account itself.
|
|
1240
|
+
* Requires `actAs: 'allow'` or a username (a core refuses it with
|
|
1241
|
+
* `actAs: 'deny'` or without `actAs`). The sign-in button leaves it out
|
|
1242
|
+
* of any request it sends with `actAs: 'deny'` (a switch back to the
|
|
1243
|
+
* signed-in account sends it); `Service.startAccessRequest` sends it as given.
|
|
1244
|
+
* Enforced by the auth page, not the core. An older core drops the
|
|
1245
|
+
* field and does not echo it (see `Service.startAccessRequest`).
|
|
1246
|
+
*/
|
|
1247
|
+
actAsManagedOnly?: boolean;
|
|
1205
1248
|
};
|
|
1206
1249
|
};
|
|
1207
1250
|
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
/* global describe, it, beforeEach, afterEach, expect */
|
|
6
|
+
|
|
7
|
+
const AuthController = require('../src/Auth/AuthController');
|
|
8
|
+
const AuthStates = require('../src/Auth/AuthStates');
|
|
9
|
+
const utils = require('../src/utils');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* [APLX] What the popup (polling) path delivers to the app's onStateChange
|
|
13
|
+
* when the auth request ends. No platform needed: the access POST and the
|
|
14
|
+
* poll GET are stubbed.
|
|
15
|
+
*/
|
|
16
|
+
describe('[APLX] AuthController popup poll outcome', function () {
|
|
17
|
+
const SERVICE_INFO = { access: 'https://reg.test.local/access/', name: 'Test' };
|
|
18
|
+
const POLL = 'https://core-a.test.local/reg/access/plk1';
|
|
19
|
+
let saved;
|
|
20
|
+
let pollBody;
|
|
21
|
+
let pollStatus;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
saved = { fetchPost: utils.fetchPost, fetchGet: utils.fetchGet };
|
|
25
|
+
utils.fetchPost = async () => ({
|
|
26
|
+
response: { ok: true, status: 201 },
|
|
27
|
+
body: { status: 'NEED_SIGNIN', key: 'plk1', poll: POLL, poll_rate_ms: 5, authUrl: 'https://ui.test.local/auth?key=plk1' }
|
|
28
|
+
});
|
|
29
|
+
utils.fetchGet = async () => ({ response: { ok: pollStatus < 400, status: pollStatus }, body: pollBody });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
utils.fetchPost = saved.fetchPost;
|
|
34
|
+
utils.fetchGet = saved.fetchGet;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** A controller past init(): INITIALIZED, as the sign-in button shows it. */
|
|
38
|
+
function makeAuth (onStateChange) {
|
|
39
|
+
const auth = new AuthController({
|
|
40
|
+
onStateChange,
|
|
41
|
+
authRequest: { requestingAppId: 'apl-app', requestedPermissions: [], credentialHandoff: 'inline' }
|
|
42
|
+
}, { infoSync: () => SERVICE_INFO });
|
|
43
|
+
auth.serviceInfo = SERVICE_INFO;
|
|
44
|
+
auth.state = { status: AuthStates.INITIALIZED, serviceInfo: SERVICE_INFO };
|
|
45
|
+
return auth;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
it('[APL1] a refused request emits REFUSED with the reasonId and message, then INITIALIZED', async () => {
|
|
49
|
+
pollStatus = 403;
|
|
50
|
+
pollBody = { status: 'REFUSED', reasonId: 'REFUSED_MANDATORY_CONSENT', message: 'A mandatory consent was declined' };
|
|
51
|
+
const seen = [];
|
|
52
|
+
const auth = makeAuth((s) => seen.push(s));
|
|
53
|
+
await auth.startAuthRequest();
|
|
54
|
+
|
|
55
|
+
expect(seen.map((s) => s.status)).to.deep.equal(['INITIALIZED', 'NEED_SIGNIN', 'REFUSED', 'INITIALIZED']);
|
|
56
|
+
// the core's answer, nothing more (no key of the refused request)
|
|
57
|
+
expect(seen[2]).to.deep.equal({
|
|
58
|
+
status: 'REFUSED',
|
|
59
|
+
id: 'REFUSED',
|
|
60
|
+
reasonId: 'REFUSED_MANDATORY_CONSENT',
|
|
61
|
+
message: 'A mandatory consent was declined',
|
|
62
|
+
serviceInfo: SERVICE_INFO
|
|
63
|
+
});
|
|
64
|
+
expect(seen[3]).to.deep.equal({ status: 'INITIALIZED', id: 'INITIALIZED', serviceInfo: SERVICE_INFO });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('[APL2] after a refusal the button is back to its initial state: a click starts a new request', async () => {
|
|
68
|
+
pollStatus = 403;
|
|
69
|
+
pollBody = { status: 'REFUSED', reasonId: 'REFUSED_BY_USER', message: 'access refused by user' };
|
|
70
|
+
const auth = makeAuth(() => {});
|
|
71
|
+
await auth.startAuthRequest();
|
|
72
|
+
expect(auth.state.status).to.equal(AuthStates.INITIALIZED);
|
|
73
|
+
|
|
74
|
+
let started = 0;
|
|
75
|
+
auth.startAuthRequest = async () => { started++; };
|
|
76
|
+
await auth.handleClick();
|
|
77
|
+
expect(started).to.equal(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('[APL3] a listener that starts over on REFUSED is not overridden by the reset', async () => {
|
|
81
|
+
pollStatus = 403;
|
|
82
|
+
pollBody = { status: 'REFUSED', reasonId: 'REFUSED_BY_USER' };
|
|
83
|
+
let auth = null;
|
|
84
|
+
auth = makeAuth((s) => {
|
|
85
|
+
if (s.status === 'REFUSED') auth.stopAuthRequest('stopped by the app');
|
|
86
|
+
});
|
|
87
|
+
await auth.startAuthRequest();
|
|
88
|
+
expect(auth.state.status).to.equal(AuthStates.ERROR);
|
|
89
|
+
expect(auth.state.message).to.equal('stopped by the app');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('[APL4] an account switch that is refused keeps the previous account', async () => {
|
|
93
|
+
pollStatus = 403;
|
|
94
|
+
pollBody = { status: 'REFUSED', reasonId: 'REFUSED_BY_USER' };
|
|
95
|
+
const seen = [];
|
|
96
|
+
const auth = makeAuth((s) => seen.push(s.status));
|
|
97
|
+
const previous = { status: AuthStates.AUTHORIZED, username: 'bob', apiEndpoint: 'https://tb@bob.test.local/' };
|
|
98
|
+
const warn = console.warn;
|
|
99
|
+
console.warn = () => {};
|
|
100
|
+
try {
|
|
101
|
+
await auth.startAuthRequest({ actAs: 'allow' }, previous);
|
|
102
|
+
} finally {
|
|
103
|
+
console.warn = warn;
|
|
104
|
+
}
|
|
105
|
+
expect(auth.state.status).to.equal(AuthStates.AUTHORIZED);
|
|
106
|
+
expect(auth.state.username).to.equal('bob');
|
|
107
|
+
expect(seen.slice(-2)).to.deep.equal(['NEED_SIGNIN', 'ACCEPTED']);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('[APL5] ACCEPTED keeps cmcInvites and delegation for the app; the credentials stay inside', async () => {
|
|
111
|
+
pollStatus = 200;
|
|
112
|
+
const cmcInvites = [
|
|
113
|
+
{ acceptEventId: 'evt-1', dataGrantAccessId: 'acc-1' },
|
|
114
|
+
{ declined: true },
|
|
115
|
+
{ acceptEventId: 'evt-3', acceptedFor: 'self' }
|
|
116
|
+
];
|
|
117
|
+
const delegation = { isDelegatedAccess: true, controlledUsername: 'kim', delegate: { username: 'alice' } };
|
|
118
|
+
pollBody = { status: 'ACCEPTED', username: 'kim', token: 'tok-kim', apiEndpoint: 'https://tok-kim@kim.test.local/', cmcInvites, delegation };
|
|
119
|
+
const seen = [];
|
|
120
|
+
const auth = makeAuth((s) => seen.push(s));
|
|
121
|
+
await auth.startAuthRequest();
|
|
122
|
+
|
|
123
|
+
const accepted = seen[seen.length - 1];
|
|
124
|
+
expect(accepted).to.deep.equal({ status: 'ACCEPTED', id: 'ACCEPTED', key: 'plk1', cmcInvites, delegation });
|
|
125
|
+
expect(auth.state.token).to.equal('tok-kim');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('[APL7] a switch back to the signed-in account sends no actAsManagedOnly (it sends actAs: \'deny\')', async () => {
|
|
129
|
+
pollStatus = 403;
|
|
130
|
+
pollBody = { status: 'REFUSED', reasonId: 'REFUSED_BY_USER' };
|
|
131
|
+
const posted = [];
|
|
132
|
+
utils.fetchPost = async (url, body) => {
|
|
133
|
+
posted.push(body);
|
|
134
|
+
return {
|
|
135
|
+
response: { ok: true, status: 201 },
|
|
136
|
+
body: { status: 'NEED_SIGNIN', key: 'plk1', poll: POLL, poll_rate_ms: 5, authUrl: 'https://ui.test.local/auth?key=plk1' }
|
|
137
|
+
};
|
|
138
|
+
};
|
|
139
|
+
const auth = new AuthController({
|
|
140
|
+
onStateChange: () => {},
|
|
141
|
+
authRequest: { requestingAppId: 'apl-app', requestedPermissions: [], credentialHandoff: 'inline', actAs: 'allow', actAsManagedOnly: true }
|
|
142
|
+
}, { infoSync: () => SERVICE_INFO });
|
|
143
|
+
auth.serviceInfo = SERVICE_INFO;
|
|
144
|
+
auth.state = { status: AuthStates.AUTHORIZED, username: 'kim', apiEndpoint: 'https://tk@kim.test.local/', profile: { username: 'kim', actingAs: { username: 'kim', delegate: 'alice' } } };
|
|
145
|
+
const warn = console.warn;
|
|
146
|
+
console.warn = () => {};
|
|
147
|
+
try {
|
|
148
|
+
await auth.switchTo(null);
|
|
149
|
+
// the other requests keep it: one more account, or a managed account by name
|
|
150
|
+
await auth.addAccount();
|
|
151
|
+
await auth.startAuthRequest({ actAs: 'kim' });
|
|
152
|
+
} finally {
|
|
153
|
+
console.warn = warn;
|
|
154
|
+
}
|
|
155
|
+
expect(posted).to.have.lengthOf(3);
|
|
156
|
+
expect(posted[0].actAs).to.equal('deny');
|
|
157
|
+
expect(posted[0]).to.not.have.property('actAsManagedOnly');
|
|
158
|
+
expect(posted[1].actAs).to.equal('allow');
|
|
159
|
+
expect(posted[1].actAsManagedOnly).to.equal(true);
|
|
160
|
+
expect(posted[2].actAs).to.equal('kim');
|
|
161
|
+
expect(posted[2].actAsManagedOnly).to.equal(true);
|
|
162
|
+
// the app's settings are left untouched
|
|
163
|
+
expect(auth.settings.authRequest.actAsManagedOnly).to.equal(true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
/** Runs `fn`, then collects the promise rejections nobody handled. */
|
|
167
|
+
async function unhandledDuring (fn) {
|
|
168
|
+
const saved = process.listeners('unhandledRejection');
|
|
169
|
+
process.removeAllListeners('unhandledRejection');
|
|
170
|
+
const seen = [];
|
|
171
|
+
const onUnhandled = (reason) => seen.push(reason);
|
|
172
|
+
process.on('unhandledRejection', onUnhandled);
|
|
173
|
+
try {
|
|
174
|
+
await fn();
|
|
175
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
176
|
+
} finally {
|
|
177
|
+
process.removeListener('unhandledRejection', onUnhandled);
|
|
178
|
+
saved.forEach((l) => process.on('unhandledRejection', l));
|
|
179
|
+
}
|
|
180
|
+
return seen;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
it('[APL8] a click whose access request fails shows ERROR and leaves no unhandled rejection', async () => {
|
|
184
|
+
utils.fetchPost = async () => ({
|
|
185
|
+
response: { ok: false, status: 500 },
|
|
186
|
+
body: { error: { id: 'unexpected-error', message: 'boom' } }
|
|
187
|
+
});
|
|
188
|
+
const seen = [];
|
|
189
|
+
const auth = makeAuth((s) => seen.push(s.status));
|
|
190
|
+
const unhandled = await unhandledDuring(() => auth.handleClick());
|
|
191
|
+
expect(unhandled).to.deep.equal([]);
|
|
192
|
+
expect(auth.state.status).to.equal(AuthStates.ERROR);
|
|
193
|
+
expect(auth.state.message).to.equal('Requesting access');
|
|
194
|
+
expect(seen).to.deep.equal(['INITIALIZED', 'ERROR']);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
/** Runs `fn` with `console[method]` captured; returns the captured calls. */
|
|
198
|
+
async function captured (method, fn) {
|
|
199
|
+
const original = console[method];
|
|
200
|
+
const calls = [];
|
|
201
|
+
console[method] = (...args) => calls.push(args);
|
|
202
|
+
try {
|
|
203
|
+
await fn();
|
|
204
|
+
} finally {
|
|
205
|
+
console[method] = original;
|
|
206
|
+
}
|
|
207
|
+
return calls;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** A controller on ERROR whose re-initialization fails with `failure`. */
|
|
211
|
+
function failingReinit (failure) {
|
|
212
|
+
const loginButton = {
|
|
213
|
+
onStateChange () {},
|
|
214
|
+
getAuthorizationData () { throw failure; }
|
|
215
|
+
};
|
|
216
|
+
const auth = new AuthController({
|
|
217
|
+
authRequest: { requestingAppId: 'apl-app', requestedPermissions: [], credentialHandoff: 'inline' }
|
|
218
|
+
}, { infoSync: () => SERVICE_INFO, assets: async () => ({}) }, loginButton);
|
|
219
|
+
auth.state = { status: AuthStates.ERROR, message: 'Requesting access' };
|
|
220
|
+
return auth;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
it('[APL9] a click on ERROR whose re-initialization fails shows ERROR and rejects for an awaiting caller', async () => {
|
|
224
|
+
const failure = new Error('storage unavailable');
|
|
225
|
+
const auth = failingReinit(failure);
|
|
226
|
+
let rejected = null;
|
|
227
|
+
await auth.handleClick().catch((e) => { rejected = e; });
|
|
228
|
+
expect(rejected).to.equal(failure);
|
|
229
|
+
expect(auth.state.status).to.equal(AuthStates.ERROR);
|
|
230
|
+
expect(auth.state.message).to.equal('Initializing');
|
|
231
|
+
expect(auth.state.error).to.equal(failure);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('[APL10] the sign-in button click leaves no unhandled rejection and no warning when the failure is shown as ERROR', async () => {
|
|
235
|
+
const LoginButton = require('../src/Browser/LoginButton');
|
|
236
|
+
const auth = failingReinit(new Error('storage unavailable'));
|
|
237
|
+
let unhandled;
|
|
238
|
+
const warned = await captured('warn', async () => {
|
|
239
|
+
unhandled = await unhandledDuring(() => LoginButton.prototype.onClick.call({ auth }));
|
|
240
|
+
});
|
|
241
|
+
expect(unhandled).to.deep.equal([]);
|
|
242
|
+
expect(warned).to.deep.equal([]);
|
|
243
|
+
expect(auth.state.status).to.equal(AuthStates.ERROR);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('[APL11] the sign-in button click logs any other failure instead of leaving it unhandled', async () => {
|
|
247
|
+
const LoginButton = require('../src/Browser/LoginButton');
|
|
248
|
+
const failure = new Error('unexpected');
|
|
249
|
+
const auth = { state: { status: AuthStates.INITIALIZED }, handleClick: async () => { throw failure; } };
|
|
250
|
+
let unhandled;
|
|
251
|
+
const warned = await captured('warn', async () => {
|
|
252
|
+
unhandled = await unhandledDuring(() => LoginButton.prototype.onClick.call({ auth }));
|
|
253
|
+
});
|
|
254
|
+
expect(unhandled).to.deep.equal([]);
|
|
255
|
+
expect(warned).to.have.lengthOf(1);
|
|
256
|
+
expect(warned[0][1]).to.equal(failure);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('[APL12] a click whose request fails unexpectedly (state neither ERROR nor the previous account) is logged', async () => {
|
|
260
|
+
const failure = new Error('unexpected');
|
|
261
|
+
const NEED_SIGNIN = { status: AuthStates.NEED_SIGNIN, key: 'k', poll: POLL, poll_rate_ms: 5 };
|
|
262
|
+
const outcomes = [
|
|
263
|
+
{ set: null, warns: 1, ends: AuthStates.INITIALIZED }, // nothing shows it
|
|
264
|
+
{ set: { status: AuthStates.ERROR, message: 'Requesting access' }, warns: 0, ends: AuthStates.ERROR },
|
|
265
|
+
{ set: { status: AuthStates.AUTHORIZED, username: 'bob', apiEndpoint: 'https://tb@bob.test.local/' }, warns: 0, ends: AuthStates.AUTHORIZED },
|
|
266
|
+
// the request failed while polling: shown as ERROR
|
|
267
|
+
{ set: NEED_SIGNIN, warns: 0, ends: AuthStates.ERROR },
|
|
268
|
+
// a newer request is the one waiting for the sign-in: left alone
|
|
269
|
+
{ set: NEED_SIGNIN, newer: true, warns: 1, ends: AuthStates.NEED_SIGNIN }
|
|
270
|
+
];
|
|
271
|
+
for (const outcome of outcomes) {
|
|
272
|
+
const auth = makeAuth(() => {});
|
|
273
|
+
auth.startAuthRequest = async () => {
|
|
274
|
+
await null; // the click has read the flow id by now
|
|
275
|
+
if (outcome.newer) auth._authFlowId++;
|
|
276
|
+
if (outcome.set != null) auth.state = { ...outcome.set };
|
|
277
|
+
throw failure;
|
|
278
|
+
};
|
|
279
|
+
let unhandled;
|
|
280
|
+
const label = (outcome.set?.status ?? 'INITIALIZED') + (outcome.newer ? ' (newer)' : '');
|
|
281
|
+
const warned = await captured('warn', async () => {
|
|
282
|
+
unhandled = await unhandledDuring(() => auth.handleClick());
|
|
283
|
+
});
|
|
284
|
+
expect(unhandled).to.deep.equal([]);
|
|
285
|
+
expect(warned.length, 'warnings with ' + label).to.equal(outcome.warns);
|
|
286
|
+
if (outcome.warns > 0) expect(warned[0][1]).to.equal(failure);
|
|
287
|
+
expect(auth.state.status, 'state with ' + label).to.equal(outcome.ends);
|
|
288
|
+
if (outcome.ends === AuthStates.ERROR && outcome.set === NEED_SIGNIN) {
|
|
289
|
+
expect(auth.state.message).to.equal('Error while polling for auth request');
|
|
290
|
+
expect(auth.state.error).to.equal(failure);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('[APL16] a click whose first poll round fails ends in ERROR, as the later rounds do', async () => {
|
|
296
|
+
const handoff = require('../src/lib/handoff');
|
|
297
|
+
const failure = new Error('unexpected');
|
|
298
|
+
const isHandoffBody = handoff.isHandoffBody;
|
|
299
|
+
pollStatus = 200;
|
|
300
|
+
pollBody = { status: 'ACCEPTED', username: 'alice', apiEndpoint: 'https://tok@alice.test.local/' };
|
|
301
|
+
// AuthController calls handoff.isHandoffBody on the module object at call
|
|
302
|
+
// time, so replacing the property makes the round throw after the answer.
|
|
303
|
+
handoff.isHandoffBody = () => { throw failure; };
|
|
304
|
+
const seen = [];
|
|
305
|
+
const auth = makeAuth((s) => seen.push(s.status));
|
|
306
|
+
let unhandled;
|
|
307
|
+
let warned;
|
|
308
|
+
try {
|
|
309
|
+
warned = await captured('warn', async () => {
|
|
310
|
+
unhandled = await unhandledDuring(() => auth.handleClick());
|
|
311
|
+
});
|
|
312
|
+
} finally {
|
|
313
|
+
handoff.isHandoffBody = isHandoffBody;
|
|
314
|
+
}
|
|
315
|
+
expect(unhandled).to.deep.equal([]);
|
|
316
|
+
expect(warned).to.deep.equal([]);
|
|
317
|
+
expect(seen).to.deep.equal(['INITIALIZED', 'NEED_SIGNIN', 'ERROR']);
|
|
318
|
+
expect(auth.state.message).to.equal('Error while polling for auth request');
|
|
319
|
+
expect(auth.state.error).to.equal(failure);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('[APL13] a click from a listener on REFUSED starts a new request (no "Unhandled action", no reset over it)', async () => {
|
|
323
|
+
pollStatus = 403;
|
|
324
|
+
pollBody = { status: 'REFUSED', reasonId: 'REFUSED_BY_USER' };
|
|
325
|
+
const seen = [];
|
|
326
|
+
let auth = null;
|
|
327
|
+
let clicked = false;
|
|
328
|
+
auth = makeAuth((s) => {
|
|
329
|
+
seen.push(s.status);
|
|
330
|
+
if (s.status === 'REFUSED' && !clicked) {
|
|
331
|
+
clicked = true;
|
|
332
|
+
// the new request is accepted
|
|
333
|
+
pollStatus = 200;
|
|
334
|
+
pollBody = { status: 'ACCEPTED', username: 'alice', token: 'tok', apiEndpoint: 'https://tok@alice.test.local/' };
|
|
335
|
+
auth.handleClick();
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
const logged = await captured('log', async () => {
|
|
339
|
+
await auth.startAuthRequest();
|
|
340
|
+
for (let i = 0; i < 50 && auth.state.status !== AuthStates.AUTHORIZED; i++) {
|
|
341
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
expect(logged.filter((args) => String(args[0]).includes('Unhandled action'))).to.deep.equal([]);
|
|
345
|
+
expect(seen).to.deep.equal(['INITIALIZED', 'NEED_SIGNIN', 'REFUSED', 'NEED_SIGNIN', 'ACCEPTED']);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('[APL14] a later poll round that fails ends in ERROR, not in an unhandled rejection', async () => {
|
|
349
|
+
const handoff = require('../src/lib/handoff');
|
|
350
|
+
const failure = new Error('unexpected');
|
|
351
|
+
const isHandoffBody = handoff.isHandoffBody;
|
|
352
|
+
let polls = 0;
|
|
353
|
+
utils.fetchGet = async () => {
|
|
354
|
+
polls++;
|
|
355
|
+
return polls === 1
|
|
356
|
+
? { response: { ok: true, status: 201 }, body: { status: 'NEED_SIGNIN' } }
|
|
357
|
+
: { response: { ok: true, status: 200 }, body: { status: 'ACCEPTED', username: 'alice', apiEndpoint: 'https://tok@alice.test.local/' } };
|
|
358
|
+
};
|
|
359
|
+
// AuthController calls handoff.isHandoffBody on the module object at call
|
|
360
|
+
// time, so replacing the property makes the second round throw after the answer.
|
|
361
|
+
handoff.isHandoffBody = () => { throw failure; };
|
|
362
|
+
const seen = [];
|
|
363
|
+
const auth = makeAuth((s) => seen.push(s.status));
|
|
364
|
+
let unhandled;
|
|
365
|
+
try {
|
|
366
|
+
unhandled = await unhandledDuring(async () => {
|
|
367
|
+
await auth.startAuthRequest();
|
|
368
|
+
for (let i = 0; i < 50 && auth.state.status === AuthStates.NEED_SIGNIN; i++) {
|
|
369
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
} finally {
|
|
373
|
+
handoff.isHandoffBody = isHandoffBody;
|
|
374
|
+
}
|
|
375
|
+
expect(polls).to.equal(2);
|
|
376
|
+
expect(unhandled).to.deep.equal([]);
|
|
377
|
+
expect(auth.state.status).to.equal(AuthStates.ERROR);
|
|
378
|
+
expect(auth.state.message).to.equal('Error while polling for auth request');
|
|
379
|
+
expect(auth.state.error).to.equal(failure);
|
|
380
|
+
expect(seen).to.deep.equal(['INITIALIZED', 'NEED_SIGNIN', 'ERROR']);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('[APL15] a poll answer without a status (unknown key, error body, no body) ends in ERROR \'Cannot fetch result\'', async () => {
|
|
384
|
+
for (const [status, body] of [[404, { error: { id: 'unknown-access-key', message: 'Unknown key' } }], [502, null]]) {
|
|
385
|
+
pollStatus = status;
|
|
386
|
+
pollBody = body;
|
|
387
|
+
const seen = [];
|
|
388
|
+
const auth = makeAuth((s) => seen.push(s.status));
|
|
389
|
+
await auth.startAuthRequest();
|
|
390
|
+
expect(seen, 'states for HTTP ' + status).to.deep.equal(['INITIALIZED', 'NEED_SIGNIN', 'ERROR']);
|
|
391
|
+
expect(auth.state.message).to.equal('Cannot fetch result');
|
|
392
|
+
expect(auth.state.error).to.deep.equal(body?.error ?? null);
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('[APL6] ACCEPTED without invites nor delegation stays { status, id, key }', async () => {
|
|
397
|
+
pollStatus = 200;
|
|
398
|
+
pollBody = { status: 'ACCEPTED', username: 'alice', token: 'tok', apiEndpoint: 'https://tok@alice.test.local/' };
|
|
399
|
+
const seen = [];
|
|
400
|
+
const auth = makeAuth((s) => seen.push(s));
|
|
401
|
+
await auth.startAuthRequest();
|
|
402
|
+
expect(seen[seen.length - 1]).to.deep.equal({ status: 'ACCEPTED', id: 'ACCEPTED', key: 'plk1' });
|
|
403
|
+
});
|
|
404
|
+
});
|
|
@@ -234,13 +234,24 @@ describe('[LBRH] LoginButton redirect return with credential hand-off', function
|
|
|
234
234
|
await loginBtn.deleteAuthorizationData();
|
|
235
235
|
});
|
|
236
236
|
|
|
237
|
-
it('[LBH5] a refused request returns to the sign-in button; an unknown key is an ERROR', async () => {
|
|
238
|
-
const
|
|
237
|
+
it('[LBH5] a refused request emits REFUSED (reasonId, message) then returns to the sign-in button; an unknown key is an ERROR', async () => {
|
|
238
|
+
const serviceInfo = { access: 'https://reg.test.local/access/' };
|
|
239
|
+
const states = [];
|
|
240
|
+
const authController = {
|
|
241
|
+
serviceInfo,
|
|
242
|
+
get state () { return states[states.length - 1]; },
|
|
243
|
+
set state (s) { states.push(s); }
|
|
244
|
+
};
|
|
239
245
|
f.setPage('http://localhost/app?prYvpoll=https%3A%2F%2Freg.test.local%2Faccess%2Fflowkey5');
|
|
240
246
|
seedFlow('flowkey5', 'https://reg.test.local/access/flowkey5');
|
|
241
|
-
f.poll({ status: 'REFUSED',
|
|
247
|
+
f.poll({ status: 'REFUSED', reasonId: 'REFUSED_MANDATORY_CONSENT', message: 'A mandatory consent was declined', other: 'x' }, { status: 403 });
|
|
242
248
|
await LoginButton.prototype.finishAuthProcessAfterRedirection.call(THIS, authController);
|
|
243
|
-
expect(
|
|
249
|
+
expect(states).to.deep.equal([
|
|
250
|
+
{ status: 'REFUSED', reasonId: 'REFUSED_MANDATORY_CONSENT', message: 'A mandatory consent was declined', serviceInfo },
|
|
251
|
+
{ status: 'INITIALIZED', serviceInfo }
|
|
252
|
+
]);
|
|
253
|
+
expect(global.window.location.href).to.equal('http://localhost/app');
|
|
254
|
+
states.length = 0;
|
|
244
255
|
|
|
245
256
|
f.setPage('http://localhost/app?prYvpoll=https%3A%2F%2Freg.test.local%2Faccess%2Fflowkey6');
|
|
246
257
|
seedFlow('flowkey6', 'https://reg.test.local/access/flowkey6');
|
|
@@ -250,6 +261,58 @@ describe('[LBRH] LoginButton redirect return with credential hand-off', function
|
|
|
250
261
|
expect(authController.state.error).to.eql({ id: 'unknown-access-key' });
|
|
251
262
|
});
|
|
252
263
|
|
|
264
|
+
it('[LBH7] end to end: a refused redirect return reaches the app as REFUSED, then the button resets to INITIALIZED', async () => {
|
|
265
|
+
f.setPage('http://localhost/app?pryvKey=flowkey7');
|
|
266
|
+
seedFlow('flowkey7', 'https://reg.test.local/access/flowkey7', 'pryv-libjs-lbh7-app');
|
|
267
|
+
f.poll({ status: 'REFUSED', reasonId: 'REFUSED_BY_USER', message: 'access refused by user' }, { status: 403 });
|
|
268
|
+
const external = [];
|
|
269
|
+
const serviceInfo = { access: 'https://reg.test.local/access/', name: 'Test' };
|
|
270
|
+
const loginBtn = new LoginButton({
|
|
271
|
+
onStateChange: (s) => external.push(s),
|
|
272
|
+
authRequest: { requestingAppId: 'lbh7-app', requestedPermissions: [], returnURL: 'self#' }
|
|
273
|
+
}, fakeService(serviceInfo));
|
|
274
|
+
const warnings = [];
|
|
275
|
+
const log = console.log;
|
|
276
|
+
console.log = (...args) => { warnings.push(args.join(' ')); };
|
|
277
|
+
try {
|
|
278
|
+
await loginBtn.init();
|
|
279
|
+
} finally {
|
|
280
|
+
console.log = log;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const refused = external.filter((s) => s.status === 'REFUSED');
|
|
284
|
+
expect(refused).to.have.length(1);
|
|
285
|
+
expect(refused[0]).to.deep.equal({ status: 'REFUSED', id: 'REFUSED', reasonId: 'REFUSED_BY_USER', message: 'access refused by user', serviceInfo });
|
|
286
|
+
expect(external[external.length - 1].status).to.equal('INITIALIZED');
|
|
287
|
+
expect(external.indexOf(refused[0])).to.equal(external.length - 2);
|
|
288
|
+
// the button is back to its sign-in label, ready for a new request
|
|
289
|
+
expect(loginBtn.auth.state.status).to.equal('INITIALIZED');
|
|
290
|
+
expect(loginBtn.text).to.equal(loginBtn.messages.LOGIN + ': Test');
|
|
291
|
+
expect(warnings.filter((w) => w.includes('Unhandled state'))).to.deep.equal([]);
|
|
292
|
+
expect(loginBtn.getAuthorizationData()).to.equal(undefined);
|
|
293
|
+
expect(global.window.location.href).to.equal('http://localhost/app');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('[LBH8] a listener that starts over on a refused redirect return is not overridden by the reset', async () => {
|
|
297
|
+
f.setPage('http://localhost/app?pryvKey=flowkey8');
|
|
298
|
+
seedFlow('flowkey8', 'https://reg.test.local/access/flowkey8', 'pryv-libjs-lbh8-app');
|
|
299
|
+
f.poll({ status: 'REFUSED', reasonId: 'REFUSED_BY_USER' }, { status: 403 });
|
|
300
|
+
const external = [];
|
|
301
|
+
let loginBtn = null;
|
|
302
|
+
loginBtn = new LoginButton({
|
|
303
|
+
onStateChange: (s) => {
|
|
304
|
+
external.push(s.status);
|
|
305
|
+
if (s.status === 'REFUSED') loginBtn.auth.stopAuthRequest('stopped by the app');
|
|
306
|
+
},
|
|
307
|
+
authRequest: { requestingAppId: 'lbh8-app', requestedPermissions: [], returnURL: 'self#' }
|
|
308
|
+
}, fakeService({ access: 'https://reg.test.local/access/', name: 'Test' }));
|
|
309
|
+
await loginBtn.init();
|
|
310
|
+
|
|
311
|
+
expect(loginBtn.auth.state.status).to.equal('ERROR');
|
|
312
|
+
expect(loginBtn.auth.state.message).to.equal('stopped by the app');
|
|
313
|
+
expect(external.slice(-2)).to.deep.equal(['REFUSED', 'ERROR']);
|
|
314
|
+
});
|
|
315
|
+
|
|
253
316
|
it('[LBH3] a failed redemption ends in ERROR, never a token-less AUTHORIZED', async () => {
|
|
254
317
|
f.setPage('http://localhost/app?prYvpoll=https%3A%2F%2Freg.test.local%2Faccess%2Fflowkey3');
|
|
255
318
|
seedFlow('flowkey3', 'https://reg.test.local/access/flowkey3');
|
|
@@ -124,4 +124,30 @@ describe('[ARQC] auth-request consent sidecar', function () {
|
|
|
124
124
|
const envOld = await older.startAccessRequest({ ...REQUEST, cmcInvites: invites });
|
|
125
125
|
expect(envOld).to.not.have.property('cmcInvites');
|
|
126
126
|
});
|
|
127
|
+
|
|
128
|
+
it('[SARM1] posts actAsManagedOnly and invite accessName verbatim; returns the actAsManagedOnly echo only when a core sends true', async function () {
|
|
129
|
+
const invites = [{ capabilityUrl: 'https://cap@doctor.test.local/', accessName: 'Dr Who data' }];
|
|
130
|
+
const request = { ...REQUEST, actAs: 'allow', actAsManagedOnly: true, cmcInvites: invites };
|
|
131
|
+
stubFetch({ key: 'k5', authUrl: 'https://auth/', poll: 'https://poll/k5', poll_rate_ms: 1000, actAsManagedOnly: true });
|
|
132
|
+
const service = new pryv.Service('https://reg.test.local/service/info');
|
|
133
|
+
const env = await service.startAccessRequest(request);
|
|
134
|
+
expect(posted[0].actAsManagedOnly).to.equal(true);
|
|
135
|
+
expect(posted[0].actAs).to.equal('allow');
|
|
136
|
+
expect(posted[0].cmcInvites).to.deep.equal(invites);
|
|
137
|
+
expect(env.actAsManagedOnly).to.equal(true);
|
|
138
|
+
if (realFetch) { global.fetch = realFetch; realFetch = null; }
|
|
139
|
+
|
|
140
|
+
// An older core drops the field: no echo, the signal an app reads.
|
|
141
|
+
stubFetch({ key: 'k6', authUrl: 'https://auth/', poll: 'https://poll/k6', poll_rate_ms: 1000 });
|
|
142
|
+
const older = new pryv.Service('https://reg.test.local/service/info');
|
|
143
|
+
const envOld = await older.startAccessRequest(request);
|
|
144
|
+
expect(envOld).to.not.have.property('actAsManagedOnly');
|
|
145
|
+
if (realFetch) { global.fetch = realFetch; realFetch = null; }
|
|
146
|
+
|
|
147
|
+
// Only `true` counts as the echo.
|
|
148
|
+
stubFetch({ key: 'k7', authUrl: 'https://auth/', poll: 'https://poll/k7', poll_rate_ms: 1000, actAsManagedOnly: 'true' });
|
|
149
|
+
const odd = new pryv.Service('https://reg.test.local/service/info');
|
|
150
|
+
const envOdd = await odd.startAccessRequest(request);
|
|
151
|
+
expect(envOdd).to.not.have.property('actAsManagedOnly');
|
|
152
|
+
});
|
|
127
153
|
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
// Compiled (never run) by typings.test.js: what an app's onStateChange
|
|
6
|
+
// listener may write about the ACCEPTED state.
|
|
7
|
+
import type { StateChange } from 'pryv';
|
|
8
|
+
|
|
9
|
+
// After a popup sign-in, the listener gets the narrowed state: no apiEndpoint, no username.
|
|
10
|
+
export const popup: StateChange<'ACCEPTED'> = { status: 'ACCEPTED', id: 'ACCEPTED', key: 'k1' };
|
|
11
|
+
|
|
12
|
+
// Restored or redirect path: apiEndpoint and username are there.
|
|
13
|
+
export const restored: StateChange<'ACCEPTED'> = {
|
|
14
|
+
status: 'ACCEPTED',
|
|
15
|
+
id: 'ACCEPTED',
|
|
16
|
+
apiEndpoint: 'https://tok@alice.pryv.me/',
|
|
17
|
+
username: 'alice'
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function endpointOf (state: StateChange<'ACCEPTED'>): string | null {
|
|
21
|
+
// @ts-expect-error apiEndpoint may be absent (popup sign-in)
|
|
22
|
+
const direct: string = state.apiEndpoint;
|
|
23
|
+
// @ts-expect-error username may be absent (popup sign-in)
|
|
24
|
+
const name: string = state.username;
|
|
25
|
+
return state.apiEndpoint ?? (direct && name ? direct : null);
|
|
26
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
/* global describe, it, expect */
|
|
6
|
+
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const ts = require('typescript');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* [TYPX] The declarations match what an app receives. The fixtures under
|
|
12
|
+
* `typings/` are compiled (strict) against `src/index.d.ts`; a declaration
|
|
13
|
+
* that drifts from the runtime shape makes them fail to compile.
|
|
14
|
+
*/
|
|
15
|
+
describe('[TYPX] typings', function () {
|
|
16
|
+
this.timeout(60000);
|
|
17
|
+
|
|
18
|
+
function compile (fixture) {
|
|
19
|
+
const file = path.join(__dirname, 'typings', fixture);
|
|
20
|
+
const program = ts.createProgram([path.join(__dirname, '../src/index.d.ts'), file], {
|
|
21
|
+
noEmit: true,
|
|
22
|
+
strict: true,
|
|
23
|
+
skipLibCheck: true,
|
|
24
|
+
target: ts.ScriptTarget.ES2022,
|
|
25
|
+
module: ts.ModuleKind.CommonJS,
|
|
26
|
+
moduleResolution: ts.ModuleResolutionKind.Node10,
|
|
27
|
+
lib: ['lib.es2022.d.ts', 'lib.dom.d.ts'],
|
|
28
|
+
types: []
|
|
29
|
+
});
|
|
30
|
+
return ts.getPreEmitDiagnostics(program)
|
|
31
|
+
.filter((d) => d.file != null && path.resolve(d.file.fileName) === file)
|
|
32
|
+
.map((d) => ts.flattenDiagnosticMessageText(d.messageText, '\n'));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
it('[TYP1] ACCEPTED: apiEndpoint and username are optional (absent from a popup sign-in state)', function () {
|
|
36
|
+
expect(compile('acceptedState.ts')).to.deep.equal([]);
|
|
37
|
+
});
|
|
38
|
+
});
|