pryv 3.10.0 → 3.12.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 +15 -0
- package/package.json +1 -1
- package/src/Auth/AuthController.js +15 -2
- package/src/Service.js +23 -2
- package/src/SharedSecrets.js +14 -6
- package/src/index.d.ts +77 -1
- package/src/lib/MfaRequiredError.js +6 -1
- package/test/Browser.AuthController.test.js +74 -1
- package/test/Service.accessRequestConsent.test.js +111 -0
- package/test/Service.mfa.test.js +25 -0
- package/test/Service.test.js +2 -2
- package/test/SharedSecrets.test.js +4 -1
- package/test/browser-index.html +2 -3
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,15 @@ 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.
|
|
257
|
+
// the LoginButton's logout confirm re-initializing to INITIALIZED) would
|
|
258
|
+
// otherwise overwrite `this._state`, so later listeners in this loop would
|
|
259
|
+
// receive the wrong state (a logout would deliver INITIALIZED instead of
|
|
260
|
+
// SIGNOUT to the app's onStateChange).
|
|
248
261
|
this.stateChangeListeners.forEach((listener) => {
|
|
249
262
|
try {
|
|
250
|
-
listener(
|
|
263
|
+
listener(newState);
|
|
251
264
|
} catch (e) {
|
|
252
265
|
console.log('Error during set state ()', e);
|
|
253
266
|
}
|
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
|
/**
|
package/src/SharedSecrets.js
CHANGED
|
@@ -86,15 +86,19 @@ async function hmacSha256Hex (verifierSecret, message) {
|
|
|
86
86
|
* @param {number} params.ttl seconds the secret stays redeemable (required)
|
|
87
87
|
* @param {string} params.title shown to the account owner (required)
|
|
88
88
|
* @param {Object} params.onConsumed `{ message, returnUrl? }` shown once spent
|
|
89
|
-
* @param {*} params.secret the payload
|
|
89
|
+
* @param {*} params.secret the payload: any non-null JSON value (the server
|
|
90
|
+
* refuses `null` and `undefined` with `shared-secret-missing-secret`)
|
|
90
91
|
* @param {Object} [params.signature] `{ type: 'secret', value }` or
|
|
91
92
|
* `{ type: 'hmac-sha256', verifierSecret }`. For the HMAC form the key
|
|
92
93
|
* material is generated here so the proof can be bound before creation.
|
|
93
|
-
* @returns {Promise<Object>} `{ id, key, expires, ... }
|
|
94
|
+
* @returns {Promise<Object>} `{ id, key, expires, ... }`; `key` is returned
|
|
94
95
|
* only here and cannot be recovered later.
|
|
95
96
|
*/
|
|
96
97
|
async function create (connection, params) {
|
|
97
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>} */
|
|
98
102
|
const body = { ...rest };
|
|
99
103
|
let key = null;
|
|
100
104
|
|
|
@@ -107,12 +111,12 @@ async function create (connection, params) {
|
|
|
107
111
|
type: 'hmac-sha256',
|
|
108
112
|
value: await hmacSha256Hex(signature.verifierSecret, random)
|
|
109
113
|
};
|
|
110
|
-
const
|
|
111
|
-
return { ...
|
|
114
|
+
const hmacRes = /** @type {any} */ (await connection.post('shared-secrets', body));
|
|
115
|
+
return { ...hmacRes.sharedSecret, key: hmacRes.sharedSecret.id + '.' + random };
|
|
112
116
|
}
|
|
113
117
|
|
|
114
118
|
if (signature != null) body.signature = signature;
|
|
115
|
-
const res = await connection.post('shared-secrets', body);
|
|
119
|
+
const res = /** @type {any} */ (await connection.post('shared-secrets', body));
|
|
116
120
|
key = res.sharedSecret.key;
|
|
117
121
|
return { ...res.sharedSecret, key };
|
|
118
122
|
}
|
|
@@ -148,7 +152,11 @@ async function retrieve (apiEndpoint, key, options = {}) {
|
|
|
148
152
|
});
|
|
149
153
|
const parsed = await res.json();
|
|
150
154
|
if (!res.ok) {
|
|
151
|
-
|
|
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
|
+
);
|
|
152
160
|
err.id = parsed?.error?.id;
|
|
153
161
|
err.returnUrl = parsed?.error?.data?.returnUrl;
|
|
154
162
|
throw err;
|
package/src/index.d.ts
CHANGED
|
@@ -172,12 +172,14 @@ declare module 'pryv' {
|
|
|
172
172
|
* Thrown by `Service.login` when the platform returned `{ mfaToken }`
|
|
173
173
|
* instead of `{ token }`. Carries the `mfaToken` so the consumer can
|
|
174
174
|
* call `Service.mfaVerify(userId, err.mfaToken, code)` after prompting
|
|
175
|
-
* the user for their
|
|
175
|
+
* the user for their code. `method` (when present) says which factor to
|
|
176
|
+
* prompt for: `'totp'` (authenticator app) or `'sms'`.
|
|
176
177
|
*/
|
|
177
178
|
export class MfaRequiredError extends PryvError {
|
|
178
179
|
constructor(mfaToken: string, response: Response, body?: any);
|
|
179
180
|
name: 'MfaRequiredError';
|
|
180
181
|
mfaToken: string;
|
|
182
|
+
method?: 'totp' | 'sms' | string;
|
|
181
183
|
}
|
|
182
184
|
|
|
183
185
|
/** Catalogue of Pryv API error ids (mirrors open-pryv.io ErrorIds.js). */
|
|
@@ -869,8 +871,16 @@ declare module 'pryv' {
|
|
|
869
871
|
authUrl: string;
|
|
870
872
|
poll: string;
|
|
871
873
|
pollRateMs: number;
|
|
874
|
+
/** Echoed only by a core that understood `authRequest.consent`. */
|
|
875
|
+
consent?: AuthRequestConsentForm;
|
|
872
876
|
}>;
|
|
873
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>;
|
|
874
884
|
|
|
875
885
|
static buildAPIEndpoint(
|
|
876
886
|
serviceInfo: ServiceInfo,
|
|
@@ -879,12 +889,74 @@ declare module 'pryv' {
|
|
|
879
889
|
): string;
|
|
880
890
|
}
|
|
881
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
|
+
|
|
882
905
|
export type AuthRequestedPermission = {
|
|
883
906
|
streamId: Identifier;
|
|
884
907
|
defaultName: string;
|
|
885
908
|
level: PermissionLevel;
|
|
886
909
|
};
|
|
887
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
|
+
|
|
888
960
|
export type States =
|
|
889
961
|
| 'ERROR'
|
|
890
962
|
| 'LOADING'
|
|
@@ -917,6 +989,7 @@ declare module 'pryv' {
|
|
|
917
989
|
level: PermissionLevel;
|
|
918
990
|
defaultName: string;
|
|
919
991
|
}>;
|
|
992
|
+
consent?: AuthRequestConsentForm;
|
|
920
993
|
requestingAppId: string;
|
|
921
994
|
returnUrl?: string | null;
|
|
922
995
|
serviceInfo?: ServiceInfo;
|
|
@@ -954,6 +1027,7 @@ declare module 'pryv' {
|
|
|
954
1027
|
poll_rate_ms: number;
|
|
955
1028
|
requestingAppId: string;
|
|
956
1029
|
requestedPermissions: AuthRequestedPermission[];
|
|
1030
|
+
consent?: AuthRequestConsentForm;
|
|
957
1031
|
lang?: string;
|
|
958
1032
|
returnURL?: string;
|
|
959
1033
|
clientData?: KeyValue;
|
|
@@ -968,6 +1042,7 @@ declare module 'pryv' {
|
|
|
968
1042
|
requestingAppId: string;
|
|
969
1043
|
languageCode?: string;
|
|
970
1044
|
requestedPermissions: AuthRequestedPermission[];
|
|
1045
|
+
consent?: AuthRequestConsent;
|
|
971
1046
|
returnUrl?: string | boolean;
|
|
972
1047
|
referer?: string;
|
|
973
1048
|
clientData?: KeyValue;
|
|
@@ -1200,6 +1275,7 @@ declare module 'pryv' {
|
|
|
1200
1275
|
MfaRequiredError: typeof MfaRequiredError;
|
|
1201
1276
|
ERRORS: typeof ERRORS;
|
|
1202
1277
|
version: version;
|
|
1278
|
+
connectFromKey: typeof connectFromKey;
|
|
1203
1279
|
};
|
|
1204
1280
|
|
|
1205
1281
|
export default pryv;
|
|
@@ -8,7 +8,10 @@ const PryvError = require('./PryvError');
|
|
|
8
8
|
/**
|
|
9
9
|
* Thrown by `Service.login` when the platform replied with `{ mfaToken }`
|
|
10
10
|
* instead of `{ token }`. Consumers catch this, prompt the user for the
|
|
11
|
-
*
|
|
11
|
+
* code, then call `Service.mfaVerify(userId, err.mfaToken, code)`.
|
|
12
|
+
*
|
|
13
|
+
* `err.method` (when present) tells the app which factor to prompt for:
|
|
14
|
+
* `'totp'` (an authenticator-app code) or `'sms'` (a code sent by SMS).
|
|
12
15
|
*
|
|
13
16
|
* try { conn = await service.login(u, p, app) }
|
|
14
17
|
* catch (err) {
|
|
@@ -33,6 +36,8 @@ class MfaRequiredError extends PryvError {
|
|
|
33
36
|
this.name = 'MfaRequiredError';
|
|
34
37
|
/** @type {string} */
|
|
35
38
|
this.mfaToken = mfaToken;
|
|
39
|
+
/** @type {string|undefined} The MFA method to prompt for: 'totp' | 'sms'. */
|
|
40
|
+
this.method = body && body.mfaMethod;
|
|
36
41
|
this.id = (apiErr && apiErr.id) || 'mfa-required';
|
|
37
42
|
this.status = response && response.status;
|
|
38
43
|
this.response = { body, status: response && response.status };
|
|
@@ -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,75 @@ 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
|
+
// Mimic LoginButton (registered after the external app listener): on
|
|
116
|
+
// SIGNOUT it confirms + re-initializes, synchronously driving the state
|
|
117
|
+
// to INITIALIZED during the same dispatch.
|
|
118
|
+
a.stateChangeListeners.push((s) => {
|
|
119
|
+
if (s.status === AuthStates.SIGNOUT) a.state = { status: AuthStates.INITIALIZED, serviceInfo: {} };
|
|
120
|
+
});
|
|
121
|
+
a._state = { status: AuthStates.AUTHORIZED };
|
|
122
|
+
seen.length = 0;
|
|
123
|
+
await a.handleClick(); // AUTHORIZED -> SIGNOUT
|
|
124
|
+
expect(seen).to.include(AuthStates.SIGNOUT);
|
|
125
|
+
expect(seen[seen.length - 1]).to.equal(AuthStates.INITIALIZED);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('[ACLO2] a listener re-entering the setter must not corrupt the state delivered to later listeners', function () {
|
|
129
|
+
// The setter must hand each listener the state dispatched to IT, not a value
|
|
130
|
+
// an earlier listener mutated mid-dispatch. Worst case: a re-entrant listener
|
|
131
|
+
// registered BEFORE the recorder.
|
|
132
|
+
const a = makeAuth(() => {});
|
|
133
|
+
const seen = [];
|
|
134
|
+
let reentered = false;
|
|
135
|
+
a.stateChangeListeners.push((s) => {
|
|
136
|
+
if (s.status === AuthStates.SIGNOUT && !reentered) {
|
|
137
|
+
reentered = true;
|
|
138
|
+
a.state = { status: AuthStates.INITIALIZED, serviceInfo: {} };
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
a.stateChangeListeners.push((s) => seen.push(s.status));
|
|
142
|
+
seen.length = 0;
|
|
143
|
+
a.state = { status: AuthStates.SIGNOUT };
|
|
144
|
+
// The re-entrant INITIALIZED dispatch completes first (inner), then the outer
|
|
145
|
+
// SIGNOUT dispatch resumes to the recorder; the recorder MUST see SIGNOUT.
|
|
146
|
+
expect(seen).to.deep.equal([AuthStates.INITIALIZED, AuthStates.SIGNOUT]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('[ACLO3] init() registers the LoginButton listener only once, so logout confirms exactly once', async function () {
|
|
150
|
+
let confirms = 0;
|
|
151
|
+
const fakeButton = {
|
|
152
|
+
onStateChange: (s) => { if (s.status === AuthStates.SIGNOUT) confirms++; },
|
|
153
|
+
getAuthorizationData: () => null
|
|
154
|
+
};
|
|
155
|
+
const a = new AuthController({
|
|
156
|
+
authRequest: { requestingAppId: 'test-app', requestedPermissions: [] }
|
|
157
|
+
}, service, fakeButton);
|
|
158
|
+
await a.init();
|
|
159
|
+
const nAfterFirst = a.stateChangeListeners.length;
|
|
160
|
+
await a.init(); // the confirmed-logout path re-inits the same controller
|
|
161
|
+
await a.init();
|
|
162
|
+
expect(a.stateChangeListeners.length).to.equal(nAfterFirst); // no compounding
|
|
163
|
+
confirms = 0;
|
|
164
|
+
a.state = { status: AuthStates.SIGNOUT }; // one dispatch -> one confirm
|
|
165
|
+
expect(confirms).to.equal(1);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
95
168
|
});
|
|
@@ -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
|
+
});
|
package/test/Service.mfa.test.js
CHANGED
|
@@ -90,5 +90,30 @@ describe('[MFLX] Service MFA', function () {
|
|
|
90
90
|
expect(err.id).to.equal('custom-id');
|
|
91
91
|
expect(err.message).to.equal('custom msg');
|
|
92
92
|
});
|
|
93
|
+
|
|
94
|
+
it('[MERC] surfaces the MFA method from the API body when present', function () {
|
|
95
|
+
const totp = new pryv.MfaRequiredError(
|
|
96
|
+
'tok-1',
|
|
97
|
+
{ status: 200 },
|
|
98
|
+
{ mfaToken: 'tok-1', mfaMethod: 'totp' }
|
|
99
|
+
);
|
|
100
|
+
expect(totp.method).to.equal('totp');
|
|
101
|
+
|
|
102
|
+
const sms = new pryv.MfaRequiredError(
|
|
103
|
+
'tok-2',
|
|
104
|
+
{ status: 200 },
|
|
105
|
+
{ mfaToken: 'tok-2', mfaMethod: 'sms' }
|
|
106
|
+
);
|
|
107
|
+
expect(sms.method).to.equal('sms');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('[MERD] leaves method undefined when the body omits it (older servers)', function () {
|
|
111
|
+
const err = new pryv.MfaRequiredError(
|
|
112
|
+
'tok-3',
|
|
113
|
+
{ status: 200 },
|
|
114
|
+
{ mfaToken: 'tok-3' }
|
|
115
|
+
);
|
|
116
|
+
expect(err.method).to.equal(undefined);
|
|
117
|
+
});
|
|
93
118
|
});
|
|
94
119
|
});
|
package/test/Service.test.js
CHANGED
|
@@ -82,8 +82,8 @@ describe('[SERX] Service', function () {
|
|
|
82
82
|
if (isDnsLess) {
|
|
83
83
|
expect(error.message).to.include('Unknown user');
|
|
84
84
|
} else {
|
|
85
|
-
// fetch
|
|
86
|
-
expect(error.message).to.match(/Load failed|fetch failed|getaddrinfo ENOTFOUND|Request has been terminated/);
|
|
85
|
+
// fetch error message differs per environment (Chrome says 'Failed to fetch')
|
|
86
|
+
expect(error.message).to.match(/Load failed|fetch failed|Failed to fetch|getaddrinfo ENOTFOUND|Request has been terminated/);
|
|
87
87
|
}
|
|
88
88
|
return;
|
|
89
89
|
}
|
|
@@ -89,7 +89,10 @@ describe('[SSEC] shared-secrets helper', function () {
|
|
|
89
89
|
const seen = new Set();
|
|
90
90
|
for (let i = 0; i < 50; i++) {
|
|
91
91
|
const out = await SharedSecrets.create(conn, {
|
|
92
|
-
ttl: 60,
|
|
92
|
+
ttl: 60,
|
|
93
|
+
title: 't',
|
|
94
|
+
onConsumed: { message: 'm' },
|
|
95
|
+
secret: 1,
|
|
93
96
|
signature: { type: 'hmac-sha256', verifierSecret: 'V' }
|
|
94
97
|
});
|
|
95
98
|
const { randomPart } = SharedSecrets.parseKey(out.key);
|
package/test/browser-index.html
CHANGED
|
@@ -2,17 +2,16 @@
|
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
4
|
<title>Pryv JS lib browser tests</title>
|
|
5
|
-
<link rel="stylesheet" href="https://unpkg.com/mocha/mocha.css" />
|
|
5
|
+
<link rel="stylesheet" href="https://unpkg.com/mocha@10.8.2/mocha.css" />
|
|
6
6
|
</head>
|
|
7
7
|
|
|
8
8
|
<body>
|
|
9
9
|
<div id="mocha"></div>
|
|
10
10
|
<script src="https://unpkg.com/chai@4.3.7/chai.js"></script>
|
|
11
|
-
<script src="https://unpkg.com/mocha/mocha.js"></script>
|
|
11
|
+
<script src="https://unpkg.com/mocha@10.8.2/mocha.js"></script>
|
|
12
12
|
|
|
13
13
|
<script class="mocha-init">
|
|
14
14
|
mocha.setup('bdd');
|
|
15
|
-
mocha.setup({ignoreLeaks: true});
|
|
16
15
|
</script>
|
|
17
16
|
|
|
18
17
|
<!-- Files cf. webpack config -->
|