pryv 3.13.0 → 3.15.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 +10 -1
- package/package.json +1 -1
- package/src/Auth/AuthController.js +33 -8
- package/src/Auth/ProfileStore.js +19 -1
- package/src/Auth/index.js +5 -2
- package/src/Browser/LoginButton.js +127 -17
- package/src/Connection.js +19 -13
- package/src/Service.js +29 -15
- package/src/index.d.ts +76 -7
- package/src/lib/MfaRequiredError.js +2 -0
- package/src/lib/PryvError.js +4 -0
- package/src/lib/pollUrls.js +71 -0
- package/src/utils.js +7 -1
- package/test/AuthController.test.js +111 -5
- package/test/Connection.apiOneError.test.js +105 -0
- package/test/LoginButton.test.js +3 -1
- package/test/LoginButton.urlCleanup.test.js +453 -14
- package/test/PryvError.test.js +10 -0
- package/test/Service.accessRequest.test.js +4 -4
- package/test/Service.accessRequestConsent.test.js +16 -0
- package/test/Service.errorBodies.test.js +82 -0
- package/test/Service.mfa.test.js +17 -4
- package/test/Service.pollUrlByKey.test.js +114 -0
|
@@ -2,31 +2,50 @@
|
|
|
2
2
|
* @license
|
|
3
3
|
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
4
|
*/
|
|
5
|
-
/* global describe, it, before, after, expect, JSDOM */
|
|
5
|
+
/* global describe, it, before, beforeEach, after, afterEach, expect, JSDOM */
|
|
6
6
|
|
|
7
7
|
const LoginButton = require('../src/Browser/LoginButton');
|
|
8
|
+
const SharedSecrets = require('../src/SharedSecrets');
|
|
9
|
+
const handoff = require('../src/lib/handoff');
|
|
10
|
+
const pollUrls = require('../src/lib/pollUrls');
|
|
8
11
|
const utils = require('../src/utils');
|
|
9
12
|
|
|
13
|
+
// Node only (JSDOM): these tests drive finishAuthProcessAfterRedirection
|
|
14
|
+
// against a page URL of their own. A return is honoured only for the auth
|
|
15
|
+
// request the page started, which the button keeps in sessionStorage under
|
|
16
|
+
// `<cookieKey>-authflow` before navigating to the auth page; `seedFlow`
|
|
17
|
+
// stands for that start.
|
|
18
|
+
const COOKIE_KEY = 'pryv-libjs-test-app';
|
|
19
|
+
const THIS = { _cookieKey: COOKIE_KEY };
|
|
20
|
+
|
|
21
|
+
function seedFlow (key, poll, cookieKey = COOKIE_KEY) {
|
|
22
|
+
global.window.sessionStorage.setItem(cookieKey + '-authflow', JSON.stringify({ key, poll, authUrl: 'https://ui.test.local/access/access.html?key=' + key }));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function storedFlow (cookieKey = COOKIE_KEY) {
|
|
26
|
+
const raw = global.window.sessionStorage.getItem(cookieKey + '-authflow');
|
|
27
|
+
return raw == null ? null : JSON.parse(raw);
|
|
28
|
+
}
|
|
29
|
+
|
|
10
30
|
describe('[LBRU] LoginButton URL cleanup after redirect', function () {
|
|
11
31
|
const PAGE_URL = 'http://localhost/app?x=1&prYvpoll=http%3A%2F%2Flocalhost%2Fpoll%2Fkey123&prYvstatus=ACCEPTED';
|
|
12
32
|
const GLOBALS = ['document', 'window', 'location', 'navigator'];
|
|
13
33
|
let saved;
|
|
14
34
|
let originalFetchGet;
|
|
15
35
|
|
|
16
|
-
before(()
|
|
36
|
+
before(function () {
|
|
37
|
+
if (typeof JSDOM === 'undefined') this.skip();
|
|
17
38
|
// Always our own page: reusing a DOM left by another file would test that
|
|
18
|
-
// page's URL, not PAGE_URL.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
saved[name] = Object.getOwnPropertyDescriptor(global, name);
|
|
23
|
-
}
|
|
24
|
-
const dom = new JSDOM('<!DOCTYPE html><body></body>', { url: PAGE_URL });
|
|
25
|
-
global.document = dom.window.document;
|
|
26
|
-
global.window = dom.window;
|
|
27
|
-
global.location = dom.window.location;
|
|
28
|
-
global.navigator = { userAgent: 'Safari' };
|
|
39
|
+
// page's URL, not PAGE_URL.
|
|
40
|
+
saved = {};
|
|
41
|
+
for (const name of GLOBALS) {
|
|
42
|
+
saved[name] = Object.getOwnPropertyDescriptor(global, name);
|
|
29
43
|
}
|
|
44
|
+
const dom = new JSDOM('<!DOCTYPE html><body></body>', { url: PAGE_URL });
|
|
45
|
+
global.document = dom.window.document;
|
|
46
|
+
global.window = dom.window;
|
|
47
|
+
global.location = dom.window.location;
|
|
48
|
+
global.navigator = { userAgent: 'Safari' };
|
|
30
49
|
originalFetchGet = utils.fetchGet;
|
|
31
50
|
utils.fetchGet = async () => ({ body: { status: 'ACCEPTED' } });
|
|
32
51
|
});
|
|
@@ -41,9 +60,429 @@ describe('[LBRU] LoginButton URL cleanup after redirect', function () {
|
|
|
41
60
|
});
|
|
42
61
|
|
|
43
62
|
it('[LBR1] strips consumed prYv* params from the visible URL', async () => {
|
|
63
|
+
seedFlow('key123', 'http://localhost/poll/key123');
|
|
44
64
|
const authController = { serviceInfo: {} };
|
|
45
|
-
await LoginButton.prototype.finishAuthProcessAfterRedirection.call(
|
|
65
|
+
await LoginButton.prototype.finishAuthProcessAfterRedirection.call(THIS, authController);
|
|
46
66
|
expect(authController.state).to.eql({ status: 'ACCEPTED' });
|
|
47
67
|
expect(global.window.location.href).to.equal('http://localhost/app?x=1');
|
|
48
68
|
});
|
|
49
69
|
});
|
|
70
|
+
|
|
71
|
+
/** Shared JSDOM page + stubs for the redirect-return suites below. */
|
|
72
|
+
function redirectReturnFixture () {
|
|
73
|
+
const GLOBALS = ['document', 'window', 'location', 'navigator'];
|
|
74
|
+
const f = { fetchCalls: [], retrieveCalls: [] };
|
|
75
|
+
let saved;
|
|
76
|
+
let originalFetchGet;
|
|
77
|
+
let originalRetrieve;
|
|
78
|
+
|
|
79
|
+
f.setPage = function (url) {
|
|
80
|
+
const dom = new JSDOM('<!DOCTYPE html><body></body>', { url });
|
|
81
|
+
global.document = dom.window.document;
|
|
82
|
+
global.window = dom.window;
|
|
83
|
+
global.location = dom.window.location;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** Poll answers `body` (and `response`), recording every URL fetched. */
|
|
87
|
+
f.poll = function (body, response) {
|
|
88
|
+
utils.fetchGet = async (url) => {
|
|
89
|
+
f.fetchCalls.push(url);
|
|
90
|
+
return { response, body: typeof body === 'function' ? body() : body };
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
f.retrieve = function (answer) {
|
|
95
|
+
SharedSecrets.retrieve = async (apiEndpoint, key) => {
|
|
96
|
+
f.retrieveCalls.push({ apiEndpoint, key });
|
|
97
|
+
return answer();
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
before(function () {
|
|
102
|
+
if (typeof JSDOM === 'undefined') this.skip();
|
|
103
|
+
saved = {};
|
|
104
|
+
for (const name of GLOBALS) saved[name] = Object.getOwnPropertyDescriptor(global, name);
|
|
105
|
+
Object.defineProperty(global, 'navigator', { value: { userAgent: 'Safari' }, configurable: true, writable: true });
|
|
106
|
+
originalFetchGet = utils.fetchGet;
|
|
107
|
+
originalRetrieve = SharedSecrets.retrieve;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
beforeEach(() => {
|
|
111
|
+
f.fetchCalls.length = 0;
|
|
112
|
+
f.retrieveCalls.length = 0;
|
|
113
|
+
// the poll-URL map is process-wide: other files may have filled it
|
|
114
|
+
pollUrls.clear();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
afterEach(() => {
|
|
118
|
+
utils.fetchGet = originalFetchGet;
|
|
119
|
+
SharedSecrets.retrieve = originalRetrieve;
|
|
120
|
+
handoff.cacheClear();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
after(() => {
|
|
124
|
+
if (saved == null) return;
|
|
125
|
+
for (const name of GLOBALS) {
|
|
126
|
+
if (saved[name]) Object.defineProperty(global, name, saved[name]);
|
|
127
|
+
else delete global[name];
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
return f;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function fakeService (serviceInfo) {
|
|
135
|
+
return {
|
|
136
|
+
infoSync: () => serviceInfo,
|
|
137
|
+
assets: async () => ({
|
|
138
|
+
loginButtonLoadCSS: async () => {},
|
|
139
|
+
loginButtonGetMessages: async () => ({ LOADING: 'Loading...' })
|
|
140
|
+
})
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* [LBRH] Return from a sign-in by redirection when the core delivered the
|
|
146
|
+
* credentials through a one-time shared secret: the ACCEPTED poll body has a
|
|
147
|
+
* token-less apiEndpoint and a `handoff` key, which must be redeemed exactly
|
|
148
|
+
* as the popup (polling) path does. Observed: the state handed to the button
|
|
149
|
+
* (what ends up in the cookie) and the retrieve call.
|
|
150
|
+
*/
|
|
151
|
+
describe('[LBRH] LoginButton redirect return with credential hand-off', function () {
|
|
152
|
+
const f = redirectReturnFixture();
|
|
153
|
+
const HANDOFF_BODY = () => ({
|
|
154
|
+
status: 'ACCEPTED',
|
|
155
|
+
username: 'alice',
|
|
156
|
+
apiEndpoint: 'https://alice.test.local/',
|
|
157
|
+
handoff: { type: 'shared-secret', key: 'evt1.' + 'a'.repeat(40) }
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('[LBH1] redeems the hand-off: token-bearing state, key kept as the flow key', async () => {
|
|
161
|
+
f.setPage('http://localhost/app?prYvpoll=https%3A%2F%2Freg.test.local%2Faccess%2Fflowkey1&prYvstatus=ACCEPTED');
|
|
162
|
+
seedFlow('flowkey1', 'https://reg.test.local/access/flowkey1');
|
|
163
|
+
f.poll(HANDOFF_BODY);
|
|
164
|
+
f.retrieve(() => ({ secret: { username: 'alice', token: 'tok-123', apiEndpoint: 'https://alice.test.local/' } }));
|
|
165
|
+
const authController = { serviceInfo: { access: 'https://reg.test.local/access/' } };
|
|
166
|
+
await LoginButton.prototype.finishAuthProcessAfterRedirection.call(THIS, authController);
|
|
167
|
+
|
|
168
|
+
const state = authController.state;
|
|
169
|
+
expect(state.status).to.equal('ACCEPTED');
|
|
170
|
+
expect(state.token).to.equal('tok-123');
|
|
171
|
+
expect(state.apiEndpoint).to.equal('https://tok-123@alice.test.local/');
|
|
172
|
+
expect(state.handoff).to.equal(undefined);
|
|
173
|
+
expect(state.profile.apiEndpoint).to.equal('https://tok-123@alice.test.local/');
|
|
174
|
+
expect(f.retrieveCalls).to.have.length(1);
|
|
175
|
+
expect(authController._authFlowKey).to.equal('flowkey1');
|
|
176
|
+
// cached under the flow key: a later connectFromKey reuses it
|
|
177
|
+
expect(handoff.cacheGet('flowkey1').token).to.equal('tok-123');
|
|
178
|
+
expect(global.window.location.href).to.equal('http://localhost/app');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('[LBH2] takes the flow key from pryvKey when present', async () => {
|
|
182
|
+
f.setPage('http://localhost/app?pryvKey=flowkey2');
|
|
183
|
+
seedFlow('flowkey2', 'https://reg.test.local/access/flowkey2');
|
|
184
|
+
f.poll(HANDOFF_BODY);
|
|
185
|
+
f.retrieve(() => ({ secret: { username: 'alice', token: 'tok-456', apiEndpoint: 'https://alice.test.local/' } }));
|
|
186
|
+
const authController = { serviceInfo: { access: 'https://reg.test.local/access/' } };
|
|
187
|
+
await LoginButton.prototype.finishAuthProcessAfterRedirection.call(THIS, authController);
|
|
188
|
+
|
|
189
|
+
expect(authController.state.token).to.equal('tok-456');
|
|
190
|
+
expect(authController._authFlowKey).to.equal('flowkey2');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('[LBH4] end to end: real button + controller store a usable sign-in, keep the legacy listener shape, sign-out clears the cache', async () => {
|
|
194
|
+
f.setPage('http://localhost/app?prYvpoll=https%3A%2F%2Freg.test.local%2Faccess%2Fflowkey4&prYvkey=flowkey4&prYvstatus=ACCEPTED');
|
|
195
|
+
seedFlow('flowkey4', 'https://reg.test.local/access/flowkey4', 'pryv-libjs-lbh4-app');
|
|
196
|
+
f.poll(HANDOFF_BODY);
|
|
197
|
+
f.retrieve(() => ({ secret: { username: 'alice', token: 'tok-789', apiEndpoint: 'https://alice.test.local/' } }));
|
|
198
|
+
const external = [];
|
|
199
|
+
const loginBtn = new LoginButton({
|
|
200
|
+
onStateChange: (s) => external.push(s),
|
|
201
|
+
authRequest: { requestingAppId: 'lbh4-app', requestedPermissions: [], returnURL: 'self#' }
|
|
202
|
+
}, fakeService({ access: 'https://reg.test.local/access/', api: 'https://{username}.test.local/', name: 'Test' }));
|
|
203
|
+
await loginBtn.init();
|
|
204
|
+
|
|
205
|
+
expect(loginBtn.auth.state.status).to.equal('ACCEPTED');
|
|
206
|
+
expect(loginBtn.auth.state.apiEndpoint).to.equal('https://tok-789@alice.test.local/');
|
|
207
|
+
// redirect apps receive the legacy shape: credentials included, no `key`
|
|
208
|
+
const authorized = external.filter((s) => s.status === 'ACCEPTED');
|
|
209
|
+
expect(authorized).to.have.length(1);
|
|
210
|
+
expect(authorized[0].apiEndpoint).to.equal('https://tok-789@alice.test.local/');
|
|
211
|
+
expect(authorized[0].key).to.equal(undefined);
|
|
212
|
+
// the cookie holds a usable sign-in
|
|
213
|
+
expect(loginBtn.getAuthorizationData().apiEndpoint).to.equal('https://tok-789@alice.test.local/');
|
|
214
|
+
expect(global.window.location.href).to.equal('http://localhost/app');
|
|
215
|
+
expect(handoff.cacheGet('flowkey4').token).to.equal('tok-789');
|
|
216
|
+
// the auth page URL of the flow is known again after the redirect
|
|
217
|
+
expect(loginBtn.auth._authUrl).to.contain('ui.test.local');
|
|
218
|
+
|
|
219
|
+
await loginBtn.auth.signOut();
|
|
220
|
+
expect(handoff.cacheGet('flowkey4')).to.equal(null);
|
|
221
|
+
const stored = loginBtn.getAuthorizationData();
|
|
222
|
+
expect(stored == null || stored.deleted === true).to.equal(true);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('[LBH6] the button never stores a sign-in without a token', async () => {
|
|
226
|
+
f.setPage('http://localhost/app');
|
|
227
|
+
const loginBtn = new LoginButton({ authRequest: { requestingAppId: 'lbh6-app', requestedPermissions: [] } },
|
|
228
|
+
fakeService({ access: 'https://reg.test.local/access/', name: 'Test' }));
|
|
229
|
+
await loginBtn.init();
|
|
230
|
+
await loginBtn.onStateChange({ status: 'ACCEPTED', username: 'bob', apiEndpoint: 'https://bob.test.local/' });
|
|
231
|
+
expect(loginBtn.getAuthorizationData()).to.equal(undefined);
|
|
232
|
+
await loginBtn.onStateChange({ status: 'ACCEPTED', username: 'bob', apiEndpoint: 'https://tok@bob.test.local/' });
|
|
233
|
+
expect(loginBtn.getAuthorizationData().apiEndpoint).to.equal('https://tok@bob.test.local/');
|
|
234
|
+
await loginBtn.deleteAuthorizationData();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('[LBH5] a refused request returns to the sign-in button; an unknown key is an ERROR', async () => {
|
|
238
|
+
const authController = { serviceInfo: { access: 'https://reg.test.local/access/' } };
|
|
239
|
+
f.setPage('http://localhost/app?prYvpoll=https%3A%2F%2Freg.test.local%2Faccess%2Fflowkey5');
|
|
240
|
+
seedFlow('flowkey5', 'https://reg.test.local/access/flowkey5');
|
|
241
|
+
f.poll({ status: 'REFUSED', reasonID: 'REFUSED_BY_USER' }, { status: 403 });
|
|
242
|
+
await LoginButton.prototype.finishAuthProcessAfterRedirection.call(THIS, authController);
|
|
243
|
+
expect(authController.state.status).to.equal('INITIALIZED');
|
|
244
|
+
|
|
245
|
+
f.setPage('http://localhost/app?prYvpoll=https%3A%2F%2Freg.test.local%2Faccess%2Fflowkey6');
|
|
246
|
+
seedFlow('flowkey6', 'https://reg.test.local/access/flowkey6');
|
|
247
|
+
f.poll({ error: { id: 'unknown-access-key' } }, { status: 400 });
|
|
248
|
+
await LoginButton.prototype.finishAuthProcessAfterRedirection.call(THIS, authController);
|
|
249
|
+
expect(authController.state.status).to.equal('ERROR');
|
|
250
|
+
expect(authController.state.error).to.eql({ id: 'unknown-access-key' });
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('[LBH3] a failed redemption ends in ERROR, never a token-less AUTHORIZED', async () => {
|
|
254
|
+
f.setPage('http://localhost/app?prYvpoll=https%3A%2F%2Freg.test.local%2Faccess%2Fflowkey3');
|
|
255
|
+
seedFlow('flowkey3', 'https://reg.test.local/access/flowkey3');
|
|
256
|
+
f.poll(HANDOFF_BODY);
|
|
257
|
+
f.retrieve(() => { throw new Error('shared-secret-unavailable'); });
|
|
258
|
+
const authController = { serviceInfo: { access: 'https://reg.test.local/access/' } };
|
|
259
|
+
await LoginButton.prototype.finishAuthProcessAfterRedirection.call(THIS, authController);
|
|
260
|
+
|
|
261
|
+
expect(authController.state.status).to.equal('ERROR');
|
|
262
|
+
expect(authController.state.message).to.equal('Credential hand-off failed');
|
|
263
|
+
expect(authController.state.apiEndpoint).to.equal(undefined);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* [LBRB] A sign-in return is honoured only for the auth request this page
|
|
269
|
+
* started, and only the poll URL the server gave for that request is fetched.
|
|
270
|
+
* A link carrying another key or a foreign poll URL must not sign the page in
|
|
271
|
+
* (to an attacker's host, or into someone else's account). Observed: the URLs
|
|
272
|
+
* fetched (a test reading only the final state would pass against a fetch
|
|
273
|
+
* that happened), the retrieve calls, the state, the cookie and the stored flow.
|
|
274
|
+
*/
|
|
275
|
+
describe('[LBRB] LoginButton redirect return is bound to the flow this page started', function () {
|
|
276
|
+
const f = redirectReturnFixture();
|
|
277
|
+
const ACCEPTED = { status: 'ACCEPTED', username: 'alice', apiEndpoint: 'https://tok@alice.mc.example.com/' };
|
|
278
|
+
const CORE_POLL = 'https://core-a.mc.example.com/reg/access/k1';
|
|
279
|
+
const SERVICE_INFO = { access: 'https://access.mc.example.com/access/', api: 'https://{username}.mc.example.com/' };
|
|
280
|
+
|
|
281
|
+
function expectRefused (authController) {
|
|
282
|
+
expect(f.fetchCalls).to.have.length(0);
|
|
283
|
+
expect(f.retrieveCalls).to.have.length(0);
|
|
284
|
+
expect(authController.state.status).to.equal('ERROR');
|
|
285
|
+
expect(authController.state.error.id).to.equal('unexpected-auth-return');
|
|
286
|
+
expect(authController._authFlowKey).to.equal(undefined);
|
|
287
|
+
expect(global.window.location.href).to.equal('http://localhost/app');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
it('[LBRB1] a redirect sign-in stores the flow before navigating; a popup sign-in stores nothing', async () => {
|
|
291
|
+
f.setPage('http://localhost/app');
|
|
292
|
+
const service = fakeService(Object.assign({ name: 'Test' }, SERVICE_INFO));
|
|
293
|
+
const redirectBtn = new LoginButton({ authRequest: { requestingAppId: 'lbrb1-app', requestedPermissions: [], returnURL: 'self#' } }, service);
|
|
294
|
+
redirectBtn._cookieKey = 'pryv-libjs-lbrb1-app';
|
|
295
|
+
const navigated = [];
|
|
296
|
+
const savedLocation = Object.getOwnPropertyDescriptor(global, 'location');
|
|
297
|
+
Object.defineProperty(global, 'location', { value: { get href () { return navigated[navigated.length - 1]; }, set href (v) { navigated.push(v); } }, configurable: true, writable: true });
|
|
298
|
+
try {
|
|
299
|
+
await redirectBtn.onStateChange({ status: 'NEED_SIGNIN', key: 'k1', poll: CORE_POLL, authUrl: 'https://ui.example.com/access?key=k1' });
|
|
300
|
+
} finally {
|
|
301
|
+
Object.defineProperty(global, 'location', savedLocation);
|
|
302
|
+
}
|
|
303
|
+
expect(navigated).to.deep.equal(['https://ui.example.com/access?key=k1']);
|
|
304
|
+
expect(storedFlow('pryv-libjs-lbrb1-app')).to.deep.equal({ key: 'k1', poll: CORE_POLL, authUrl: 'https://ui.example.com/access?key=k1' });
|
|
305
|
+
|
|
306
|
+
const popupBtn = new LoginButton({ authRequest: { requestingAppId: 'lbrb1p-app', requestedPermissions: [] } }, service);
|
|
307
|
+
popupBtn._cookieKey = 'pryv-libjs-lbrb1p-app';
|
|
308
|
+
const originalOpen = global.window.open;
|
|
309
|
+
global.window.open = () => null;
|
|
310
|
+
try {
|
|
311
|
+
await popupBtn.onStateChange({ status: 'NEED_SIGNIN', key: 'k2', poll: CORE_POLL, authUrl: 'https://ui.example.com/access?key=k2' });
|
|
312
|
+
} finally { global.window.open = originalOpen; }
|
|
313
|
+
expect(storedFlow('pryv-libjs-lbrb1p-app')).to.equal(null);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('[LBRB2] a return with no flow started on this page is refused without any fetch', async () => {
|
|
317
|
+
f.setPage('http://localhost/app?prYvpoll=https%3A%2F%2Fevil.example%2Fpoll&prYvkey=x&prYvstatus=ACCEPTED');
|
|
318
|
+
f.poll(ACCEPTED);
|
|
319
|
+
const authController = { serviceInfo: SERVICE_INFO };
|
|
320
|
+
await LoginButton.prototype.finishAuthProcessAfterRedirection.call(THIS, authController);
|
|
321
|
+
expectRefused(authController);
|
|
322
|
+
// nothing from the page URL reaches the poll-URL map either
|
|
323
|
+
expect(pollUrls.lookup('x')).to.equal(null);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('[LBRB3] a return for another key is refused and keeps the pending flow', async () => {
|
|
327
|
+
f.setPage('http://localhost/app?prYvpoll=https%3A%2F%2Faccess.mc.example.com%2Faccess%2Fk2&prYvkey=k2');
|
|
328
|
+
seedFlow('k1', CORE_POLL);
|
|
329
|
+
f.poll(ACCEPTED);
|
|
330
|
+
const authController = { serviceInfo: SERVICE_INFO };
|
|
331
|
+
await LoginButton.prototype.finishAuthProcessAfterRedirection.call(THIS, authController);
|
|
332
|
+
expectRefused(authController);
|
|
333
|
+
expect(storedFlow().key).to.equal('k1');
|
|
334
|
+
expect(pollUrls.lookup('k2')).to.equal(null);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('[LBRB4] the stored (core) poll URL is fetched, never the one in the URL nor access + key; the flow is consumed', async () => {
|
|
338
|
+
f.setPage('http://localhost/app?prYvpoll=https%3A%2F%2Fevil.example%2Faccess%2Fk1&prYvkey=k1');
|
|
339
|
+
seedFlow('k1', CORE_POLL);
|
|
340
|
+
f.poll(ACCEPTED);
|
|
341
|
+
const authController = { serviceInfo: SERVICE_INFO };
|
|
342
|
+
await LoginButton.prototype.finishAuthProcessAfterRedirection.call(THIS, authController);
|
|
343
|
+
expect(f.fetchCalls).to.deep.equal([CORE_POLL]);
|
|
344
|
+
expect(authController.state.status).to.equal('ACCEPTED');
|
|
345
|
+
expect(storedFlow()).to.equal(null);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('[LBRB5] a return carrying only the key polls the stored URL', async () => {
|
|
349
|
+
f.setPage('http://localhost/app?pryvKey=k1');
|
|
350
|
+
seedFlow('k1', CORE_POLL);
|
|
351
|
+
f.poll(ACCEPTED);
|
|
352
|
+
const authController = { serviceInfo: SERVICE_INFO };
|
|
353
|
+
await LoginButton.prototype.finishAuthProcessAfterRedirection.call(THIS, authController);
|
|
354
|
+
expect(f.fetchCalls).to.deep.equal([CORE_POLL]);
|
|
355
|
+
expect(authController.state.status).to.equal('ACCEPTED');
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('[LBRB6] an apiEndpoint on a core host outside the api template is accepted (DNSless multi-core)', async () => {
|
|
359
|
+
f.setPage('http://localhost/app?prYvkey=k1');
|
|
360
|
+
seedFlow('k1', 'https://api2.example.com/reg/access/k1');
|
|
361
|
+
f.poll({ status: 'ACCEPTED', username: 'alice', apiEndpoint: 'https://tok@api2.example.com/alice/' });
|
|
362
|
+
const authController = { serviceInfo: SERVICE_INFO };
|
|
363
|
+
await LoginButton.prototype.finishAuthProcessAfterRedirection.call(THIS, authController);
|
|
364
|
+
expect(authController.state.status).to.equal('ACCEPTED');
|
|
365
|
+
expect(authController.state.apiEndpoint).to.equal('https://tok@api2.example.com/alice/');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('[LBRB7] a forged return through a real button stores nothing', async () => {
|
|
369
|
+
f.setPage('http://localhost/app?prYvpoll=https%3A%2F%2Fevil.example%2Fpoll&prYvkey=x');
|
|
370
|
+
f.poll(ACCEPTED);
|
|
371
|
+
const external = [];
|
|
372
|
+
const loginBtn = new LoginButton({
|
|
373
|
+
onStateChange: (s) => external.push(s),
|
|
374
|
+
authRequest: { requestingAppId: 'lbrb7-app', requestedPermissions: [], returnURL: 'self#' }
|
|
375
|
+
}, fakeService(Object.assign({ name: 'Test' }, SERVICE_INFO)));
|
|
376
|
+
await loginBtn.init();
|
|
377
|
+
expect(f.fetchCalls).to.have.length(0);
|
|
378
|
+
expect(loginBtn.auth.state.status).to.equal('ERROR');
|
|
379
|
+
expect(external.map((s) => s.status)).to.not.include('ACCEPTED');
|
|
380
|
+
expect(loginBtn.getAuthorizationData()).to.equal(undefined);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('[LBRB9] a stored flow without a usable poll URL is refused without any fetch', async () => {
|
|
384
|
+
f.setPage('http://localhost/app?prYvkey=k1');
|
|
385
|
+
global.window.sessionStorage.setItem(COOKIE_KEY + '-authflow', JSON.stringify({ key: 'k1' }));
|
|
386
|
+
f.poll(ACCEPTED);
|
|
387
|
+
const authController = { serviceInfo: SERVICE_INFO };
|
|
388
|
+
await LoginButton.prototype.finishAuthProcessAfterRedirection.call(THIS, authController);
|
|
389
|
+
expectRefused(authController);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('[LBRBA] an account switch by redirection ends on the new account, the previous one remembered', async () => {
|
|
393
|
+
f.setPage('http://localhost/app?prYvkey=k1');
|
|
394
|
+
const loginBtn = new LoginButton({ authRequest: { requestingAppId: 'lbrba-app', requestedPermissions: [], returnURL: 'self#' } },
|
|
395
|
+
fakeService(Object.assign({ name: 'Test' }, SERVICE_INFO)));
|
|
396
|
+
loginBtn._cookieKey = 'pryv-libjs-lbrba-app';
|
|
397
|
+
loginBtn.saveAuthorizationData({ username: 'bob', apiEndpoint: 'https://tb@bob.mc.example.com/', profiles: [{ username: 'bob', apiEndpoint: 'https://tb@bob.mc.example.com/' }] });
|
|
398
|
+
seedFlow('k1', CORE_POLL, 'pryv-libjs-lbrba-app');
|
|
399
|
+
f.poll(ACCEPTED);
|
|
400
|
+
await loginBtn.init();
|
|
401
|
+
expect(f.fetchCalls).to.deep.equal([CORE_POLL]);
|
|
402
|
+
expect(loginBtn.auth.state.username).to.equal('alice');
|
|
403
|
+
const stored = loginBtn.getAuthorizationData();
|
|
404
|
+
expect(stored.username).to.equal('alice');
|
|
405
|
+
expect(stored.profiles.map((p) => p.username)).to.deep.equal(['alice', 'bob']);
|
|
406
|
+
await loginBtn.deleteAuthorizationData();
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('[LBRBD] a switch return that is refused, fails or was never started keeps the account already signed in', async () => {
|
|
410
|
+
const cases = [
|
|
411
|
+
['refused', 'http://localhost/app?prYvkey=k1', true, { status: 'REFUSED' }, { status: 403 }],
|
|
412
|
+
['unknown key', 'http://localhost/app?prYvkey=k1', true, { error: { id: 'unknown-access-key' } }, { status: 400 }],
|
|
413
|
+
['stray link', 'http://localhost/app?prYvkey=other', false, ACCEPTED, { status: 200 }]
|
|
414
|
+
];
|
|
415
|
+
for (const [label, page, seeded, body, response] of cases) {
|
|
416
|
+
f.setPage(page);
|
|
417
|
+
f.fetchCalls.length = 0;
|
|
418
|
+
const external = [];
|
|
419
|
+
const loginBtn = new LoginButton({ onStateChange: (s) => external.push(s.status), authRequest: { requestingAppId: 'lbrbd-app', requestedPermissions: [], returnURL: 'self#' } },
|
|
420
|
+
fakeService(Object.assign({ name: 'Test' }, SERVICE_INFO)));
|
|
421
|
+
loginBtn._cookieKey = 'pryv-libjs-lbrbd-app';
|
|
422
|
+
loginBtn.saveAuthorizationData({ username: 'bob', apiEndpoint: 'https://tb@bob.mc.example.com/', profiles: [{ username: 'bob', apiEndpoint: 'https://tb@bob.mc.example.com/' }] });
|
|
423
|
+
if (seeded) seedFlow('k1', CORE_POLL, 'pryv-libjs-lbrbd-app');
|
|
424
|
+
f.poll(body, response);
|
|
425
|
+
await loginBtn.init();
|
|
426
|
+
expect(loginBtn.auth.state.status, label).to.equal('ACCEPTED');
|
|
427
|
+
expect(loginBtn.auth.state.username, label).to.equal('bob');
|
|
428
|
+
expect(loginBtn.getAuthorizationData().username, label).to.equal('bob');
|
|
429
|
+
expect(f.fetchCalls.length, label).to.equal(seeded ? 1 : 0);
|
|
430
|
+
// the stored sign-in only: no second AUTHORIZED for the failed return
|
|
431
|
+
expect(external.filter((s) => s === 'ACCEPTED'), label).to.have.length(1);
|
|
432
|
+
expect(global.window.location.href, label).to.equal('http://localhost/app');
|
|
433
|
+
if (!seeded) expect(pollUrls.lookup('other'), label).to.equal(null);
|
|
434
|
+
await loginBtn.deleteAuthorizationData();
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('[LBRBE] a redirect return remembers the poll URL for an app calling connectFromKey(key)', async () => {
|
|
439
|
+
f.setPage('http://localhost/app?prYvkey=k-rem');
|
|
440
|
+
seedFlow('k-rem', 'https://core-b.mc.example.com/reg/access/k-rem');
|
|
441
|
+
f.poll(ACCEPTED);
|
|
442
|
+
await LoginButton.prototype.finishAuthProcessAfterRedirection.call(THIS, { serviceInfo: SERVICE_INFO });
|
|
443
|
+
expect(pollUrls.lookup('k-rem')).to.equal('https://core-b.mc.example.com/reg/access/k-rem');
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('[LBRBB] a click on the button in ERROR starts over instead of doing nothing', async () => {
|
|
447
|
+
f.setPage('http://localhost/app?prYvkey=forged');
|
|
448
|
+
const loginBtn = new LoginButton({ authRequest: { requestingAppId: 'lbrbb-app', requestedPermissions: [], returnURL: 'self#' } },
|
|
449
|
+
fakeService(Object.assign({ name: 'Test' }, SERVICE_INFO)));
|
|
450
|
+
await loginBtn.init();
|
|
451
|
+
expect(loginBtn.auth.state.status).to.equal('ERROR');
|
|
452
|
+
await loginBtn.auth.handleClick();
|
|
453
|
+
expect(loginBtn.auth.state.status).to.equal('INITIALIZED');
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('[LBRBC] leftover one-shot params without a key are cleaned; a malformed escape does not break init', async () => {
|
|
457
|
+
f.setPage('http://localhost/app?x=1&prYvstatus=ACCEPTED');
|
|
458
|
+
await LoginButton.prototype.finishAuthProcessAfterRedirection.call(THIS, { serviceInfo: SERVICE_INFO });
|
|
459
|
+
expect(global.window.location.href).to.equal('http://localhost/app?x=1');
|
|
460
|
+
|
|
461
|
+
f.setPage('http://localhost/app?q=100%&prYvkey=k1');
|
|
462
|
+
const authController = { serviceInfo: SERVICE_INFO };
|
|
463
|
+
await LoginButton.prototype.finishAuthProcessAfterRedirection.call(THIS, authController);
|
|
464
|
+
expect(authController.state.error.id).to.equal('unexpected-auth-return');
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('[LBRB8] without sessionStorage, a redirect sign-in still navigates and a return is refused', async () => {
|
|
468
|
+
f.setPage('http://localhost/app?prYvkey=k1');
|
|
469
|
+
Object.defineProperty(global.window, 'sessionStorage', { get () { throw new Error('blocked'); }, configurable: true });
|
|
470
|
+
const btn = new LoginButton({ authRequest: { requestingAppId: 'lbrb8-app', requestedPermissions: [], returnURL: 'self#' } },
|
|
471
|
+
fakeService(Object.assign({ name: 'Test' }, SERVICE_INFO)));
|
|
472
|
+
btn._cookieKey = 'pryv-libjs-lbrb8-app';
|
|
473
|
+
const navigated = [];
|
|
474
|
+
const savedLocation = Object.getOwnPropertyDescriptor(global, 'location');
|
|
475
|
+
Object.defineProperty(global, 'location', { value: { get href () { return navigated[navigated.length - 1]; }, set href (v) { navigated.push(v); } }, configurable: true, writable: true });
|
|
476
|
+
try {
|
|
477
|
+
await btn.onStateChange({ status: 'NEED_SIGNIN', key: 'k1', poll: CORE_POLL, authUrl: 'https://ui.example.com/access?key=k1' });
|
|
478
|
+
} finally {
|
|
479
|
+
Object.defineProperty(global, 'location', savedLocation);
|
|
480
|
+
}
|
|
481
|
+
expect(navigated).to.have.length(1);
|
|
482
|
+
|
|
483
|
+
f.poll(ACCEPTED);
|
|
484
|
+
const authController = { serviceInfo: SERVICE_INFO };
|
|
485
|
+
await LoginButton.prototype.finishAuthProcessAfterRedirection.call(THIS, authController);
|
|
486
|
+
expectRefused(authController);
|
|
487
|
+
});
|
|
488
|
+
});
|
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
|
});
|
|
@@ -13,14 +13,14 @@ describe('[ARQX] Service access-request init', function () {
|
|
|
13
13
|
service = new pryv.Service(testData.serviceInfoUrl);
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
-
describe('[
|
|
17
|
-
it('[
|
|
16
|
+
describe('[SARX] Service.startAccessRequest', function () {
|
|
17
|
+
it('[SARA] rejects when requestingAppId is missing', async function () {
|
|
18
18
|
let caught;
|
|
19
19
|
try { await service.startAccessRequest({}); } catch (e) { caught = e; }
|
|
20
20
|
expect(caught).to.be.instanceOf(pryv.PryvError);
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
it('[
|
|
23
|
+
it('[SARB] returns { key, authUrl, poll, pollRateMs }', async function () {
|
|
24
24
|
this.timeout(15000);
|
|
25
25
|
const env = await service.startAccessRequest({
|
|
26
26
|
requestingAppId: 'jslib-test',
|
|
@@ -84,7 +84,7 @@ describe('[ARQX] Service access-request init', function () {
|
|
|
84
84
|
expect(state.status).to.equal('NEED_SIGNIN');
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
-
it('[APRC] accepts a bare key (no scheme) and
|
|
87
|
+
it('[APRC] accepts a bare key (no scheme) and polls the request', async function () {
|
|
88
88
|
this.timeout(15000);
|
|
89
89
|
const env = await service.startAccessRequest({
|
|
90
90
|
requestingAppId: 'jslib-test',
|
|
@@ -108,4 +108,20 @@ 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
|
+
});
|
|
111
127
|
});
|
|
@@ -0,0 +1,82 @@
|
|
|
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
|
+
* [SEBX] A malformed server answer never lands in the error message.
|
|
9
|
+
*
|
|
10
|
+
* `login`, `mfaVerify` and `startAccessRequest` throw when a 2xx answer lacks
|
|
11
|
+
* what they need. The answer may hold secrets (a misplaced token) or the
|
|
12
|
+
* request's data, and error messages end up in logs and on screen: it rides
|
|
13
|
+
* on `innerObject` only. `fetch` is stubbed: no server.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const SECRET = 'S3CRET-in-the-answer';
|
|
17
|
+
const SERVICE_INFO = {
|
|
18
|
+
register: 'https://reg.seb.test.local/',
|
|
19
|
+
access: 'https://access.seb.test.local/access/',
|
|
20
|
+
api: 'https://{username}.seb.test.local/',
|
|
21
|
+
name: 'Test'
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe('[SEBX] malformed answers stay out of error messages', function () {
|
|
25
|
+
let realFetch;
|
|
26
|
+
|
|
27
|
+
/** Service info on GET; `postBody` with HTTP 200 on every POST. */
|
|
28
|
+
function stubFetch (postBody) {
|
|
29
|
+
realFetch = global.fetch;
|
|
30
|
+
global.fetch = async function (url, options) {
|
|
31
|
+
if (options?.method === 'POST') return { ok: true, status: 200, json: async () => postBody };
|
|
32
|
+
return { ok: true, status: 200, json: async () => SERVICE_INFO };
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
afterEach(function () {
|
|
37
|
+
if (realFetch) global.fetch = realFetch;
|
|
38
|
+
realFetch = null;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
async function caught (promise) {
|
|
42
|
+
try {
|
|
43
|
+
await promise;
|
|
44
|
+
} catch (e) {
|
|
45
|
+
return e;
|
|
46
|
+
}
|
|
47
|
+
throw new Error('expected a rejection');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
it('[SEB1] login: an answer without token', async () => {
|
|
51
|
+
const body = { session: SECRET };
|
|
52
|
+
stubFetch(body);
|
|
53
|
+
const service = new pryv.Service('https://reg.seb.test.local/service/info');
|
|
54
|
+
const e = await caught(service.login('alice', 'pw', 'app-id'));
|
|
55
|
+
expect(e).to.be.instanceOf(pryv.PryvError);
|
|
56
|
+
expect(e.message).to.include('Invalid login response');
|
|
57
|
+
expect(e.message).to.not.include(SECRET);
|
|
58
|
+
expect(e.innerObject).to.deep.equal(body);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('[SEB2] mfaVerify: an answer without token', async () => {
|
|
62
|
+
const body = { session: SECRET };
|
|
63
|
+
stubFetch(body);
|
|
64
|
+
const service = new pryv.Service('https://reg.seb.test.local/service/info');
|
|
65
|
+
const e = await caught(service.mfaVerify('alice', 'mfa-token', '123456'));
|
|
66
|
+
expect(e).to.be.instanceOf(pryv.PryvError);
|
|
67
|
+
expect(e.message).to.include('did not return a token');
|
|
68
|
+
expect(e.message).to.not.include(SECRET);
|
|
69
|
+
expect(e.innerObject).to.deep.equal(body);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('[SEB3] startAccessRequest: an answer without key or poll', async () => {
|
|
73
|
+
const body = { requestedPermissions: [{ streamId: SECRET, level: 'read' }] };
|
|
74
|
+
stubFetch(body);
|
|
75
|
+
const service = new pryv.Service('https://reg.seb.test.local/service/info');
|
|
76
|
+
const e = await caught(service.startAccessRequest({ requestingAppId: 'app-id', requestedPermissions: [] }));
|
|
77
|
+
expect(e).to.be.instanceOf(pryv.PryvError);
|
|
78
|
+
expect(e.message).to.include('Invalid access-request response');
|
|
79
|
+
expect(e.message).to.not.include(SECRET);
|
|
80
|
+
expect(e.innerObject).to.deep.equal(body);
|
|
81
|
+
});
|
|
82
|
+
});
|