pryv 3.14.2 → 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 +13 -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 +11 -3
- package/src/globals.d.ts +4 -5
- package/src/index.d.ts +89 -3
- package/src/lib/MfaRequiredError.js +2 -0
- package/src/lib/PryvError.js +4 -0
- package/test/AuthController.pollOutcome.test.js +404 -0
- package/test/LoginButton.urlCleanup.test.js +67 -4
- package/test/PryvError.test.js +10 -0
- package/test/Service.accessRequestConsent.test.js +42 -0
- package/test/Service.mfa.test.js +13 -0
- package/test/typings/acceptedState.ts +26 -0
- package/test/typings.test.js +38 -0
package/README.md
CHANGED
|
@@ -131,6 +131,13 @@ Here is an implementation of the [Pryv.io authentication process](https://api.pr
|
|
|
131
131
|
optIn: ['location'] // offered UNticked; the user opts in
|
|
132
132
|
// a permission in neither list is optional and shown pre-selected
|
|
133
133
|
},
|
|
134
|
+
// optional (core 2.0.0-rc.32+): cross-account messaging invites the
|
|
135
|
+
// user answers in the same sign-in (1 to 8). The ACCEPTED state then
|
|
136
|
+
// carries `cmcInvites`, one outcome per invite ({ acceptEventId, ... },
|
|
137
|
+
// { declined: true } or { reason }), a hint: the requester learns the
|
|
138
|
+
// truth from its own inbox. Declining a mandatory one refuses the sign-in
|
|
139
|
+
// (REFUSED, reasonId 'REFUSED_MANDATORY_CONSENT'). Ignored by older cores.
|
|
140
|
+
// cmcInvites: [{ capabilityUrl: invite.capabilityUrl, mandatory: true }],
|
|
134
141
|
clientData: {
|
|
135
142
|
'app-web-auth:description': {
|
|
136
143
|
'type': 'note/txt', 'content': 'This is a consent message.'
|
|
@@ -600,14 +607,15 @@ getAuthorizationData () {
|
|
|
600
607
|
The [authentication process](https://api.pryv.com/reference/#authenticate-your-app) implementation on the frontend can go through the following states:
|
|
601
608
|
|
|
602
609
|
1. `LOADING`: while the visual assets are loading
|
|
603
|
-
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)
|
|
604
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)
|
|
605
612
|
4. `AUTHORIZED`: When [polling](https://api.pryv.com/reference/#poll-request) concludes with **Result: Accepted**
|
|
606
|
-
5. `
|
|
607
|
-
6. `
|
|
608
|
-
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
|
|
609
617
|
|
|
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.
|
|
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.
|
|
611
619
|
|
|
612
620
|
```js
|
|
613
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) {
|
|
@@ -524,6 +527,11 @@ class Service {
|
|
|
524
527
|
// all-or-nothing, which a caller may want to know before showing the
|
|
525
528
|
// approve link.
|
|
526
529
|
if (body.consent != null) envelope.consent = body.consent;
|
|
530
|
+
// Same for consent invites: echoed only by a core that understood them.
|
|
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;
|
|
527
535
|
// polling by key (pollAccessRequest, connectFromKey) then reaches the
|
|
528
536
|
// core that holds the request
|
|
529
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
|
@@ -891,6 +891,13 @@ declare module 'pryv' {
|
|
|
891
891
|
pollRateMs: number;
|
|
892
892
|
/** Echoed only by a core that understood `authRequest.consent`. */
|
|
893
893
|
consent?: AuthRequestConsentForm;
|
|
894
|
+
/** Echoed only by a core that understood `authRequest.cmcInvites`. */
|
|
895
|
+
cmcInvites?: AuthCmcInvite[];
|
|
896
|
+
/**
|
|
897
|
+
* Echoed (`true`) only by a core that understood
|
|
898
|
+
* `authRequest.actAsManagedOnly`; absent otherwise.
|
|
899
|
+
*/
|
|
900
|
+
actAsManagedOnly?: true;
|
|
894
901
|
}>;
|
|
895
902
|
/**
|
|
896
903
|
* Poll an access request once, by its full poll URL (recommended) or its
|
|
@@ -982,6 +989,44 @@ declare module 'pryv' {
|
|
|
982
989
|
>;
|
|
983
990
|
};
|
|
984
991
|
|
|
992
|
+
/**
|
|
993
|
+
* A cross-account messaging invite the user is asked to answer in the same
|
|
994
|
+
* authorisation request (`authRequest.cmcInvites`): the capability URL the
|
|
995
|
+
* app received, whether declining it refuses the whole request, and for
|
|
996
|
+
* which account it is accepted (`'target'`: the account the access is
|
|
997
|
+
* granted for, when the user acts for an account it manages).
|
|
998
|
+
*/
|
|
999
|
+
export type AuthRequestCmcInvite = {
|
|
1000
|
+
capabilityUrl: string;
|
|
1001
|
+
mandatory?: boolean;
|
|
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;
|
|
1010
|
+
};
|
|
1011
|
+
|
|
1012
|
+
/** An invite as the core echoes it back, with its defaults filled in. */
|
|
1013
|
+
export type AuthCmcInvite = {
|
|
1014
|
+
capabilityUrl: string;
|
|
1015
|
+
mandatory: boolean;
|
|
1016
|
+
for: 'self' | 'target';
|
|
1017
|
+
/** Present only when the invite was sent with an `accessName`. */
|
|
1018
|
+
accessName?: string;
|
|
1019
|
+
};
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* What the auth page did with one invite, in the ACCEPTED body (one per
|
|
1023
|
+
* invite, in order). A hint: the requester learns the truth from its inbox.
|
|
1024
|
+
*/
|
|
1025
|
+
export type AuthCmcInviteOutcome =
|
|
1026
|
+
| { acceptEventId: string; dataGrantAccessId?: string; acceptedFor?: 'self' }
|
|
1027
|
+
| { declined: true }
|
|
1028
|
+
| { reason: string };
|
|
1029
|
+
|
|
985
1030
|
export type States =
|
|
986
1031
|
| 'ERROR'
|
|
987
1032
|
| 'LOADING'
|
|
@@ -1025,14 +1070,25 @@ declare module 'pryv' {
|
|
|
1025
1070
|
defaultName: string;
|
|
1026
1071
|
}>;
|
|
1027
1072
|
consent?: AuthRequestConsentForm;
|
|
1073
|
+
/** Present only when the request carried `cmcInvites` the core understood. */
|
|
1074
|
+
cmcInvites?: AuthCmcInvite[];
|
|
1075
|
+
/** Present only when the request carried `actAsManagedOnly: true` and the core understood it. */
|
|
1076
|
+
actAsManagedOnly?: true;
|
|
1028
1077
|
requestingAppId: string;
|
|
1029
1078
|
returnURL?: string | null;
|
|
1030
1079
|
serviceInfo?: ServiceInfo;
|
|
1031
1080
|
};
|
|
1032
1081
|
ACCEPTED: {
|
|
1033
1082
|
serviceInfo?: ServiceInfo;
|
|
1034
|
-
|
|
1035
|
-
|
|
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;
|
|
1036
1092
|
/** Present on inline delivery; absent when a one-time `handoff` key is used. */
|
|
1037
1093
|
token?: string;
|
|
1038
1094
|
/**
|
|
@@ -1055,6 +1111,8 @@ declare module 'pryv' {
|
|
|
1055
1111
|
profile?: AuthProfile;
|
|
1056
1112
|
/** One-time credential hand-off key (shared-secret delivery), in place of `token`. */
|
|
1057
1113
|
handoff?: { type: 'shared-secret'; key: string };
|
|
1114
|
+
/** What the auth page did with each invite of the request (a hint). */
|
|
1115
|
+
cmcInvites?: AuthCmcInviteOutcome[];
|
|
1058
1116
|
};
|
|
1059
1117
|
SIGNOUT: {};
|
|
1060
1118
|
SWITCHING: {
|
|
@@ -1062,8 +1120,16 @@ declare module 'pryv' {
|
|
|
1062
1120
|
/** null: the account is chosen in the sign-in popup */
|
|
1063
1121
|
to: string | null;
|
|
1064
1122
|
};
|
|
1123
|
+
/**
|
|
1124
|
+
* The auth request was refused on the auth page; followed by
|
|
1125
|
+
* INITIALIZED (the sign-in button resets).
|
|
1126
|
+
*/
|
|
1065
1127
|
REFUSED: {
|
|
1066
|
-
|
|
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;
|
|
1067
1133
|
message?: string;
|
|
1068
1134
|
serviceInfo?: ServiceInfo;
|
|
1069
1135
|
};
|
|
@@ -1089,6 +1155,8 @@ declare module 'pryv' {
|
|
|
1089
1155
|
requestingAppId: string;
|
|
1090
1156
|
requestedPermissions: AuthRequestedPermission[];
|
|
1091
1157
|
consent?: AuthRequestConsentForm;
|
|
1158
|
+
cmcInvites?: AuthCmcInvite[];
|
|
1159
|
+
actAsManagedOnly?: true;
|
|
1092
1160
|
lang?: string;
|
|
1093
1161
|
returnURL?: string;
|
|
1094
1162
|
clientData?: KeyValue;
|
|
@@ -1125,6 +1193,13 @@ declare module 'pryv' {
|
|
|
1125
1193
|
languageCode?: string;
|
|
1126
1194
|
requestedPermissions: AuthRequestedPermission[];
|
|
1127
1195
|
consent?: AuthRequestConsent;
|
|
1196
|
+
/**
|
|
1197
|
+
* Cross-account messaging invites the user answers in the same
|
|
1198
|
+
* sign-in (1 to 8). The ACCEPTED state carries one outcome per invite;
|
|
1199
|
+
* a declined mandatory invite ends the request REFUSED with
|
|
1200
|
+
* `reasonId: 'REFUSED_MANDATORY_CONSENT'`. An older core ignores the field.
|
|
1201
|
+
*/
|
|
1202
|
+
cmcInvites?: AuthRequestCmcInvite[];
|
|
1128
1203
|
/**
|
|
1129
1204
|
* Where the sign-in happens: `'auto#'` (default, also when unset or
|
|
1130
1205
|
* `false`) opens a popup on desktop and redirects on a phone or tablet;
|
|
@@ -1159,6 +1234,17 @@ declare module 'pryv' {
|
|
|
1159
1234
|
* 'deny', or the username to preselect.
|
|
1160
1235
|
*/
|
|
1161
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;
|
|
1162
1248
|
};
|
|
1163
1249
|
};
|
|
1164
1250
|
|
|
@@ -41,6 +41,8 @@ class MfaRequiredError extends PryvError {
|
|
|
41
41
|
this.id = (apiErr && apiErr.id) || 'mfa-required';
|
|
42
42
|
this.status = response && response.status;
|
|
43
43
|
this.response = { body, status: response && response.status };
|
|
44
|
+
// Read by name, but not printed with the error (like `innerObject`).
|
|
45
|
+
Object.defineProperty(this, 'mfaToken', { enumerable: false });
|
|
44
46
|
|
|
45
47
|
if (Error.captureStackTrace) {
|
|
46
48
|
Error.captureStackTrace(this, MfaRequiredError);
|
package/src/lib/PryvError.js
CHANGED
|
@@ -35,6 +35,10 @@ class PryvError extends Error {
|
|
|
35
35
|
this.status = undefined;
|
|
36
36
|
/** @type {{ body: any, status: number }|undefined} Raw response */
|
|
37
37
|
this.response = undefined;
|
|
38
|
+
// `innerObject` and `response` hold raw platform answers: still readable and
|
|
39
|
+
// writable, but not enumerable, so `console.log(err)` does not print them.
|
|
40
|
+
Object.defineProperty(this, 'innerObject', { enumerable: false });
|
|
41
|
+
Object.defineProperty(this, 'response', { enumerable: false });
|
|
38
42
|
|
|
39
43
|
if (Error.captureStackTrace) {
|
|
40
44
|
Error.captureStackTrace(this, PryvError);
|
|
@@ -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');
|
package/test/PryvError.test.js
CHANGED
|
@@ -71,4 +71,14 @@ describe('[PERX] PryvError', function () {
|
|
|
71
71
|
expect(error.status).to.equal(502);
|
|
72
72
|
expect(error.response).to.deep.equal({ body: undefined, status: 502 });
|
|
73
73
|
});
|
|
74
|
+
|
|
75
|
+
it('[PERJ] innerObject and response are readable but not enumerable (not printed with the error)', function () {
|
|
76
|
+
const error = new PryvError('Outer', { id: 'invalid-access-token', secret: 'tok' });
|
|
77
|
+
error.response = { body: { secret: 'tok' }, status: 401 };
|
|
78
|
+
expect(error.innerObject.id).to.equal('invalid-access-token');
|
|
79
|
+
expect(error.response.status).to.equal(401);
|
|
80
|
+
expect(Object.keys(error)).to.not.include('innerObject');
|
|
81
|
+
expect(Object.keys(error)).to.not.include('response');
|
|
82
|
+
expect(require('util').inspect(error)).to.not.include('tok');
|
|
83
|
+
});
|
|
74
84
|
});
|
|
@@ -108,4 +108,46 @@ describe('[ARQC] auth-request consent sidecar', function () {
|
|
|
108
108
|
expect(envOld.key).to.equal('k2');
|
|
109
109
|
expect(envOld).to.not.have.property('consent');
|
|
110
110
|
});
|
|
111
|
+
|
|
112
|
+
it('[LCI3] posts cmcInvites verbatim and returns the echo a core sends, nothing when it does not', async function () {
|
|
113
|
+
const invites = [{ capabilityUrl: 'https://cap@doctor.test.local/', mandatory: true }];
|
|
114
|
+
const echo = [{ capabilityUrl: 'https://cap@doctor.test.local/', mandatory: true, for: 'self' }];
|
|
115
|
+
stubFetch({ key: 'k3', authUrl: 'https://auth/', poll: 'https://poll/k3', poll_rate_ms: 1000, cmcInvites: echo });
|
|
116
|
+
const service = new pryv.Service('https://reg.test.local/service/info');
|
|
117
|
+
const env = await service.startAccessRequest({ ...REQUEST, cmcInvites: invites });
|
|
118
|
+
expect(posted[0].cmcInvites).to.deep.equal(invites);
|
|
119
|
+
expect(env.cmcInvites).to.deep.equal(echo);
|
|
120
|
+
if (realFetch) { global.fetch = realFetch; realFetch = null; }
|
|
121
|
+
|
|
122
|
+
stubFetch({ key: 'k4', authUrl: 'https://auth/', poll: 'https://poll/k4', poll_rate_ms: 1000 });
|
|
123
|
+
const older = new pryv.Service('https://reg.test.local/service/info');
|
|
124
|
+
const envOld = await older.startAccessRequest({ ...REQUEST, cmcInvites: invites });
|
|
125
|
+
expect(envOld).to.not.have.property('cmcInvites');
|
|
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
|
+
});
|
|
111
153
|
});
|
package/test/Service.mfa.test.js
CHANGED
|
@@ -115,5 +115,18 @@ describe('[MFLX] Service MFA', function () {
|
|
|
115
115
|
);
|
|
116
116
|
expect(err.method).to.equal(undefined);
|
|
117
117
|
});
|
|
118
|
+
|
|
119
|
+
it('[MFEE] mfaToken and the raw response are readable but not printed with the error', function () {
|
|
120
|
+
const err = new pryv.MfaRequiredError(
|
|
121
|
+
'tok-secret-4',
|
|
122
|
+
{ status: 200 },
|
|
123
|
+
{ mfaToken: 'tok-secret-4' }
|
|
124
|
+
);
|
|
125
|
+
expect(err.mfaToken).to.equal('tok-secret-4');
|
|
126
|
+
expect(err.response.body.mfaToken).to.equal('tok-secret-4');
|
|
127
|
+
expect(Object.keys(err)).to.not.include('mfaToken');
|
|
128
|
+
expect(Object.keys(err)).to.not.include('response');
|
|
129
|
+
expect(require('util').inspect(err)).to.not.include('tok-secret-4');
|
|
130
|
+
});
|
|
118
131
|
});
|
|
119
132
|
});
|
|
@@ -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
|
+
});
|