pryv 3.11.0 → 3.12.1
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 +15 -0
- package/package.json +1 -1
- package/src/Auth/AuthController.js +16 -2
- package/src/Browser/LoginButton.js +5 -2
- package/src/Service.js +29 -4
- package/src/SharedSecrets.js +11 -4
- package/src/index.d.ts +74 -0
- package/test/Browser.AuthController.test.js +75 -1
- package/test/LoginButton.test.js +18 -0
- package/test/LoginButton.urlCleanup.test.js +15 -10
- package/test/Service.accessRequestConsent.test.js +111 -0
package/README.md
CHANGED
|
@@ -114,8 +114,23 @@ Here is an implementation of the [Pryv.io authentication process](https://api.pr
|
|
|
114
114
|
streamId: 'test',
|
|
115
115
|
defaultName: 'test',
|
|
116
116
|
level: 'manage'
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
streamId: 'location',
|
|
120
|
+
defaultName: 'Location',
|
|
121
|
+
level: 'read'
|
|
117
122
|
}
|
|
118
123
|
],
|
|
124
|
+
// optional: how the consent screen should present each permission.
|
|
125
|
+
// Ids are a stream permission's streamId (or a feature permission's
|
|
126
|
+
// feature). Ignored by older cores, which fall back to
|
|
127
|
+
// all-or-nothing rather than failing.
|
|
128
|
+
consent: {
|
|
129
|
+
allowUserChoice: true, // false (the default) = accept all or deny
|
|
130
|
+
mandatory: ['test'], // required: cannot be unticked
|
|
131
|
+
optIn: ['location'] // offered UNticked; the user opts in
|
|
132
|
+
// a permission in neither list is optional and shown pre-selected
|
|
133
|
+
},
|
|
119
134
|
clientData: {
|
|
120
135
|
'app-web-auth:description': {
|
|
121
136
|
'type': 'note/txt', 'content': 'This is a consent message.'
|
package/package.json
CHANGED
|
@@ -70,7 +70,14 @@ class AuthController {
|
|
|
70
70
|
const loginButton = this.loginButton;
|
|
71
71
|
// initialize human interaction interface
|
|
72
72
|
if (loginButton != null) {
|
|
73
|
-
|
|
73
|
+
// Register the button's state listener at most once: init() can run again
|
|
74
|
+
// on the same controller (the LoginButton re-inits after a confirmed
|
|
75
|
+
// logout), and a duplicate listener would fire the logout confirm twice
|
|
76
|
+
// and compound the listener list on every re-login.
|
|
77
|
+
if (!this._loginButtonListenerRegistered) {
|
|
78
|
+
this.stateChangeListeners.push(loginButton.onStateChange.bind(loginButton));
|
|
79
|
+
this._loginButtonListenerRegistered = true;
|
|
80
|
+
}
|
|
74
81
|
// autologin needs cookies/storage implemented in human interaction interface
|
|
75
82
|
await checkAutoLogin(this);
|
|
76
83
|
}
|
|
@@ -245,9 +252,16 @@ class AuthController {
|
|
|
245
252
|
|
|
246
253
|
this._state = newState;
|
|
247
254
|
|
|
255
|
+
// Dispatch the state that was just set (`newState`), NOT the live `this.state`
|
|
256
|
+
// getter: a listener that synchronously changes the state mid-dispatch (e.g. a
|
|
257
|
+
// custom listener re-initializing to INITIALIZED on SIGNOUT; the LoginButton's
|
|
258
|
+
// own re-init now starts a microtask later) would otherwise overwrite
|
|
259
|
+
// `this._state`, so later listeners in this loop would receive the wrong state
|
|
260
|
+
// (a logout would deliver INITIALIZED instead of SIGNOUT to the app's
|
|
261
|
+
// onStateChange).
|
|
248
262
|
this.stateChangeListeners.forEach((listener) => {
|
|
249
263
|
try {
|
|
250
|
-
listener(
|
|
264
|
+
listener(newState);
|
|
251
265
|
} catch (e) {
|
|
252
266
|
console.log('Error during set state ()', e);
|
|
253
267
|
}
|
|
@@ -74,8 +74,11 @@ class LoginButton {
|
|
|
74
74
|
case AuthStates.SIGNOUT: {
|
|
75
75
|
const message = this.messages.SIGNOUT_CONFIRM ? this.messages.SIGNOUT_CONFIRM : 'Logout ?';
|
|
76
76
|
if (confirm(message)) {
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
// Awaited: a re-init left running in the background resumes later
|
|
78
|
+
// (after loading assets) and consumes whatever poll URL the page shows
|
|
79
|
+
// by then, racing any other sign-in in progress.
|
|
80
|
+
await this.deleteAuthorizationData();
|
|
81
|
+
await this.auth.init();
|
|
79
82
|
}
|
|
80
83
|
break;
|
|
81
84
|
}
|
package/src/Service.js
CHANGED
|
@@ -473,13 +473,28 @@ class Service {
|
|
|
473
473
|
* @param {Object} authRequest - The auth-request body
|
|
474
474
|
* @param {string} authRequest.requestingAppId
|
|
475
475
|
* @param {Array<{ streamId: string, level: string, defaultName: string }>} authRequest.requestedPermissions
|
|
476
|
+
* @param {Object} [authRequest.consent] - Says how the consent screen
|
|
477
|
+
* should present each requested permission. `mandatory` and `optIn`
|
|
478
|
+
* name permission ids (a stream permission's `streamId`, a feature
|
|
479
|
+
* permission's `feature`); an id in neither list is optional and
|
|
480
|
+
* shown pre-selected. Ignored by cores that predate it, so the flow
|
|
481
|
+
* falls back to all-or-nothing rather than failing: the returned
|
|
482
|
+
* `consent` tells you which happened.
|
|
483
|
+
* @param {boolean} [authRequest.consent.allowUserChoice=false] - false
|
|
484
|
+
* means the user may only accept the whole set or deny.
|
|
485
|
+
* @param {string[]} [authRequest.consent.mandatory] - ids the user
|
|
486
|
+
* cannot leave out.
|
|
487
|
+
* @param {string[]} [authRequest.consent.optIn] - ids offered NOT
|
|
488
|
+
* pre-selected, so the user has to choose them.
|
|
476
489
|
* @param {string} [authRequest.languageCode='en']
|
|
477
490
|
* @param {string|boolean} [authRequest.returnUrl]
|
|
478
491
|
* @param {string} [authRequest.referer]
|
|
479
492
|
* @param {Object} [authRequest.clientData]
|
|
480
493
|
* @param {string} [authRequest.deviceName]
|
|
481
494
|
* @param {number} [authRequest.expireAfter]
|
|
482
|
-
* @returns {Promise<{ key: string, authUrl: string, poll: string, pollRateMs: number }>}
|
|
495
|
+
* @returns {Promise<{ key: string, authUrl: string, poll: string, pollRateMs: number, consent?: Object }>}
|
|
496
|
+
* `consent` is echoed back only by a core that understood the
|
|
497
|
+
* annotations, which is how you detect support.
|
|
483
498
|
* @throws {PryvError} on non-2xx
|
|
484
499
|
*/
|
|
485
500
|
async startAccessRequest (authRequest) {
|
|
@@ -499,12 +514,18 @@ class Service {
|
|
|
499
514
|
'Invalid access-request response: ' + JSON.stringify(body)
|
|
500
515
|
);
|
|
501
516
|
}
|
|
502
|
-
|
|
517
|
+
const envelope = {
|
|
503
518
|
key: body.key,
|
|
504
519
|
authUrl: body.authUrl || body.url,
|
|
505
520
|
poll: body.poll,
|
|
506
521
|
pollRateMs: body.poll_rate_ms != null ? body.poll_rate_ms : body.pollRateMs
|
|
507
522
|
};
|
|
523
|
+
// Present only when the core understood a `consent` sidecar. Absent
|
|
524
|
+
// means the annotations were ignored and the consent screen will be
|
|
525
|
+
// all-or-nothing, which a caller may want to know before showing the
|
|
526
|
+
// approve link.
|
|
527
|
+
if (body.consent != null) envelope.consent = body.consent;
|
|
528
|
+
return envelope;
|
|
508
529
|
}
|
|
509
530
|
|
|
510
531
|
/**
|
|
@@ -552,8 +573,12 @@ class Service {
|
|
|
552
573
|
* apiEndpoint), and uses this method to build a working `Connection`.
|
|
553
574
|
*
|
|
554
575
|
* The implementation polls `<access>/<key>` once; the call MUST be
|
|
555
|
-
* made while the access is still in the ACCEPTED
|
|
556
|
-
*
|
|
576
|
+
* made while the access request is still readable in the ACCEPTED
|
|
577
|
+
* state. Servers keep a decided request only for a short retention
|
|
578
|
+
* window after it is first polled (default 2 minutes, operator setting
|
|
579
|
+
* `access.terminalRetentionMs`), so call this promptly after the flow
|
|
580
|
+
* completes; afterwards the key is unknown. (`expireAfter` on the
|
|
581
|
+
* access request is the lifetime of the access created, not of the key.)
|
|
557
582
|
*
|
|
558
583
|
* @param {string} key - polling key from `startAccessRequest`
|
|
559
584
|
* @returns {Promise<Connection>}
|
package/src/SharedSecrets.js
CHANGED
|
@@ -96,6 +96,9 @@ async function hmacSha256Hex (verifierSecret, message) {
|
|
|
96
96
|
*/
|
|
97
97
|
async function create (connection, params) {
|
|
98
98
|
const { signature, ...rest } = params || {};
|
|
99
|
+
/** The request body: built here, so its shape is wider than `params`
|
|
100
|
+
* (the HMAC form adds `keyHash` and a computed `signature`).
|
|
101
|
+
* @type {Record<string, any>} */
|
|
99
102
|
const body = { ...rest };
|
|
100
103
|
let key = null;
|
|
101
104
|
|
|
@@ -108,12 +111,12 @@ async function create (connection, params) {
|
|
|
108
111
|
type: 'hmac-sha256',
|
|
109
112
|
value: await hmacSha256Hex(signature.verifierSecret, random)
|
|
110
113
|
};
|
|
111
|
-
const
|
|
112
|
-
return { ...
|
|
114
|
+
const hmacRes = /** @type {any} */ (await connection.post('shared-secrets', body));
|
|
115
|
+
return { ...hmacRes.sharedSecret, key: hmacRes.sharedSecret.id + '.' + random };
|
|
113
116
|
}
|
|
114
117
|
|
|
115
118
|
if (signature != null) body.signature = signature;
|
|
116
|
-
const res = await connection.post('shared-secrets', body);
|
|
119
|
+
const res = /** @type {any} */ (await connection.post('shared-secrets', body));
|
|
117
120
|
key = res.sharedSecret.key;
|
|
118
121
|
return { ...res.sharedSecret, key };
|
|
119
122
|
}
|
|
@@ -149,7 +152,11 @@ async function retrieve (apiEndpoint, key, options = {}) {
|
|
|
149
152
|
});
|
|
150
153
|
const parsed = await res.json();
|
|
151
154
|
if (!res.ok) {
|
|
152
|
-
|
|
155
|
+
// The API error id and the creator's returnUrl ride on the Error so a
|
|
156
|
+
// caller can tell WHY it was refused and where to send the user next.
|
|
157
|
+
const err = /** @type {Error & { id?: string, returnUrl?: string }} */ (
|
|
158
|
+
new Error(parsed?.error?.message || 'Shared secret unavailable.')
|
|
159
|
+
);
|
|
153
160
|
err.id = parsed?.error?.id;
|
|
154
161
|
err.returnUrl = parsed?.error?.data?.returnUrl;
|
|
155
162
|
throw err;
|
package/src/index.d.ts
CHANGED
|
@@ -871,8 +871,16 @@ declare module 'pryv' {
|
|
|
871
871
|
authUrl: string;
|
|
872
872
|
poll: string;
|
|
873
873
|
pollRateMs: number;
|
|
874
|
+
/** Echoed only by a core that understood `authRequest.consent`. */
|
|
875
|
+
consent?: AuthRequestConsentForm;
|
|
874
876
|
}>;
|
|
875
877
|
pollAccessRequest(keyOrPollUrl: string): Promise<any>;
|
|
878
|
+
/**
|
|
879
|
+
* Resolve an auth-flow polling `key` (from {@link Service.startAccessRequest})
|
|
880
|
+
* into a working {@link Connection}. Polls the access request once; throws a
|
|
881
|
+
* {@link PryvError} unless the access is `ACCEPTED`.
|
|
882
|
+
*/
|
|
883
|
+
connectFromKey(key: string): Promise<Connection>;
|
|
876
884
|
|
|
877
885
|
static buildAPIEndpoint(
|
|
878
886
|
serviceInfo: ServiceInfo,
|
|
@@ -881,12 +889,74 @@ declare module 'pryv' {
|
|
|
881
889
|
): string;
|
|
882
890
|
}
|
|
883
891
|
|
|
892
|
+
/**
|
|
893
|
+
* Module-level convenience over {@link Service#connectFromKey}: builds a
|
|
894
|
+
* transient {@link Service} for `serviceInfoUrl`, fetches its info, and
|
|
895
|
+
* resolves the auth-flow polling `key` into a working {@link Connection}.
|
|
896
|
+
* Mirrors the `pryv.connectFromKey(key, serviceInfoUrl)` shape the headless
|
|
897
|
+
* polling pattern documents.
|
|
898
|
+
*/
|
|
899
|
+
export function connectFromKey(
|
|
900
|
+
key: string,
|
|
901
|
+
serviceInfoUrl: string,
|
|
902
|
+
serviceCustomizations?: serviceCustomizations,
|
|
903
|
+
): Promise<Connection>;
|
|
904
|
+
|
|
884
905
|
export type AuthRequestedPermission = {
|
|
885
906
|
streamId: Identifier;
|
|
886
907
|
defaultName: string;
|
|
887
908
|
level: PermissionLevel;
|
|
888
909
|
};
|
|
889
910
|
|
|
911
|
+
/**
|
|
912
|
+
* How the consent screen should present each requested permission.
|
|
913
|
+
*
|
|
914
|
+
* The lists name permission ids: a stream permission's `streamId`, a
|
|
915
|
+
* feature permission's `feature`. Together they give three words for
|
|
916
|
+
* what an entry means to the user:
|
|
917
|
+
*
|
|
918
|
+
* mandatory listed in `mandatory` : required; cannot be unticked
|
|
919
|
+
* opt-out in neither list : optional, shown pre-selected
|
|
920
|
+
* opt-in listed in `optIn` : optional, shown NOT pre-selected
|
|
921
|
+
*
|
|
922
|
+
* An id may not appear in both lists, and every id must match exactly
|
|
923
|
+
* one requested permission. A core that predates this ignores the whole
|
|
924
|
+
* object, so the consent falls back to all-or-nothing instead of
|
|
925
|
+
* failing; `AuthRequestConsentForm` echoed back is how you tell.
|
|
926
|
+
*/
|
|
927
|
+
export type AuthRequestConsent = {
|
|
928
|
+
/** Default false: the user may only accept the whole set or deny. */
|
|
929
|
+
allowUserChoice?: boolean;
|
|
930
|
+
mandatory?: string[];
|
|
931
|
+
optIn?: string[];
|
|
932
|
+
};
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* The resolved consent form: the requested permissions with their
|
|
936
|
+
* annotations attached, as the auth page renders them. Returned by the
|
|
937
|
+
* auth-request POST and carried on the NEED_SIGNIN poll, in both cases
|
|
938
|
+
* ONLY when the request carried a `consent` object the core understood.
|
|
939
|
+
*/
|
|
940
|
+
export type AuthRequestConsentForm = {
|
|
941
|
+
allowUserChoice: boolean;
|
|
942
|
+
permissions: Array<
|
|
943
|
+
(
|
|
944
|
+
| {
|
|
945
|
+
streamId: Identifier;
|
|
946
|
+
level: PermissionLevel;
|
|
947
|
+
/** Echoed only when the request carried it. */
|
|
948
|
+
defaultName?: string;
|
|
949
|
+
/** Present when the core resolved the stream's real name. */
|
|
950
|
+
name?: string;
|
|
951
|
+
}
|
|
952
|
+
| { feature: string; setting: string }
|
|
953
|
+
) & {
|
|
954
|
+
mandatory?: true;
|
|
955
|
+
optIn?: true;
|
|
956
|
+
}
|
|
957
|
+
>;
|
|
958
|
+
};
|
|
959
|
+
|
|
890
960
|
export type States =
|
|
891
961
|
| 'ERROR'
|
|
892
962
|
| 'LOADING'
|
|
@@ -919,6 +989,7 @@ declare module 'pryv' {
|
|
|
919
989
|
level: PermissionLevel;
|
|
920
990
|
defaultName: string;
|
|
921
991
|
}>;
|
|
992
|
+
consent?: AuthRequestConsentForm;
|
|
922
993
|
requestingAppId: string;
|
|
923
994
|
returnUrl?: string | null;
|
|
924
995
|
serviceInfo?: ServiceInfo;
|
|
@@ -956,6 +1027,7 @@ declare module 'pryv' {
|
|
|
956
1027
|
poll_rate_ms: number;
|
|
957
1028
|
requestingAppId: string;
|
|
958
1029
|
requestedPermissions: AuthRequestedPermission[];
|
|
1030
|
+
consent?: AuthRequestConsentForm;
|
|
959
1031
|
lang?: string;
|
|
960
1032
|
returnURL?: string;
|
|
961
1033
|
clientData?: KeyValue;
|
|
@@ -970,6 +1042,7 @@ declare module 'pryv' {
|
|
|
970
1042
|
requestingAppId: string;
|
|
971
1043
|
languageCode?: string;
|
|
972
1044
|
requestedPermissions: AuthRequestedPermission[];
|
|
1045
|
+
consent?: AuthRequestConsent;
|
|
973
1046
|
returnUrl?: string | boolean;
|
|
974
1047
|
referer?: string;
|
|
975
1048
|
clientData?: KeyValue;
|
|
@@ -1202,6 +1275,7 @@ declare module 'pryv' {
|
|
|
1202
1275
|
MfaRequiredError: typeof MfaRequiredError;
|
|
1203
1276
|
ERRORS: typeof ERRORS;
|
|
1204
1277
|
version: version;
|
|
1278
|
+
connectFromKey: typeof connectFromKey;
|
|
1205
1279
|
};
|
|
1206
1280
|
|
|
1207
1281
|
export default pryv;
|
|
@@ -7,11 +7,13 @@
|
|
|
7
7
|
const utils = require('../src/utils.js');
|
|
8
8
|
const Service = require('../src/Service');
|
|
9
9
|
const AuthController = require('../src/Auth/AuthController.js');
|
|
10
|
+
const AuthStates = require('../src/Auth/AuthStates');
|
|
10
11
|
|
|
11
12
|
describe('[AUTX] Browser.LoginButton', function () {
|
|
12
13
|
this.timeout(15000);
|
|
13
14
|
|
|
14
15
|
let auth;
|
|
16
|
+
let service;
|
|
15
17
|
let cleanupDom = false;
|
|
16
18
|
before(async function () {
|
|
17
19
|
if (typeof document !== 'undefined') return; // in browser
|
|
@@ -32,7 +34,7 @@ describe('[AUTX] Browser.LoginButton', function () {
|
|
|
32
34
|
delete global.location;
|
|
33
35
|
});
|
|
34
36
|
before(async function () {
|
|
35
|
-
|
|
37
|
+
service = new Service(testData.serviceInfoUrl);
|
|
36
38
|
await service.info();
|
|
37
39
|
auth = new AuthController({
|
|
38
40
|
authRequest: {
|
|
@@ -92,4 +94,76 @@ describe('[AUTX] Browser.LoginButton', function () {
|
|
|
92
94
|
expect('https://my.Url.com/#bobby=2').to.equal(utils.cleanURLFromPrYvParams(
|
|
93
95
|
'https://my.Url.com/#bobby=2&prYvZoutOu=1&prYvstatus=2jsadh'));
|
|
94
96
|
});
|
|
97
|
+
|
|
98
|
+
// Logout state dispatch (https://github.com/pryv/lib-js/issues/13): a listener
|
|
99
|
+
// that synchronously re-enters the setter (the LoginButton's logout confirm
|
|
100
|
+
// re-initializing to INITIALIZED) must not corrupt the state delivered to the
|
|
101
|
+
// other listeners in the same dispatch, and init() must not register the button
|
|
102
|
+
// listener twice (which would fire the logout confirm dialog more than once).
|
|
103
|
+
describe('[ACLO] logout state dispatch (re-entrancy)', function () {
|
|
104
|
+
function makeAuth (onStateChange) {
|
|
105
|
+
return new AuthController({
|
|
106
|
+
authRequest: { requestingAppId: 'test-app', requestedPermissions: [] },
|
|
107
|
+
onStateChange
|
|
108
|
+
}, service);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
it('[ACLO1] the app onStateChange receives SIGNOUT on logout, then INITIALIZED', async function () {
|
|
112
|
+
const seen = [];
|
|
113
|
+
const a = makeAuth((s) => seen.push(s.status));
|
|
114
|
+
await a.init();
|
|
115
|
+
// Worst case of a LoginButton-like listener (registered after the external
|
|
116
|
+
// app listener): on SIGNOUT it re-initializes synchronously, driving the
|
|
117
|
+
// state to INITIALIZED during the same dispatch. (The real LoginButton
|
|
118
|
+
// awaits its re-init, which starts a microtask later.)
|
|
119
|
+
a.stateChangeListeners.push((s) => {
|
|
120
|
+
if (s.status === AuthStates.SIGNOUT) a.state = { status: AuthStates.INITIALIZED, serviceInfo: {} };
|
|
121
|
+
});
|
|
122
|
+
a._state = { status: AuthStates.AUTHORIZED };
|
|
123
|
+
seen.length = 0;
|
|
124
|
+
await a.handleClick(); // AUTHORIZED -> SIGNOUT
|
|
125
|
+
expect(seen).to.include(AuthStates.SIGNOUT);
|
|
126
|
+
expect(seen[seen.length - 1]).to.equal(AuthStates.INITIALIZED);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('[ACLO2] a listener re-entering the setter must not corrupt the state delivered to later listeners', function () {
|
|
130
|
+
// The setter must hand each listener the state dispatched to IT, not a value
|
|
131
|
+
// an earlier listener mutated mid-dispatch. Worst case: a re-entrant listener
|
|
132
|
+
// registered BEFORE the recorder.
|
|
133
|
+
const a = makeAuth(() => {});
|
|
134
|
+
const seen = [];
|
|
135
|
+
let reentered = false;
|
|
136
|
+
a.stateChangeListeners.push((s) => {
|
|
137
|
+
if (s.status === AuthStates.SIGNOUT && !reentered) {
|
|
138
|
+
reentered = true;
|
|
139
|
+
a.state = { status: AuthStates.INITIALIZED, serviceInfo: {} };
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
a.stateChangeListeners.push((s) => seen.push(s.status));
|
|
143
|
+
seen.length = 0;
|
|
144
|
+
a.state = { status: AuthStates.SIGNOUT };
|
|
145
|
+
// The re-entrant INITIALIZED dispatch completes first (inner), then the outer
|
|
146
|
+
// SIGNOUT dispatch resumes to the recorder; the recorder MUST see SIGNOUT.
|
|
147
|
+
expect(seen).to.deep.equal([AuthStates.INITIALIZED, AuthStates.SIGNOUT]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('[ACLO3] init() registers the LoginButton listener only once, so logout confirms exactly once', async function () {
|
|
151
|
+
let confirms = 0;
|
|
152
|
+
const fakeButton = {
|
|
153
|
+
onStateChange: (s) => { if (s.status === AuthStates.SIGNOUT) confirms++; },
|
|
154
|
+
getAuthorizationData: () => null
|
|
155
|
+
};
|
|
156
|
+
const a = new AuthController({
|
|
157
|
+
authRequest: { requestingAppId: 'test-app', requestedPermissions: [] }
|
|
158
|
+
}, service, fakeButton);
|
|
159
|
+
await a.init();
|
|
160
|
+
const nAfterFirst = a.stateChangeListeners.length;
|
|
161
|
+
await a.init(); // the confirmed-logout path re-inits the same controller
|
|
162
|
+
await a.init();
|
|
163
|
+
expect(a.stateChangeListeners.length).to.equal(nAfterFirst); // no compounding
|
|
164
|
+
confirms = 0;
|
|
165
|
+
a.state = { status: AuthStates.SIGNOUT }; // one dispatch -> one confirm
|
|
166
|
+
expect(confirms).to.equal(1);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
95
169
|
});
|
package/test/LoginButton.test.js
CHANGED
|
@@ -39,6 +39,7 @@ describe('[LBTX] LoginButton', function () {
|
|
|
39
39
|
delete global.document;
|
|
40
40
|
delete global.window;
|
|
41
41
|
delete global.location;
|
|
42
|
+
delete global.navigator;
|
|
42
43
|
delete global.confirm;
|
|
43
44
|
});
|
|
44
45
|
|
|
@@ -174,6 +175,23 @@ describe('[LBTX] LoginButton', function () {
|
|
|
174
175
|
// Confirm is mocked to return true
|
|
175
176
|
});
|
|
176
177
|
|
|
178
|
+
it('[LBSG] SIGNOUT waits for the re-init to finish (none left running in the background)', async function () {
|
|
179
|
+
// A re-init that outlives onStateChange resumes later and consumes
|
|
180
|
+
// whatever poll URL the page shows by then (it raced [LBRU] on CI).
|
|
181
|
+
const originalInit = loginBtn.auth.init;
|
|
182
|
+
let reinitDone = false;
|
|
183
|
+
loginBtn.auth.init = async function () {
|
|
184
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
185
|
+
reinitDone = true;
|
|
186
|
+
};
|
|
187
|
+
try {
|
|
188
|
+
await loginBtn.onStateChange({ status: AuthStates.SIGNOUT });
|
|
189
|
+
expect(reinitDone).to.equal(true);
|
|
190
|
+
} finally {
|
|
191
|
+
loginBtn.auth.init = originalInit;
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
177
195
|
it('[LBSF] handles unknown state gracefully', async function () {
|
|
178
196
|
// Should log warning but not throw
|
|
179
197
|
await loginBtn.onStateChange({ status: 'UNKNOWN_STATE' });
|
|
@@ -9,14 +9,19 @@ const utils = require('../src/utils');
|
|
|
9
9
|
|
|
10
10
|
describe('[LBRU] LoginButton URL cleanup after redirect', function () {
|
|
11
11
|
const PAGE_URL = 'http://localhost/app?x=1&prYvpoll=http%3A%2F%2Flocalhost%2Fpoll%2Fkey123&prYvstatus=ACCEPTED';
|
|
12
|
-
|
|
13
|
-
let
|
|
12
|
+
const GLOBALS = ['document', 'window', 'location', 'navigator'];
|
|
13
|
+
let saved;
|
|
14
14
|
let originalFetchGet;
|
|
15
15
|
|
|
16
16
|
before(() => {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
// Always our own page: reusing a DOM left by another file would test that
|
|
18
|
+
// page's URL, not PAGE_URL. Node only (a real browser run has no JSDOM).
|
|
19
|
+
if (typeof JSDOM !== 'undefined') {
|
|
20
|
+
saved = {};
|
|
21
|
+
for (const name of GLOBALS) {
|
|
22
|
+
saved[name] = Object.getOwnPropertyDescriptor(global, name);
|
|
23
|
+
}
|
|
24
|
+
const dom = new JSDOM('<!DOCTYPE html><body></body>', { url: PAGE_URL });
|
|
20
25
|
global.document = dom.window.document;
|
|
21
26
|
global.window = dom.window;
|
|
22
27
|
global.location = dom.window.location;
|
|
@@ -28,11 +33,11 @@ describe('[LBRU] LoginButton URL cleanup after redirect', function () {
|
|
|
28
33
|
|
|
29
34
|
after(() => {
|
|
30
35
|
utils.fetchGet = originalFetchGet;
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
if (saved == null) return;
|
|
37
|
+
for (const name of GLOBALS) {
|
|
38
|
+
if (saved[name]) Object.defineProperty(global, name, saved[name]);
|
|
39
|
+
else delete global[name];
|
|
40
|
+
}
|
|
36
41
|
});
|
|
37
42
|
|
|
38
43
|
it('[LBR1] strips consumed prYv* params from the visible URL', async () => {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
/* global describe, it, afterEach, expect, pryv */
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* [ARQC] The `consent` sidecar of an auth request travels untouched.
|
|
9
|
+
*
|
|
10
|
+
* The sidecar is how an app says which permissions are required, which are
|
|
11
|
+
* offered pre-selected, and which the user must opt into. This library must
|
|
12
|
+
* neither invent it nor filter it: it posts what the caller gave it and
|
|
13
|
+
* hands back what the core answered. These pin that, so a future "helpful"
|
|
14
|
+
* field whitelist in `startAccessRequest` cannot silently drop consent
|
|
15
|
+
* semantics on their way to the server.
|
|
16
|
+
*
|
|
17
|
+
* Driven through a stubbed global fetch rather than a live core: what is
|
|
18
|
+
* being tested is this library's pass-through, and a core that predates
|
|
19
|
+
* the sidecar is one of the cases.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const SERVICE_INFO = {
|
|
23
|
+
register: 'https://reg.test.local/',
|
|
24
|
+
access: 'https://reg.test.local/access',
|
|
25
|
+
api: 'https://{username}.test.local/',
|
|
26
|
+
name: 'Test'
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const CONSENT = {
|
|
30
|
+
allowUserChoice: true,
|
|
31
|
+
mandatory: ['diary'],
|
|
32
|
+
optIn: ['weight']
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const REQUEST = {
|
|
36
|
+
requestingAppId: 'jslib-test',
|
|
37
|
+
requestedPermissions: [
|
|
38
|
+
{ streamId: 'diary', level: 'read', defaultName: 'Journal' },
|
|
39
|
+
{ streamId: 'weight', level: 'read', defaultName: 'Weight' }
|
|
40
|
+
],
|
|
41
|
+
consent: CONSENT
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
describe('[ARQC] auth-request consent sidecar', function () {
|
|
45
|
+
let realFetch;
|
|
46
|
+
let posted;
|
|
47
|
+
|
|
48
|
+
/** Stub fetch: serve service-info, capture the access POST, answer it. */
|
|
49
|
+
function stubFetch (accessResponse) {
|
|
50
|
+
realFetch = global.fetch;
|
|
51
|
+
posted = [];
|
|
52
|
+
global.fetch = async function (url, options) {
|
|
53
|
+
if (String(url).includes('/access')) {
|
|
54
|
+
posted.push(JSON.parse(options.body));
|
|
55
|
+
return {
|
|
56
|
+
ok: true,
|
|
57
|
+
status: 201,
|
|
58
|
+
json: async () => accessResponse
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return { ok: true, status: 200, json: async () => SERVICE_INFO };
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
afterEach(function () {
|
|
66
|
+
if (realFetch) global.fetch = realFetch;
|
|
67
|
+
realFetch = null;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('[SARC1] posts the consent sidecar verbatim', async function () {
|
|
71
|
+
stubFetch({ key: 'k1', authUrl: 'https://auth/', poll: 'https://poll/k1', poll_rate_ms: 1000 });
|
|
72
|
+
const service = new pryv.Service('https://reg.test.local/service/info');
|
|
73
|
+
await service.startAccessRequest(REQUEST);
|
|
74
|
+
expect(posted).to.have.lengthOf(1);
|
|
75
|
+
expect(posted[0].consent).to.deep.equal(CONSENT);
|
|
76
|
+
// And the permissions stay plain: the annotations live in the sidecar,
|
|
77
|
+
// never inside an entry (the API rejects unknown per-entry fields).
|
|
78
|
+
expect(posted[0].requestedPermissions).to.deep.equal(REQUEST.requestedPermissions);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('[SARC2] returns the consent form a core echoes back, and nothing when it does not', async function () {
|
|
82
|
+
const form = {
|
|
83
|
+
allowUserChoice: true,
|
|
84
|
+
permissions: [
|
|
85
|
+
{ streamId: 'diary', level: 'read', defaultName: 'Journal', mandatory: true },
|
|
86
|
+
{ streamId: 'weight', level: 'read', defaultName: 'Weight', optIn: true }
|
|
87
|
+
]
|
|
88
|
+
};
|
|
89
|
+
stubFetch({
|
|
90
|
+
key: 'k1',
|
|
91
|
+
authUrl: 'https://auth/',
|
|
92
|
+
poll: 'https://poll/k1',
|
|
93
|
+
poll_rate_ms: 1000,
|
|
94
|
+
consent: form
|
|
95
|
+
});
|
|
96
|
+
const service = new pryv.Service('https://reg.test.local/service/info');
|
|
97
|
+
const env = await service.startAccessRequest(REQUEST);
|
|
98
|
+
// The echo is how an app knows the core understood the annotations.
|
|
99
|
+
expect(env.consent).to.deep.equal(form);
|
|
100
|
+
if (realFetch) { global.fetch = realFetch; realFetch = null; }
|
|
101
|
+
|
|
102
|
+
// A core that predates the sidecar answers without it. The request
|
|
103
|
+
// still succeeds and the consent screen falls back to all-or-nothing,
|
|
104
|
+
// so the absence is the signal, not an error.
|
|
105
|
+
stubFetch({ key: 'k2', authUrl: 'https://auth/', poll: 'https://poll/k2', poll_rate_ms: 1000 });
|
|
106
|
+
const older = new pryv.Service('https://reg.test.local/service/info');
|
|
107
|
+
const envOld = await older.startAccessRequest(REQUEST);
|
|
108
|
+
expect(envOld.key).to.equal('k2');
|
|
109
|
+
expect(envOld).to.not.have.property('consent');
|
|
110
|
+
});
|
|
111
|
+
});
|