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.
@@ -0,0 +1,153 @@
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
+ * [ARQH] Credential hand-off (shared-secret delivery) in `connectFromKey`.
9
+ *
10
+ * When an access request asked for `credentialHandoff: 'shared-secret'`, the
11
+ * ACCEPTED poll body carries a one-time `handoff` key and a token-less
12
+ * `apiEndpoint` instead of the token. `connectFromKey` must redeem that key
13
+ * once, build a token-bearing `Connection`, and reuse the cached result on a
14
+ * second call (the one-time secret cannot be redeemed twice). A legacy inline
15
+ * body must keep working unchanged.
16
+ *
17
+ * Driven through a stubbed global fetch: what is tested is this library's
18
+ * handling of the two ACCEPTED shapes.
19
+ */
20
+
21
+ const SERVICE_INFO = {
22
+ register: 'https://reg.test.local/',
23
+ access: 'https://reg.test.local/access',
24
+ api: 'https://{username}.test.local/',
25
+ name: 'Test'
26
+ };
27
+
28
+ let keyCounter = 0;
29
+ function freshKey () { return 'poll-key-' + (++keyCounter); }
30
+
31
+ describe('[ARQH] auth-request credential hand-off', function () {
32
+ let realFetch;
33
+ let retrieveCalls;
34
+ let retrievePosts;
35
+
36
+ /**
37
+ * Stub fetch: serve service-info, answer the poll GET with `pollBody`, and
38
+ * answer `shared-secrets/retrieve` with `retrieveResponse` (counting calls).
39
+ */
40
+ function stubFetch (pollBody, retrieveResponse) {
41
+ realFetch = global.fetch;
42
+ retrieveCalls = 0;
43
+ retrievePosts = [];
44
+ global.fetch = async function (url, options) {
45
+ const u = String(url);
46
+ if (u.includes('shared-secrets/retrieve')) {
47
+ retrieveCalls++;
48
+ retrievePosts.push(JSON.parse(options.body));
49
+ return {
50
+ ok: retrieveResponse.ok !== false,
51
+ status: retrieveResponse.status || 200,
52
+ json: async () => retrieveResponse.body
53
+ };
54
+ }
55
+ if (u.includes('/access')) { // the poll GET
56
+ return { ok: true, status: 200, json: async () => pollBody };
57
+ }
58
+ return { ok: true, status: 200, json: async () => SERVICE_INFO }; // service-info
59
+ };
60
+ }
61
+
62
+ afterEach(function () {
63
+ if (realFetch) global.fetch = realFetch;
64
+ realFetch = null;
65
+ });
66
+
67
+ it('[SARH1] redeems a hand-off ACCEPTED body once and builds a token-bearing connection', async function () {
68
+ const key = freshKey();
69
+ stubFetch(
70
+ {
71
+ status: 'ACCEPTED',
72
+ username: 'alice',
73
+ apiEndpoint: 'https://alice.test.local/', // token-less
74
+ handoff: { type: 'shared-secret', key: 'evt1.' + 'a'.repeat(40) }
75
+ },
76
+ { body: { secret: { username: 'alice', token: 'tok-123', apiEndpoint: 'https://alice.test.local/' } } }
77
+ );
78
+
79
+ const service = new pryv.Service('https://reg.test.local/service/info');
80
+ await service.info();
81
+ const conn = await service.connectFromKey(key);
82
+
83
+ expect(retrieveCalls).to.equal(1);
84
+ expect(retrievePosts[0].key).to.equal('evt1.' + 'a'.repeat(40));
85
+ expect(conn.token).to.equal('tok-123');
86
+ expect(conn.apiEndpoint).to.contain('tok-123@');
87
+ expect(conn.apiEndpoint).to.contain('alice.test.local');
88
+ });
89
+
90
+ it('[SARH2] redeems only once across two connectFromKey calls for the same key (module cache)', async function () {
91
+ const key = freshKey();
92
+ stubFetch(
93
+ {
94
+ status: 'ACCEPTED',
95
+ username: 'bob',
96
+ apiEndpoint: 'https://bob.test.local/',
97
+ handoff: { type: 'shared-secret', key: 'evt2.' + 'b'.repeat(40) }
98
+ },
99
+ { body: { secret: { username: 'bob', token: 'tok-xyz', apiEndpoint: 'https://bob.test.local/' } } }
100
+ );
101
+
102
+ // Module-level convenience builds a fresh Service each call; the shared
103
+ // cache is what stops a second redeem of the one-shot secret.
104
+ const c1 = await pryv.connectFromKey(key, 'https://reg.test.local/service/info');
105
+ const c2 = await pryv.connectFromKey(key, 'https://reg.test.local/service/info');
106
+
107
+ expect(retrieveCalls).to.equal(1);
108
+ expect(c1.token).to.equal('tok-xyz');
109
+ expect(c2.token).to.equal('tok-xyz');
110
+ });
111
+
112
+ it('[SARH3] leaves a legacy inline ACCEPTED body unchanged (no retrieve)', async function () {
113
+ const key = freshKey();
114
+ stubFetch(
115
+ {
116
+ status: 'ACCEPTED',
117
+ username: 'carol',
118
+ token: 'tok-inline',
119
+ apiEndpoint: 'https://tok-inline@carol.test.local/'
120
+ },
121
+ { body: { secret: {} } }
122
+ );
123
+
124
+ const service = new pryv.Service('https://reg.test.local/service/info');
125
+ await service.info();
126
+ const conn = await service.connectFromKey(key);
127
+
128
+ expect(retrieveCalls).to.equal(0);
129
+ expect(conn.token).to.equal('tok-inline');
130
+ });
131
+
132
+ it('[SARH4] surfaces a consumed/expired hand-off as a PryvError telling the caller to restart', async function () {
133
+ const key = freshKey();
134
+ stubFetch(
135
+ {
136
+ status: 'ACCEPTED',
137
+ username: 'dan',
138
+ apiEndpoint: 'https://dan.test.local/',
139
+ handoff: { type: 'shared-secret', key: 'evt4.' + 'd'.repeat(40) }
140
+ },
141
+ { ok: false, status: 403, body: { error: { id: 'forbidden', message: 'used', data: { id: 'shared-secret-unavailable' } } } }
142
+ );
143
+
144
+ const service = new pryv.Service('https://reg.test.local/service/info');
145
+ await service.info();
146
+ let caught;
147
+ try {
148
+ await service.connectFromKey(key);
149
+ } catch (e) { caught = e; }
150
+ expect(caught).to.be.instanceof(pryv.PryvError);
151
+ expect(caught.id).to.equal('credential-handoff-failed');
152
+ });
153
+ });
@@ -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
+ });
@@ -65,8 +65,8 @@ describe('[MFLX] Service MFA', function () {
65
65
  });
66
66
  });
67
67
 
68
- describe('[MERX] MfaRequiredError', function () {
69
- it('[MERA] is exported on the package root and extends PryvError', function () {
68
+ describe('[MFEX] MfaRequiredError', function () {
69
+ it('[MFEA] is exported on the package root and extends PryvError', function () {
70
70
  expect(pryv.MfaRequiredError).to.be.a('function');
71
71
  const err = new pryv.MfaRequiredError(
72
72
  'tok-abc',
@@ -81,7 +81,7 @@ describe('[MFLX] Service MFA', function () {
81
81
  expect(err.name).to.equal('MfaRequiredError');
82
82
  });
83
83
 
84
- it('[MERB] picks up id/message from API error body when provided', function () {
84
+ it('[MFEB] picks up id/message from API error body when provided', function () {
85
85
  const err = new pryv.MfaRequiredError(
86
86
  'tok-xyz',
87
87
  { status: 401 },
@@ -91,7 +91,7 @@ describe('[MFLX] Service MFA', function () {
91
91
  expect(err.message).to.equal('custom msg');
92
92
  });
93
93
 
94
- it('[MERC] surfaces the MFA method from the API body when present', function () {
94
+ it('[MFEC] surfaces the MFA method from the API body when present', function () {
95
95
  const totp = new pryv.MfaRequiredError(
96
96
  'tok-1',
97
97
  { status: 200 },
@@ -0,0 +1,114 @@
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
+ * [PUKX] Polling an auth request by key reaches the core that holds it.
9
+ *
10
+ * On a multi-core platform a pending auth request lives on the core that
11
+ * created it, and the server answers the access request with a poll URL on
12
+ * that core, while the service's `access` URL may reach any core. Polling by
13
+ * key must use the server-issued URL when this process started the request.
14
+ * Observed: the URLs actually fetched (a stubbed global fetch), not only the
15
+ * result.
16
+ */
17
+
18
+ const pollUrls = require('../src/lib/pollUrls');
19
+ const AuthController = require('../src/Auth/AuthController');
20
+ const utils = require('../src/utils');
21
+
22
+ const SERVICE_INFO = {
23
+ register: 'https://reg.mc.test.local/',
24
+ access: 'https://access.mc.test.local/access/',
25
+ api: 'https://{username}.mc.test.local/',
26
+ name: 'Test'
27
+ };
28
+
29
+ let keyCounter = 0;
30
+ function freshKey () { return 'puk-key-' + (++keyCounter); }
31
+ function corePoll (key) { return 'https://core-a.mc.test.local/reg/access/' + key; }
32
+
33
+ describe('[PUKX] polling an auth request by key', function () {
34
+ let realFetch;
35
+ let gets;
36
+
37
+ /** service-info, POST <access> -> NEED_SIGNIN with a core poll URL, GET -> `pollBody` */
38
+ function stubFetch (key, pollBody) {
39
+ realFetch = global.fetch;
40
+ gets = [];
41
+ global.fetch = async function (url, options) {
42
+ const u = String(url);
43
+ if (options?.method === 'POST' && u === SERVICE_INFO.access) {
44
+ return { ok: true, status: 201, json: async () => ({ status: 'NEED_SIGNIN', key, poll: corePoll(key), authUrl: 'https://ui.mc.test.local/access?key=' + key, poll_rate_ms: 1000 }) };
45
+ }
46
+ if (u.includes('/access/')) {
47
+ gets.push(u);
48
+ return { ok: true, status: 200, json: async () => pollBody };
49
+ }
50
+ return { ok: true, status: 200, json: async () => SERVICE_INFO };
51
+ };
52
+ }
53
+
54
+ afterEach(function () {
55
+ if (realFetch) global.fetch = realFetch;
56
+ realFetch = null;
57
+ });
58
+
59
+ it('[PUK1] remembers valid entries only, keeps a bounded number and expires them', function () {
60
+ const realNow = Date.now;
61
+ pollUrls.clear();
62
+ try {
63
+ pollUrls.remember('puk1-a', 'https://core-a.example.com/reg/access/puk1-a');
64
+ pollUrls.remember('puk1-b', 'javascript:alert(1)');
65
+ pollUrls.remember(null, 'https://core-a.example.com/reg/access/x');
66
+ expect(pollUrls.lookup('puk1-a')).to.equal('https://core-a.example.com/reg/access/puk1-a');
67
+ expect(pollUrls.lookup('puk1-b')).to.equal(null);
68
+ for (let i = 0; i < pollUrls.MAX_ENTRIES; i++) pollUrls.remember('puk1-fill-' + i, 'https://c.example.com/' + i);
69
+ expect(pollUrls.lookup('puk1-a')).to.equal(null); // oldest dropped
70
+ expect(pollUrls.lookup('puk1-fill-' + (pollUrls.MAX_ENTRIES - 1))).to.equal('https://c.example.com/' + (pollUrls.MAX_ENTRIES - 1));
71
+ const later = realNow() + pollUrls.TTL_MS + 1000;
72
+ Date.now = () => later;
73
+ expect(pollUrls.lookup('puk1-fill-' + (pollUrls.MAX_ENTRIES - 1))).to.equal(null); // expired
74
+ } finally {
75
+ Date.now = realNow;
76
+ pollUrls.clear();
77
+ }
78
+ });
79
+
80
+ it('[PUK2] pollAccessRequest(key) and connectFromKey(key) poll the core URL the server issued', async function () {
81
+ const key = freshKey();
82
+ stubFetch(key, { status: 'ACCEPTED', username: 'alice', apiEndpoint: 'https://tok@alice.mc.test.local/', token: 'tok' });
83
+ const service = new pryv.Service(null, SERVICE_INFO);
84
+ const env = await service.startAccessRequest({ requestingAppId: 'puk', requestedPermissions: [] });
85
+ expect(env.poll).to.equal(corePoll(key));
86
+
87
+ await service.pollAccessRequest(key);
88
+ // a fresh Service, as pryv.connectFromKey(key, url) builds one
89
+ const connection = await new pryv.Service(null, SERVICE_INFO).connectFromKey(key);
90
+ expect(gets).to.deep.equal([corePoll(key), corePoll(key)]);
91
+ expect(connection.apiEndpoint).to.equal('https://tok@alice.mc.test.local/');
92
+ });
93
+
94
+ it('[PUK3] a key this process never saw falls back to access + key', async function () {
95
+ const key = freshKey();
96
+ stubFetch(key, { status: 'NEED_SIGNIN' });
97
+ await new pryv.Service(null, SERVICE_INFO).pollAccessRequest(key);
98
+ expect(gets).to.deep.equal([SERVICE_INFO.access + key]);
99
+ });
100
+
101
+ it('[PUK4] a sign-in started by the button is remembered for connectFromKey', async function () {
102
+ const key = freshKey();
103
+ const { fetchPost, fetchGet } = utils;
104
+ utils.fetchPost = async () => ({ response: { ok: true }, body: { status: 'NEED_SIGNIN', key, poll: corePoll(key), poll_rate_ms: 1, authUrl: 'https://ui.mc.test.local/access?key=' + key } });
105
+ utils.fetchGet = async () => ({ response: { status: 200 }, body: { status: 'REFUSED' } });
106
+ const service = { infoSync: () => SERVICE_INFO, assets: async () => ({}) };
107
+ const auth = new AuthController({ authRequest: { requestingAppId: 'puk4', requestedPermissions: [] } }, service);
108
+ auth.serviceInfo = SERVICE_INFO;
109
+ try {
110
+ await auth.startAuthRequest();
111
+ } finally { utils.fetchPost = fetchPost; utils.fetchGet = fetchGet; }
112
+ expect(pollUrls.lookup(key)).to.equal(corePoll(key));
113
+ });
114
+ });
@@ -1,6 +1,7 @@
1
1
  <!DOCTYPE html>
2
2
  <html lang="en">
3
3
  <head>
4
+ <meta charset="utf-8" />
4
5
  <title>Pryv JS lib browser tests</title>
5
6
  <link rel="stylesheet" href="https://unpkg.com/mocha@10.8.2/mocha.css" />
6
7
  </head>