pryv 3.8.0 → 3.10.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 +41 -1
- package/package.json +5 -3
- package/src/Connection.js +15 -2
- package/src/OAuth2Client.js +391 -0
- package/src/SharedSecrets.js +172 -0
- package/src/SignedConnection.js +66 -0
- package/src/dpopProof.js +96 -0
- package/src/index.d.ts +59 -0
- package/src/index.js +5 -0
- package/src/utils.js +1 -1
- package/test/Connection.authHeader.test.js +43 -0
- package/test/OAuth2Client.test.js +439 -0
- package/test/SharedSecrets.test.js +110 -0
- package/test/SignedConnection.test.js +97 -0
- package/test/esm-deps.test.js +19 -0
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
/* global describe, it, beforeEach, afterEach, expect */
|
|
6
|
+
|
|
7
|
+
const OAuth2Client = require('../src/OAuth2Client');
|
|
8
|
+
const oauth = require('oauth4webapi');
|
|
9
|
+
|
|
10
|
+
const ISSUER = 'https://host';
|
|
11
|
+
const DISCOVERY = {
|
|
12
|
+
issuer: ISSUER,
|
|
13
|
+
authorization_endpoint: ISSUER + '/oauth2/authorize',
|
|
14
|
+
token_endpoint: ISSUER + '/oauth2/token',
|
|
15
|
+
response_types_supported: ['code'],
|
|
16
|
+
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
17
|
+
token_endpoint_auth_methods_supported: ['client_secret_basic', 'none'],
|
|
18
|
+
code_challenge_methods_supported: ['S256']
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// A Pryv token-endpoint response (RFC 6749 §5.1 + the `apiEndpoint` extension).
|
|
22
|
+
function tokenBody (overrides = {}) {
|
|
23
|
+
return Object.assign({
|
|
24
|
+
access_token: 'TOKEN123',
|
|
25
|
+
token_type: 'Bearer',
|
|
26
|
+
expires_in: 3600,
|
|
27
|
+
refresh_token: 'REFRESH456',
|
|
28
|
+
scope: 'cmc:study-A',
|
|
29
|
+
apiEndpoint: 'https://TOKEN123@host/path/'
|
|
30
|
+
}, overrides);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function jsonResponse (body, status = 200) {
|
|
34
|
+
return new Response(JSON.stringify(body), {
|
|
35
|
+
status,
|
|
36
|
+
headers: { 'content-type': 'application/json' }
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function memoryStorage () {
|
|
41
|
+
const map = new Map();
|
|
42
|
+
return {
|
|
43
|
+
map,
|
|
44
|
+
getItem: (k) => (map.has(k) ? map.get(k) : null),
|
|
45
|
+
setItem: (k, v) => { map.set(k, String(v)); },
|
|
46
|
+
removeItem: (k) => { map.delete(k); }
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Install a global.fetch mock serving discovery + a configurable token response.
|
|
51
|
+
// Returns a handle exposing the captured token request and lets tests override
|
|
52
|
+
// the token response.
|
|
53
|
+
function installFetchMock () {
|
|
54
|
+
const state = { tokenResponse: jsonResponse(tokenBody()), lastTokenRequest: null, discovery: null, tokenRequestCount: 0 };
|
|
55
|
+
const original = globalThis.fetch;
|
|
56
|
+
globalThis.fetch = async (input, init) => {
|
|
57
|
+
const url = String(input && input.url ? input.url : input);
|
|
58
|
+
if (url.includes('.well-known/oauth-authorization-server')) {
|
|
59
|
+
return jsonResponse(state.discovery || DISCOVERY);
|
|
60
|
+
}
|
|
61
|
+
if (url === DISCOVERY.token_endpoint) {
|
|
62
|
+
state.tokenRequestCount++;
|
|
63
|
+
state.lastTokenRequest = { url, init, body: init && init.body ? init.body.toString() : '' };
|
|
64
|
+
// A Response body is single-use; clone so a test can reuse one response
|
|
65
|
+
// object across sequential requests without an "already read" error.
|
|
66
|
+
return (state.tokenResponse instanceof Response) ? state.tokenResponse.clone() : state.tokenResponse;
|
|
67
|
+
}
|
|
68
|
+
throw new Error('unexpected fetch: ' + url);
|
|
69
|
+
};
|
|
70
|
+
state.restore = () => { globalThis.fetch = original; };
|
|
71
|
+
return state;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function newClient (storage, overrides = {}) {
|
|
75
|
+
return new OAuth2Client(Object.assign({
|
|
76
|
+
authorizationServer: ISSUER,
|
|
77
|
+
clientId: 'app1',
|
|
78
|
+
redirectUri: 'https://app.example/cb',
|
|
79
|
+
scope: 'cmc:study-A',
|
|
80
|
+
storage
|
|
81
|
+
}, overrides));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe('[OAUTH-LIB] OAuth2Client', function () {
|
|
85
|
+
let fetchMock;
|
|
86
|
+
beforeEach(function () { fetchMock = installFetchMock(); });
|
|
87
|
+
afterEach(function () { fetchMock.restore(); });
|
|
88
|
+
|
|
89
|
+
describe('[OAL-CTOR] constructor validation', function () {
|
|
90
|
+
it('[OAL-C1] throws without authorizationServer', function () {
|
|
91
|
+
expect(() => new OAuth2Client({ clientId: 'a', redirectUri: 'b' }))
|
|
92
|
+
.to.throw(/authorizationServer/);
|
|
93
|
+
});
|
|
94
|
+
it('[OAL-C2] throws without clientId', function () {
|
|
95
|
+
expect(() => new OAuth2Client({ authorizationServer: ISSUER, redirectUri: 'b' }))
|
|
96
|
+
.to.throw(/clientId/);
|
|
97
|
+
});
|
|
98
|
+
it('[OAL-C3] throws without redirectUri', function () {
|
|
99
|
+
expect(() => new OAuth2Client({ authorizationServer: ISSUER, clientId: 'a' }))
|
|
100
|
+
.to.throw(/redirectUri/);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('[OAL-AUTH] redirectToAuthorize', function () {
|
|
105
|
+
it('[OAL-A1] builds the authorize URL with all required params', async function () {
|
|
106
|
+
const storage = memoryStorage();
|
|
107
|
+
const client = newClient(storage);
|
|
108
|
+
const url = await client.redirectToAuthorize({ state: 'STATE123' });
|
|
109
|
+
const u = new URL(url);
|
|
110
|
+
expect(u.origin + u.pathname).to.equal(DISCOVERY.authorization_endpoint);
|
|
111
|
+
expect(u.searchParams.get('response_type')).to.equal('code');
|
|
112
|
+
expect(u.searchParams.get('client_id')).to.equal('app1');
|
|
113
|
+
expect(u.searchParams.get('redirect_uri')).to.equal('https://app.example/cb');
|
|
114
|
+
expect(u.searchParams.get('scope')).to.equal('cmc:study-A');
|
|
115
|
+
expect(u.searchParams.get('state')).to.equal('STATE123');
|
|
116
|
+
expect(u.searchParams.get('code_challenge_method')).to.equal('S256');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('[OAL-A2] stores a PKCE verifier keyed by state; challenge = base64url(sha256(verifier))', async function () {
|
|
120
|
+
const storage = memoryStorage();
|
|
121
|
+
const client = newClient(storage);
|
|
122
|
+
const url = await client.redirectToAuthorize({ state: 'S' });
|
|
123
|
+
const verifier = storage.getItem('pryv-oauth2-verifier:S');
|
|
124
|
+
expect(verifier).to.be.a('string').with.length.greaterThan(42);
|
|
125
|
+
const challenge = new URL(url).searchParams.get('code_challenge');
|
|
126
|
+
expect(challenge).to.match(/^[A-Za-z0-9_-]{43}$/);
|
|
127
|
+
const expected = await oauth.calculatePKCECodeChallenge(verifier);
|
|
128
|
+
expect(challenge).to.equal(expected);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('[OAL-A3] generates a random state when none is supplied', async function () {
|
|
132
|
+
const storage = memoryStorage();
|
|
133
|
+
const client = newClient(storage);
|
|
134
|
+
const url = await client.redirectToAuthorize();
|
|
135
|
+
const state = new URL(url).searchParams.get('state');
|
|
136
|
+
expect(state).to.be.a('string').with.length.greaterThan(0);
|
|
137
|
+
expect(storage.getItem('pryv-oauth2-verifier:' + state)).to.be.a('string');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('[OAL-A4] invokes the provided redirect function with the URL', async function () {
|
|
141
|
+
const storage = memoryStorage();
|
|
142
|
+
const client = newClient(storage);
|
|
143
|
+
let redirected = null;
|
|
144
|
+
const url = await client.redirectToAuthorize({ state: 'S', redirect: (u) => { redirected = u; } });
|
|
145
|
+
expect(redirected).to.equal(url);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('[OAL-A5] omits scope from the URL when not configured', async function () {
|
|
149
|
+
const storage = memoryStorage();
|
|
150
|
+
const client = newClient(storage, { scope: undefined });
|
|
151
|
+
const url = await client.redirectToAuthorize({ state: 'S' });
|
|
152
|
+
expect(new URL(url).searchParams.has('scope')).to.equal(false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('[OAL-A6] allows an http authorization_endpoint on a loopback host (local dev)', async function () {
|
|
156
|
+
const storage = memoryStorage();
|
|
157
|
+
fetchMock.discovery = Object.assign({}, DISCOVERY, {
|
|
158
|
+
authorization_endpoint: 'http://127.0.0.1:3000/oauth2/authorize'
|
|
159
|
+
});
|
|
160
|
+
const client = newClient(storage);
|
|
161
|
+
const url = await client.redirectToAuthorize({ state: 'S', redirect: () => {} });
|
|
162
|
+
expect(new URL(url).protocol).to.equal('http:');
|
|
163
|
+
expect(new URL(url).hostname).to.equal('127.0.0.1');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('[OAL-A7] refuses a non-loopback http authorization_endpoint (MITM discovery)', async function () {
|
|
167
|
+
const storage = memoryStorage();
|
|
168
|
+
fetchMock.discovery = Object.assign({}, DISCOVERY, {
|
|
169
|
+
authorization_endpoint: 'http://evil.example/oauth2/authorize'
|
|
170
|
+
});
|
|
171
|
+
const client = newClient(storage);
|
|
172
|
+
let navigated = null;
|
|
173
|
+
await expect(client.redirectToAuthorize({ state: 'S', redirect: (u) => { navigated = u; } }))
|
|
174
|
+
.to.be.rejectedWith(/insecure|https/);
|
|
175
|
+
expect(navigated).to.equal(null); // never navigated the browser
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('[OAL-A8] refuses a javascript: authorization_endpoint', async function () {
|
|
179
|
+
const storage = memoryStorage();
|
|
180
|
+
fetchMock.discovery = Object.assign({}, DISCOVERY, {
|
|
181
|
+
authorization_endpoint: 'javascript:alert(document.cookie)'
|
|
182
|
+
});
|
|
183
|
+
const client = newClient(storage);
|
|
184
|
+
await expect(client.redirectToAuthorize({ state: 'S', redirect: () => {} }))
|
|
185
|
+
.to.be.rejectedWith(/insecure|https/);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('[OAL-CB] handleCallback', function () {
|
|
190
|
+
async function primedClient (storage) {
|
|
191
|
+
const client = newClient(storage);
|
|
192
|
+
await client.redirectToAuthorize({ state: 'S', redirect: () => {} });
|
|
193
|
+
return client;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
it('[OAL-B1] exchanges the code and returns a Connection built from apiEndpoint', async function () {
|
|
197
|
+
const storage = memoryStorage();
|
|
198
|
+
const client = await primedClient(storage);
|
|
199
|
+
const connection = await client.handleCallback('?code=CODE&state=S');
|
|
200
|
+
expect(connection.constructor.name).to.equal('Connection');
|
|
201
|
+
expect(connection.token).to.equal('TOKEN123');
|
|
202
|
+
expect(connection.endpoint).to.equal('https://host/path/');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('[OAL-B2] sends grant_type=authorization_code + the PKCE verifier to the token endpoint', async function () {
|
|
206
|
+
const storage = memoryStorage();
|
|
207
|
+
const client = await primedClient(storage);
|
|
208
|
+
const verifier = storage.getItem('pryv-oauth2-verifier:S');
|
|
209
|
+
await client.handleCallback('?code=CODE&state=S');
|
|
210
|
+
const body = new URLSearchParams(fetchMock.lastTokenRequest.body);
|
|
211
|
+
expect(body.get('grant_type')).to.equal('authorization_code');
|
|
212
|
+
expect(body.get('code')).to.equal('CODE');
|
|
213
|
+
expect(body.get('code_verifier')).to.equal(verifier);
|
|
214
|
+
expect(body.get('redirect_uri')).to.equal('https://app.example/cb');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('[OAL-B3] removes the stored verifier after a successful exchange', async function () {
|
|
218
|
+
const storage = memoryStorage();
|
|
219
|
+
const client = await primedClient(storage);
|
|
220
|
+
await client.handleCallback('?code=CODE&state=S');
|
|
221
|
+
expect(storage.getItem('pryv-oauth2-verifier:S')).to.equal(null);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('[OAL-B4] accepts a query string without a leading question mark', async function () {
|
|
225
|
+
const storage = memoryStorage();
|
|
226
|
+
const client = await primedClient(storage);
|
|
227
|
+
const connection = await client.handleCallback('code=CODE&state=S');
|
|
228
|
+
expect(connection.token).to.equal('TOKEN123');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('[OAL-B5] throws when the callback has no state', async function () {
|
|
232
|
+
const storage = memoryStorage();
|
|
233
|
+
const client = await primedClient(storage);
|
|
234
|
+
await expect(client.handleCallback('?code=CODE')).to.be.rejectedWith(/state/);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('[OAL-B6] throws when no verifier is stored for the state (forged/expired)', async function () {
|
|
238
|
+
const storage = memoryStorage();
|
|
239
|
+
const client = await primedClient(storage);
|
|
240
|
+
await expect(client.handleCallback('?code=CODE&state=OTHER')).to.be.rejectedWith(/verifier/);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('[OAL-B7] throws when the token response lacks the apiEndpoint extension', async function () {
|
|
244
|
+
const storage = memoryStorage();
|
|
245
|
+
const client = await primedClient(storage);
|
|
246
|
+
const body = tokenBody();
|
|
247
|
+
delete body.apiEndpoint;
|
|
248
|
+
fetchMock.tokenResponse = jsonResponse(body);
|
|
249
|
+
await expect(client.handleCallback('?code=CODE&state=S')).to.be.rejectedWith(/apiEndpoint/);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('[OAL-B8] removes the stored verifier even when the exchange fails', async function () {
|
|
253
|
+
const storage = memoryStorage();
|
|
254
|
+
const client = await primedClient(storage);
|
|
255
|
+
const body = tokenBody();
|
|
256
|
+
delete body.apiEndpoint; // force _connectionFromTokenResponse to throw
|
|
257
|
+
fetchMock.tokenResponse = jsonResponse(body);
|
|
258
|
+
await expect(client.handleCallback('?code=CODE&state=S')).to.be.rejected;
|
|
259
|
+
expect(storage.getItem('pryv-oauth2-verifier:S')).to.equal(null);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('[OAL-B9] refuses an insecure (non-https, non-loopback) apiEndpoint', async function () {
|
|
263
|
+
const storage = memoryStorage();
|
|
264
|
+
const client = await primedClient(storage);
|
|
265
|
+
fetchMock.tokenResponse = jsonResponse(tokenBody({ apiEndpoint: 'http://TOKEN123@evil.example/path/' }));
|
|
266
|
+
await expect(client.handleCallback('?code=CODE&state=S')).to.be.rejectedWith(/insecure|https/);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('[OAL-B10] allows an http apiEndpoint on a loopback host (local dev)', async function () {
|
|
270
|
+
const storage = memoryStorage();
|
|
271
|
+
const client = await primedClient(storage);
|
|
272
|
+
fetchMock.tokenResponse = jsonResponse(tokenBody({ apiEndpoint: 'http://TOKEN123@127.0.0.1:3000/path/' }));
|
|
273
|
+
const connection = await client.handleCallback('?code=CODE&state=S');
|
|
274
|
+
expect(connection.token).to.equal('TOKEN123');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('[OAL-B11] refuses a loopback-spoofing apiEndpoint whose real endpoint is a remote http host', async function () {
|
|
278
|
+
// Connection splits token/endpoint on the LAST `@`, so this parses as
|
|
279
|
+
// token `tok@127.0.0.1/x`, endpoint `http://evil.example/` — the token
|
|
280
|
+
// would go to evil.example in cleartext. The guard must validate the
|
|
281
|
+
// extracted endpoint, not the loopback-looking raw string.
|
|
282
|
+
const storage = memoryStorage();
|
|
283
|
+
const client = await primedClient(storage);
|
|
284
|
+
fetchMock.tokenResponse = jsonResponse(tokenBody({ apiEndpoint: 'http://tok@127.0.0.1/x@evil.example/' }));
|
|
285
|
+
await expect(client.handleCallback('?code=CODE&state=S')).to.be.rejectedWith(/insecure|https/);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe('[OAL-RF] refresh', function () {
|
|
290
|
+
it('[OAL-R1] throws before any successful callback', async function () {
|
|
291
|
+
const storage = memoryStorage();
|
|
292
|
+
const client = newClient(storage);
|
|
293
|
+
await expect(client.refresh()).to.be.rejectedWith(/refresh token/);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('[OAL-R2] exchanges the stored refresh token for a fresh Connection', async function () {
|
|
297
|
+
const storage = memoryStorage();
|
|
298
|
+
const client = newClient(storage);
|
|
299
|
+
await client.redirectToAuthorize({ state: 'S', redirect: () => {} });
|
|
300
|
+
await client.handleCallback('?code=CODE&state=S');
|
|
301
|
+
fetchMock.tokenResponse = jsonResponse(tokenBody({
|
|
302
|
+
access_token: 'TOKEN789',
|
|
303
|
+
apiEndpoint: 'https://TOKEN789@host/path/'
|
|
304
|
+
}));
|
|
305
|
+
const connection = await client.refresh();
|
|
306
|
+
expect(connection.token).to.equal('TOKEN789');
|
|
307
|
+
const body = new URLSearchParams(fetchMock.lastTokenRequest.body);
|
|
308
|
+
expect(body.get('grant_type')).to.equal('refresh_token');
|
|
309
|
+
expect(body.get('refresh_token')).to.equal('REFRESH456');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
async function primed (storage, overrides = {}) {
|
|
313
|
+
const client = newClient(storage, overrides);
|
|
314
|
+
await client.redirectToAuthorize({ state: 'S', redirect: () => {} });
|
|
315
|
+
await client.handleCallback('?code=CODE&state=S');
|
|
316
|
+
return client;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
it('[OAL-R3-F1] serializes concurrent refresh() onto ONE token request', async function () {
|
|
320
|
+
const client = await primed(memoryStorage());
|
|
321
|
+
fetchMock.tokenResponse = jsonResponse(tokenBody({ access_token: 'T2', refresh_token: 'R2', apiEndpoint: 'https://T2@host/p/' }));
|
|
322
|
+
fetchMock.tokenRequestCount = 0;
|
|
323
|
+
const [c1, c2] = await Promise.all([client.refresh(), client.refresh()]);
|
|
324
|
+
expect(fetchMock.tokenRequestCount).to.equal(1); // deduped, no reuse loser
|
|
325
|
+
expect(c1.token).to.equal('T2');
|
|
326
|
+
expect(c2.token).to.equal('T2');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('[OAL-R4-F1] a new refresh() after the in-flight one settles issues a fresh request', async function () {
|
|
330
|
+
const client = await primed(memoryStorage());
|
|
331
|
+
fetchMock.tokenResponse = jsonResponse(tokenBody({ refresh_token: 'R2', apiEndpoint: 'https://T2@host/p/' }));
|
|
332
|
+
await client.refresh();
|
|
333
|
+
fetchMock.tokenRequestCount = 0;
|
|
334
|
+
await client.refresh();
|
|
335
|
+
expect(fetchMock.tokenRequestCount).to.equal(1); // slot cleared, not stuck
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('[OAL-R5-F2] persists the rotated refresh token even when apiEndpoint validation throws', async function () {
|
|
339
|
+
const client = await primed(memoryStorage());
|
|
340
|
+
// Server rotates (REFRESH456 -> ROTATED1) but the response has no apiEndpoint
|
|
341
|
+
// => _connectionFromTokenResponse throws AFTER the token was ingested.
|
|
342
|
+
const bad = tokenBody({ refresh_token: 'ROTATED1' });
|
|
343
|
+
delete bad.apiEndpoint;
|
|
344
|
+
fetchMock.tokenResponse = jsonResponse(bad);
|
|
345
|
+
await expect(client.refresh()).to.be.rejectedWith(/apiEndpoint/);
|
|
346
|
+
// The old token is dead server-side; the client must now hold ROTATED1,
|
|
347
|
+
// and a subsequent refresh() must present it (not the stale REFRESH456).
|
|
348
|
+
expect(client.refreshToken).to.equal('ROTATED1');
|
|
349
|
+
fetchMock.tokenResponse = jsonResponse(tokenBody({ access_token: 'T3', refresh_token: 'R3', apiEndpoint: 'https://T3@host/p/' }));
|
|
350
|
+
const conn = await client.refresh();
|
|
351
|
+
expect(conn.token).to.equal('T3');
|
|
352
|
+
expect(new URLSearchParams(fetchMock.lastTokenRequest.body).get('refresh_token')).to.equal('ROTATED1');
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
describe('[OAL-SEAM] refresh-token persistence seam (F3)', function () {
|
|
357
|
+
it('[OAL-S1] refresh() works from a constructor-seeded refresh token (no handleCallback)', async function () {
|
|
358
|
+
const client = newClient(memoryStorage(), { refreshToken: 'SEEDED' });
|
|
359
|
+
fetchMock.tokenResponse = jsonResponse(tokenBody({ access_token: 'T2', apiEndpoint: 'https://T2@host/p/' }));
|
|
360
|
+
const conn = await client.refresh();
|
|
361
|
+
expect(conn.token).to.equal('T2');
|
|
362
|
+
expect(new URLSearchParams(fetchMock.lastTokenRequest.body).get('refresh_token')).to.equal('SEEDED');
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('[OAL-S2] the refreshToken getter reflects the rotated value', async function () {
|
|
366
|
+
const client = newClient(memoryStorage(), { refreshToken: 'SEEDED' });
|
|
367
|
+
expect(client.refreshToken).to.equal('SEEDED');
|
|
368
|
+
fetchMock.tokenResponse = jsonResponse(tokenBody({ refresh_token: 'ROTATED', apiEndpoint: 'https://T2@host/p/' }));
|
|
369
|
+
await client.refresh();
|
|
370
|
+
expect(client.refreshToken).to.equal('ROTATED');
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('[OAL-S3] onTokenRotated fires with the new token on callback and each refresh', async function () {
|
|
374
|
+
const storage = memoryStorage();
|
|
375
|
+
const rotations = [];
|
|
376
|
+
const client = newClient(storage, { onTokenRotated: (t) => rotations.push(t) });
|
|
377
|
+
await client.redirectToAuthorize({ state: 'S', redirect: () => {} });
|
|
378
|
+
await client.handleCallback('?code=CODE&state=S'); // REFRESH456
|
|
379
|
+
fetchMock.tokenResponse = jsonResponse(tokenBody({ refresh_token: 'ROTATED', apiEndpoint: 'https://T2@host/p/' }));
|
|
380
|
+
await client.refresh();
|
|
381
|
+
expect(rotations).to.deep.equal(['REFRESH456', 'ROTATED']);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('[OAL-S4] a throwing onTokenRotated never breaks the exchange', async function () {
|
|
385
|
+
const client = newClient(memoryStorage(), {
|
|
386
|
+
refreshToken: 'SEEDED',
|
|
387
|
+
onTokenRotated: () => { throw new Error('persist boom'); }
|
|
388
|
+
});
|
|
389
|
+
fetchMock.tokenResponse = jsonResponse(tokenBody({ access_token: 'T2', apiEndpoint: 'https://T2@host/p/' }));
|
|
390
|
+
const conn = await client.refresh();
|
|
391
|
+
expect(conn.token).to.equal('T2');
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
describe('[OAL-DPOP] DPoP option (RFC 9449)', function () {
|
|
396
|
+
function headerValue (init, name) {
|
|
397
|
+
const h = init && init.headers;
|
|
398
|
+
if (h == null) return null;
|
|
399
|
+
if (typeof h.get === 'function') return h.get(name);
|
|
400
|
+
const key = Object.keys(h).find((k) => k.toLowerCase() === name.toLowerCase());
|
|
401
|
+
return key ? h[key] : null;
|
|
402
|
+
}
|
|
403
|
+
const dpopTokenBody = (o = {}) => tokenBody(Object.assign({ token_type: 'DPoP' }, o));
|
|
404
|
+
|
|
405
|
+
it('[OAL-DP1] handleCallback with dpop:true returns a SignedConnection and sends a DPoP proof on the token request', async function () {
|
|
406
|
+
fetchMock.tokenResponse = jsonResponse(dpopTokenBody());
|
|
407
|
+
const client = newClient(memoryStorage(), { dpop: true });
|
|
408
|
+
await client.redirectToAuthorize({ state: 'S', redirect: () => {} });
|
|
409
|
+
const connection = await client.handleCallback('?code=CODE&state=S');
|
|
410
|
+
expect(connection.constructor.name).to.equal('SignedConnection');
|
|
411
|
+
expect(connection.token).to.equal('TOKEN123');
|
|
412
|
+
const proof = headerValue(fetchMock.lastTokenRequest.init, 'DPoP');
|
|
413
|
+
expect(proof, 'token request carries a DPoP header').to.be.a('string');
|
|
414
|
+
expect(proof.split('.')).to.have.length(3); // compact JWS
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('[OAL-DP2] default (no dpop) sends NO DPoP header and returns a plain Connection', async function () {
|
|
418
|
+
const client = newClient(memoryStorage());
|
|
419
|
+
await client.redirectToAuthorize({ state: 'S', redirect: () => {} });
|
|
420
|
+
const connection = await client.handleCallback('?code=CODE&state=S');
|
|
421
|
+
expect(connection.constructor.name).to.equal('Connection');
|
|
422
|
+
expect(headerValue(fetchMock.lastTokenRequest.init, 'DPoP')).to.equal(null);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('[OAL-DP3] refresh() keeps the DPoP binding — SignedConnection with the same key, proof on the request', async function () {
|
|
426
|
+
fetchMock.tokenResponse = jsonResponse(dpopTokenBody());
|
|
427
|
+
const client = newClient(memoryStorage(), { dpop: true });
|
|
428
|
+
await client.redirectToAuthorize({ state: 'S', redirect: () => {} });
|
|
429
|
+
const first = await client.handleCallback('?code=CODE&state=S');
|
|
430
|
+
fetchMock.tokenResponse = jsonResponse(dpopTokenBody({ access_token: 'TOKEN789', apiEndpoint: 'https://TOKEN789@host/path/' }));
|
|
431
|
+
const refreshed = await client.refresh();
|
|
432
|
+
expect(refreshed.constructor.name).to.equal('SignedConnection');
|
|
433
|
+
expect(refreshed.token).to.equal('TOKEN789');
|
|
434
|
+
// Same key pair reused across the flow (binding is stable).
|
|
435
|
+
expect(refreshed._dpopKeyPair).to.equal(first._dpopKeyPair);
|
|
436
|
+
expect(headerValue(fetchMock.lastTokenRequest.init, 'DPoP')).to.be.a('string');
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright (C) Pryv https://pryv.com
|
|
4
|
+
* This file is part of Pryv.io and released under BSD-Clause-3 License
|
|
5
|
+
* Refer to LICENSE file
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Shared secrets helper — unit tests.
|
|
10
|
+
*
|
|
11
|
+
* Driven with a stub connection that records what would be sent, so the crypto
|
|
12
|
+
* and the request shape are checked without a server (same approach as the CMC
|
|
13
|
+
* helper tests).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/* global describe, it */
|
|
17
|
+
|
|
18
|
+
const assert = require('node:assert/strict');
|
|
19
|
+
const crypto = require('node:crypto');
|
|
20
|
+
const SharedSecrets = require('../src/SharedSecrets');
|
|
21
|
+
|
|
22
|
+
function stubConnection (reply) {
|
|
23
|
+
const calls = [];
|
|
24
|
+
return {
|
|
25
|
+
calls,
|
|
26
|
+
async post (path, body) {
|
|
27
|
+
calls.push({ path, body });
|
|
28
|
+
return reply(path, body);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('[SSEC] shared-secrets helper', function () {
|
|
34
|
+
it('[SS01] parses a key and rejects malformed ones without throwing', function () {
|
|
35
|
+
assert.deepEqual(SharedSecrets.parseKey('abc.def'), { id: 'abc', randomPart: 'def' });
|
|
36
|
+
for (const bad of ['', '.', 'nodot', 'a.', '.b', 'a.b.c', null, undefined, 42, {}]) {
|
|
37
|
+
assert.equal(SharedSecrets.parseKey(bad), null, 'must reject ' + JSON.stringify(bad));
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('[SS02] hashes and HMACs identically to the server side', async function () {
|
|
42
|
+
const hex = await SharedSecrets.sha256Hex('hello');
|
|
43
|
+
assert.equal(hex, crypto.createHash('sha256').update('hello').digest('hex'));
|
|
44
|
+
|
|
45
|
+
const mac = await SharedSecrets.hmacSha256Hex('V', 'message');
|
|
46
|
+
assert.equal(mac, crypto.createHmac('sha256', 'V').update('message').digest('hex'));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('[SS03] plain create passes the params through and returns the server key', async function () {
|
|
50
|
+
const conn = stubConnection(() => ({
|
|
51
|
+
sharedSecret: { id: 'evt1', key: 'evt1.RANDOM', status: 'pending' }
|
|
52
|
+
}));
|
|
53
|
+
const out = await SharedSecrets.create(conn, {
|
|
54
|
+
ttl: 300, title: 't', onConsumed: { message: 'm' }, secret: { a: 1 }
|
|
55
|
+
});
|
|
56
|
+
assert.equal(conn.calls[0].path, 'shared-secrets');
|
|
57
|
+
assert.equal(conn.calls[0].body.ttl, 300);
|
|
58
|
+
assert.equal(conn.calls[0].body.keyHash, undefined, 'server mints the key in this mode');
|
|
59
|
+
assert.equal(out.key, 'evt1.RANDOM');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('[SS04] hmac create sends only a hash and a proof, never the verifier secret', async function () {
|
|
63
|
+
const conn = stubConnection(() => ({ sharedSecret: { id: 'evt2', status: 'pending' } }));
|
|
64
|
+
const out = await SharedSecrets.create(conn, {
|
|
65
|
+
ttl: 300,
|
|
66
|
+
title: 't',
|
|
67
|
+
onConsumed: { message: 'm' },
|
|
68
|
+
secret: { a: 1 },
|
|
69
|
+
signature: { type: 'hmac-sha256', verifierSecret: 'V' }
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const sent = conn.calls[0].body;
|
|
73
|
+
assert.match(sent.keyHash, /^[0-9a-f]{64}$/);
|
|
74
|
+
assert.equal(sent.signature.type, 'hmac-sha256');
|
|
75
|
+
const serialized = JSON.stringify(sent);
|
|
76
|
+
assert.ok(!serialized.includes('"V"'), 'the verifier secret must never be sent');
|
|
77
|
+
|
|
78
|
+
// The key is composed client-side, and its random half is what was hashed.
|
|
79
|
+
const parsed = SharedSecrets.parseKey(out.key);
|
|
80
|
+
assert.equal(parsed.id, 'evt2');
|
|
81
|
+
assert.equal(await SharedSecrets.sha256Hex(parsed.randomPart), sent.keyHash);
|
|
82
|
+
// …and the proof is the HMAC over that same random half.
|
|
83
|
+
assert.equal(sent.signature.value,
|
|
84
|
+
crypto.createHmac('sha256', 'V').update(parsed.randomPart).digest('hex'));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('[SS05] the random half carries real entropy and never repeats', async function () {
|
|
88
|
+
const conn = stubConnection(() => ({ sharedSecret: { id: 'e', status: 'pending' } }));
|
|
89
|
+
const seen = new Set();
|
|
90
|
+
for (let i = 0; i < 50; i++) {
|
|
91
|
+
const out = await SharedSecrets.create(conn, {
|
|
92
|
+
ttl: 60, title: 't', onConsumed: { message: 'm' }, secret: 1,
|
|
93
|
+
signature: { type: 'hmac-sha256', verifierSecret: 'V' }
|
|
94
|
+
});
|
|
95
|
+
const { randomPart } = SharedSecrets.parseKey(out.key);
|
|
96
|
+
assert.ok(randomPart.length >= 32, 'too short: ' + randomPart);
|
|
97
|
+
assert.match(randomPart, /^[A-Za-z0-9_-]+$/);
|
|
98
|
+
seen.add(randomPart);
|
|
99
|
+
}
|
|
100
|
+
assert.equal(seen.size, 50);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('[SS06] status posts the key in the body, never in the path', async function () {
|
|
104
|
+
const conn = stubConnection(() => ({ sharedSecret: { id: 'e', status: 'consumed' } }));
|
|
105
|
+
await SharedSecrets.status(conn, 'e.RANDOM');
|
|
106
|
+
assert.equal(conn.calls[0].path, 'shared-secrets/status',
|
|
107
|
+
'the key must not appear in the path — it would land in access logs');
|
|
108
|
+
assert.equal(conn.calls[0].body.key, 'e.RANDOM');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
/* global describe, it, before, expect */
|
|
6
|
+
|
|
7
|
+
const SignedConnection = require('../src/SignedConnection');
|
|
8
|
+
const { createDPoPProof } = require('../src/dpopProof');
|
|
9
|
+
|
|
10
|
+
const subtle = globalThis.crypto.subtle;
|
|
11
|
+
const decoder = new TextDecoder();
|
|
12
|
+
|
|
13
|
+
function b64urlToBytes (s) {
|
|
14
|
+
const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((s.length + 3) % 4);
|
|
15
|
+
const bin = atob(b64);
|
|
16
|
+
const bytes = new Uint8Array(bin.length);
|
|
17
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
18
|
+
return bytes;
|
|
19
|
+
}
|
|
20
|
+
function decodeSegment (seg) { return JSON.parse(decoder.decode(b64urlToBytes(seg))); }
|
|
21
|
+
async function sha256b64url (str) {
|
|
22
|
+
const d = await subtle.digest('SHA-256', new TextEncoder().encode(str));
|
|
23
|
+
return btoa(String.fromCharCode(...new Uint8Array(d))).replace(/=+$/g, '').replace(/\+/g, '-').replace(/\//g, '_');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('[OAUTH-SC] SignedConnection + DPoP proof', function () {
|
|
27
|
+
let keyPair;
|
|
28
|
+
before(async function () {
|
|
29
|
+
keyPair = await subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('[SC-PROOF] createDPoPProof', function () {
|
|
33
|
+
it('[SC-P1] builds a compact JWS whose signature verifies against the embedded key', async function () {
|
|
34
|
+
const proof = await createDPoPProof({ keyPair, htm: 'GET', htu: 'https://host/path/events', accessToken: 'TOK' });
|
|
35
|
+
const [h, p, sig] = proof.split('.');
|
|
36
|
+
expect(proof.split('.')).to.have.length(3);
|
|
37
|
+
|
|
38
|
+
const header = decodeSegment(h);
|
|
39
|
+
expect(header.typ).to.equal('dpop+jwt');
|
|
40
|
+
expect(header.alg).to.equal('ES256');
|
|
41
|
+
expect(header.jwk).to.include.keys(['kty', 'crv', 'x', 'y']);
|
|
42
|
+
expect(header.jwk).to.not.have.property('d'); // never the private half
|
|
43
|
+
|
|
44
|
+
// The signature verifies against the public key embedded in the header.
|
|
45
|
+
const pub = await subtle.importKey('jwk', header.jwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
|
|
46
|
+
const ok = await subtle.verify(
|
|
47
|
+
{ name: 'ECDSA', hash: 'SHA-256' }, pub, b64urlToBytes(sig), new TextEncoder().encode(h + '.' + p)
|
|
48
|
+
);
|
|
49
|
+
expect(ok).to.equal(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('[SC-P2] binds htm, htu (query stripped), iat, a unique jti, and ath', async function () {
|
|
53
|
+
const proof = await createDPoPProof({ keyPair, htm: 'POST', htu: 'https://host/path/events?a=1#frag', accessToken: 'THE-TOKEN' });
|
|
54
|
+
const payload = decodeSegment(proof.split('.')[1]);
|
|
55
|
+
expect(payload.htm).to.equal('POST');
|
|
56
|
+
expect(payload.htu).to.equal('https://host/path/events'); // no query / fragment
|
|
57
|
+
expect(payload.iat).to.be.a('number');
|
|
58
|
+
expect(payload.jti).to.be.a('string').with.length.greaterThan(8);
|
|
59
|
+
expect(payload.ath).to.equal(await sha256b64url('THE-TOKEN'));
|
|
60
|
+
|
|
61
|
+
const again = await createDPoPProof({ keyPair, htm: 'POST', htu: 'https://host/path/events', accessToken: 'THE-TOKEN' });
|
|
62
|
+
expect(decodeSegment(again.split('.')[1]).jti).to.not.equal(payload.jti); // fresh each call
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('[SC-P3] omits ath when no access token is given (token-endpoint shape)', async function () {
|
|
66
|
+
const proof = await createDPoPProof({ keyPair, htm: 'POST', htu: 'https://host/oauth2/token' });
|
|
67
|
+
expect(decodeSegment(proof.split('.')[1])).to.not.have.property('ath');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('[SC-CONN] SignedConnection', function () {
|
|
72
|
+
it('[SC-C1] rejects construction without a key pair', function () {
|
|
73
|
+
expect(() => new SignedConnection('https://TOK@host/path/')).to.throw(/CryptoKeyPair/);
|
|
74
|
+
expect(() => new SignedConnection('https://TOK@host/path/', {})).to.throw(/CryptoKeyPair/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('[SC-C2] _authHeaders presents the token under the DPoP scheme + a matching proof', async function () {
|
|
78
|
+
const conn = new SignedConnection('https://TOK@host/path/', keyPair);
|
|
79
|
+
expect(conn.token).to.equal('TOK');
|
|
80
|
+
const headers = await conn._authHeaders('GET', conn.endpoint + 'events');
|
|
81
|
+
expect(headers.Authorization).to.equal('DPoP TOK');
|
|
82
|
+
expect(headers.DPoP).to.be.a('string');
|
|
83
|
+
const payload = decodeSegment(headers.DPoP.split('.')[1]);
|
|
84
|
+
expect(payload.htm).to.equal('GET');
|
|
85
|
+
expect(payload.htu).to.equal('https://host/path/events');
|
|
86
|
+
expect(payload.ath).to.equal(await sha256b64url('TOK')); // bound to THIS connection's token
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('[SC-C3] is a Connection (inherits the data API)', function () {
|
|
90
|
+
const conn = new SignedConnection('https://TOK@host/path/', keyPair);
|
|
91
|
+
const Connection = require('../src/Connection');
|
|
92
|
+
expect(conn).to.be.instanceOf(Connection);
|
|
93
|
+
expect(typeof conn.get).to.equal('function');
|
|
94
|
+
expect(typeof conn.post).to.equal('function');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
/* global describe, it, expect */
|
|
6
|
+
|
|
7
|
+
// `pryv` consumes the ESM-only `oauth4webapi` package via Node's require(esm)
|
|
8
|
+
// (unflagged on Node >= 20.19 / >= 22.12 — hence the engines floor). That only
|
|
9
|
+
// works while nothing in the package's import graph uses top-level await; a
|
|
10
|
+
// future dependency upgrade that introduced TLA would throw
|
|
11
|
+
// ERR_REQUIRE_ASYNC_MODULE at require time. This guard makes such an upgrade
|
|
12
|
+
// fail here in CI instead of at consumer runtime.
|
|
13
|
+
describe('[ESMD] ESM-only deps load via require(esm)', function () {
|
|
14
|
+
it('[ESMD1] oauth4webapi requires synchronously (no top-level await in its graph)', function () {
|
|
15
|
+
const oauth = require('oauth4webapi');
|
|
16
|
+
expect(typeof oauth.generateRandomCodeVerifier).to.equal('function');
|
|
17
|
+
expect(typeof oauth.calculatePKCECodeChallenge).to.equal('function');
|
|
18
|
+
});
|
|
19
|
+
});
|