pryv 3.12.1 → 3.13.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 +39 -7
- package/package.json +1 -1
- package/src/Auth/AuthController.js +335 -7
- package/src/Auth/AuthStates.js +4 -2
- package/src/Auth/LoginMessages.js +70 -1
- package/src/Auth/ProfileStore.js +119 -0
- package/src/Browser/LoginButton.js +383 -17
- package/src/Service.js +22 -1
- package/src/ServiceAssets.js +3 -0
- package/src/SharedSecrets.js +4 -1
- package/src/index.d.ts +139 -4
- package/src/lib/handoff.js +139 -0
- package/test/AuthController.test.js +210 -0
- package/test/LoginButton.test.js +571 -10
- package/test/LoginMessages.test.js +8 -0
- package/test/ProfileStore.test.js +88 -0
- package/test/Service.accessRequestHandoff.test.js +153 -0
- package/test/browser-index.html +1 -0
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|