pryv 3.12.1 → 3.14.2

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.
@@ -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. 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 });
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({}, authController);
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
+ });
@@ -45,4 +45,12 @@ describe('[MSGX] LoginMessages', function () {
45
45
  const msgs = Messages('fr', customDefs);
46
46
  expect(msgs.TEST).to.equal('English only');
47
47
  });
48
+
49
+ it('[MSGF] keys a service does not define keep their default text', function () {
50
+ // a service's message asset written before the account menu existed
51
+ const msgs = Messages('fr', { LOADING: { en: 'Loading...' } });
52
+ expect(msgs.LOADING).to.equal('Loading...');
53
+ expect(msgs.LOGOUT).to.equal('Se déconnecter');
54
+ expect(msgs.MANAGE_ACCOUNT).to.equal('Gérer mon compte');
55
+ });
48
56
  });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @license
3
+ * [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
4
+ */
5
+ /* global describe, it, expect */
6
+
7
+ const ProfileStore = require('../src/Auth/ProfileStore');
8
+
9
+ const parent = { username: 'parent-doe', apiEndpoint: 'https://tok1@parent-doe.example.com/' };
10
+ const kim = { username: 'kim-doe', apiEndpoint: 'https://tok2@kim-doe.example.com/', actingAs: { username: 'kim-doe', delegate: 'parent-doe' } };
11
+
12
+ describe('[PSTX] ProfileStore', function () {
13
+ it('[PSTA] reads stored data of any version: nothing, deleted, a single account, several', function () {
14
+ expect(ProfileStore.read(null)).to.deep.equal({ active: null, profiles: [], authUrl: undefined });
15
+ expect(ProfileStore.read({ deleted: true }).profiles).to.deep.equal([]);
16
+ // a single account, as stored before profiles existed
17
+ const legacy = ProfileStore.read({ apiEndpoint: parent.apiEndpoint, username: parent.username, authUrl: 'https://ui.example.com/auth' });
18
+ expect(legacy.active).to.deep.equal(parent);
19
+ expect(legacy.profiles).to.deep.equal([parent]);
20
+ expect(legacy.authUrl).to.equal('https://ui.example.com/auth');
21
+ // the active account always comes first, once
22
+ const several = ProfileStore.read(Object.assign({}, kim, { profiles: [parent, kim] }));
23
+ expect(several.active).to.deep.equal(kim);
24
+ expect(several.profiles).to.deep.equal([kim, parent]);
25
+ // remembered accounts, none active
26
+ const none = ProfileStore.read({ profiles: [parent, kim] });
27
+ expect(none.active).to.equal(null);
28
+ expect(none.profiles).to.have.lengthOf(2);
29
+ });
30
+
31
+ it('[PSTB] activating keeps the most recent first, replaces the same username, and forgets beyond the limit', function () {
32
+ let store = ProfileStore.read(null);
33
+ store = ProfileStore.activate(store, parent);
34
+ store = ProfileStore.activate(store, kim);
35
+ expect(store.active.username).to.equal('kim-doe');
36
+ expect(store.profiles.map((p) => p.username)).to.deep.equal(['kim-doe', 'parent-doe']);
37
+ // a new sign-in to a remembered account replaces it (new token, flags cleared)
38
+ store = ProfileStore.markUnavailable(store, 'parent-doe');
39
+ store = ProfileStore.activate(store, Object.assign({}, parent, { apiEndpoint: 'https://tok3@parent-doe.example.com/' }));
40
+ expect(store.profiles).to.deep.equal([
41
+ { username: 'parent-doe', apiEndpoint: 'https://tok3@parent-doe.example.com/' },
42
+ kim
43
+ ]);
44
+ // the least recently used account goes first; the active one stays
45
+ for (let i = 0; i < 6; i++) {
46
+ store = ProfileStore.activate(store, { username: 'u' + i, apiEndpoint: 'https://t@u' + i + '.example.com/' }, 3);
47
+ }
48
+ expect(store.profiles.map((p) => p.username)).to.deep.equal(['u5', 'u4', 'u3']);
49
+ expect(ProfileStore.activate(store, parent).profiles).to.have.lengthOf(ProfileStore.DEFAULT_LIMIT - 1);
50
+ });
51
+
52
+ it('[PSTC] the stored form keeps the active account at the top level, readable by versions without profiles', function () {
53
+ const store = ProfileStore.activate(ProfileStore.activate(ProfileStore.read(null), parent), kim);
54
+ const data = ProfileStore.write(Object.assign(store, { authUrl: 'https://ui.example.com/auth' }));
55
+ expect(data.username).to.equal('kim-doe');
56
+ expect(data.apiEndpoint).to.equal(kim.apiEndpoint);
57
+ expect(data.actingAs).to.deep.equal(kim.actingAs);
58
+ expect(data.authUrl).to.equal('https://ui.example.com/auth');
59
+ expect(data.profiles).to.deep.equal([kim, parent]);
60
+ // no active account: no top-level credentials, so nothing signs in on reload
61
+ const loggedOut = ProfileStore.write(ProfileStore.remove(store, 'kim-doe'));
62
+ expect(loggedOut.username).to.equal(undefined);
63
+ expect(loggedOut.apiEndpoint).to.equal(undefined);
64
+ expect(loggedOut.profiles).to.deep.equal([parent]);
65
+ expect(ProfileStore.write(ProfileStore.read(null))).to.equal(null);
66
+ // unknown fields are not stored
67
+ const extra = ProfileStore.write(ProfileStore.activate(ProfileStore.read(null), Object.assign({ token: 'x', other: 1 }, parent)));
68
+ expect(extra.profiles[0]).to.deep.equal(parent);
69
+ });
70
+
71
+ it('[PSTD] a revoked account stays listed as unavailable and is no longer active', function () {
72
+ const store = ProfileStore.markUnavailable(ProfileStore.activate(ProfileStore.activate(ProfileStore.read(null), parent), kim), 'kim-doe');
73
+ expect(store.active).to.equal(null);
74
+ expect(store.profiles.find((p) => p.username === 'kim-doe').unavailable).to.equal(true);
75
+ const reread = ProfileStore.read(ProfileStore.write(store));
76
+ expect(reread.active).to.equal(null);
77
+ expect(reread.profiles.find((p) => p.username === 'kim-doe').unavailable).to.equal(true);
78
+ });
79
+
80
+ it('[PSTE] an ACCEPTED body marks the account as used through delegation only when its hint names that account', function () {
81
+ const hint = { isDelegatedAccess: true, controlledUsername: 'kim-doe', delegate: { username: 'parent-doe' } };
82
+ expect(ProfileStore.fromAccepted({ username: 'kim-doe', apiEndpoint: kim.apiEndpoint, delegation: hint })).to.deep.equal(kim);
83
+ expect(ProfileStore.fromAccepted({ username: 'parent-doe', apiEndpoint: parent.apiEndpoint })).to.deep.equal(parent);
84
+ // a hint about another account is ignored
85
+ expect(ProfileStore.fromAccepted({ username: 'lou-doe', apiEndpoint: 'https://t@lou.example.com/', delegation: hint }).actingAs).to.equal(undefined);
86
+ expect(ProfileStore.fromAccepted({ username: 'kim-doe', apiEndpoint: kim.apiEndpoint, delegation: { controlledUsername: 'kim-doe', delegate: { username: 'p' } } }).actingAs).to.equal(undefined);
87
+ });
88
+ });
@@ -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('[ASTX] Service.startAccessRequest', function () {
17
- it('[ASTA] rejects when requestingAppId is missing', async function () {
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('[ASTB] returns { key, authUrl, poll, pollRateMs }', async function () {
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 builds the URL', async function () {
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',