pryv 3.8.1 → 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 +2 -0
- package/package.json +1 -1
- package/src/Connection.js +15 -2
- package/src/OAuth2Client.js +46 -3
- package/src/SharedSecrets.js +172 -0
- package/src/SignedConnection.js +66 -0
- package/src/dpopProof.js +96 -0
- package/src/index.d.ts +17 -0
- package/src/index.js +3 -0
- package/test/OAuth2Client.test.js +45 -0
- package/test/SharedSecrets.test.js +110 -0
- package/test/SignedConnection.test.js +97 -0
package/README.md
CHANGED
|
@@ -58,6 +58,7 @@ Other distributions available:
|
|
|
58
58
|
- Socket.IO: [NPM package](https://www.npmjs.com/package/@pryv/socket.io), [README](https://github.com/pryv/lib-js/tree/master/components/pryv-socket.io#readme)
|
|
59
59
|
- Monitor: [NPM package](https://www.npmjs.com/package/@pryv/monitor), [README](https://github.com/pryv/lib-js/tree/master/components/pryv-monitor#readme)
|
|
60
60
|
- CMC (Cross-account Messaging & Consent) client helpers: [NPM package](https://www.npmjs.com/package/@pryv/cmc), [README](https://github.com/pryv/lib-js/tree/master/components/pryv-cmc#readme)
|
|
61
|
+
- Encryption (client-side event encryption/decryption): [README](https://github.com/pryv/lib-js/tree/master/components/pryv-encryption#readme)
|
|
61
62
|
|
|
62
63
|
|
|
63
64
|
### Quick example
|
|
@@ -717,6 +718,7 @@ The project is structured as a monorepo with components (a.k.a. workspaces in NP
|
|
|
717
718
|
- `pryv-socket.io`: Socket.IO add-on
|
|
718
719
|
- `pryv-monitor`: Monitor add-on
|
|
719
720
|
- `pryv-cmc`: CMC (Cross-account Messaging & Consent) client helpers
|
|
721
|
+
- `pryv-encryption`: client-side event encryption/decryption add-on
|
|
720
722
|
|
|
721
723
|
|
|
722
724
|
|
package/package.json
CHANGED
package/src/Connection.js
CHANGED
|
@@ -263,7 +263,7 @@ class Connection {
|
|
|
263
263
|
*/
|
|
264
264
|
async _postFetchRaw (path, data, contentType) {
|
|
265
265
|
const headers = {
|
|
266
|
-
|
|
266
|
+
...(await this._authHeaders('POST', this.endpoint + path)),
|
|
267
267
|
Accept: 'application/json'
|
|
268
268
|
};
|
|
269
269
|
// optional for form-data llowing fetch to
|
|
@@ -280,6 +280,19 @@ class Connection {
|
|
|
280
280
|
return { response, body };
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
/**
|
|
284
|
+
* @protected
|
|
285
|
+
* Authentication headers for a request. The base Connection sends the
|
|
286
|
+
* bearer token as-is; {@link SignedConnection} overrides this to attach a
|
|
287
|
+
* per-request DPoP (RFC 9449) proof. Async because a proof requires signing.
|
|
288
|
+
* @param {string} method - HTTP method (informs the proof's `htm`)
|
|
289
|
+
* @param {string} url - full request URL WITHOUT query (informs `htu`)
|
|
290
|
+
* @returns {Promise<Object>} header map to merge into the request
|
|
291
|
+
*/
|
|
292
|
+
async _authHeaders (method, url) {
|
|
293
|
+
return { Authorization: this.token };
|
|
294
|
+
}
|
|
295
|
+
|
|
283
296
|
/**
|
|
284
297
|
* GET from API and return results
|
|
285
298
|
* @param {string} path - API path
|
|
@@ -308,7 +321,7 @@ class Connection {
|
|
|
308
321
|
}
|
|
309
322
|
const response = await fetch(this.endpoint + path + queryStr, {
|
|
310
323
|
headers: {
|
|
311
|
-
|
|
324
|
+
...(await this._authHeaders('GET', this.endpoint + path)),
|
|
312
325
|
Accept: 'application/json'
|
|
313
326
|
}
|
|
314
327
|
});
|
package/src/OAuth2Client.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
const oauth = require('oauth4webapi');
|
|
6
6
|
const Connection = require('./Connection');
|
|
7
|
+
const SignedConnection = require('./SignedConnection');
|
|
7
8
|
const utils = require('./utils');
|
|
8
9
|
|
|
9
10
|
// sessionStorage key prefix for the per-flow PKCE verifier, keyed by `state`.
|
|
@@ -69,9 +70,16 @@ class OAuth2Client {
|
|
|
69
70
|
* NEW refresh token every time it rotates (after `handleCallback()` and each
|
|
70
71
|
* `refresh()`), so the app can persist the minimal secret. Exceptions it throws
|
|
71
72
|
* are swallowed (persistence must not break the token exchange).
|
|
73
|
+
* @param {boolean} [options.dpop=false] - Opt into RFC 9449 DPoP: an ES256 key
|
|
74
|
+
* pair is generated per client, the token is bound to it at issuance, and
|
|
75
|
+
* `handleCallback()` / `refresh()` return a {@link SignedConnection} that
|
|
76
|
+
* proves possession of the key on every request. A token stolen from the
|
|
77
|
+
* resulting connection is useless without the private key. The key is
|
|
78
|
+
* session-scoped (not persisted) — re-seeding a `refreshToken` after reload
|
|
79
|
+
* mints a fresh binding for the new key.
|
|
72
80
|
*/
|
|
73
81
|
constructor (options = {}) {
|
|
74
|
-
const { authorizationServer, clientId, redirectUri, scope, storage, refreshToken, onTokenRotated } = options;
|
|
82
|
+
const { authorizationServer, clientId, redirectUri, scope, storage, refreshToken, onTokenRotated, dpop } = options;
|
|
75
83
|
if (!authorizationServer) throw new Error('OAuth2Client: "authorizationServer" is required');
|
|
76
84
|
if (!clientId) throw new Error('OAuth2Client: "clientId" is required');
|
|
77
85
|
if (!redirectUri) throw new Error('OAuth2Client: "redirectUri" is required');
|
|
@@ -88,6 +96,12 @@ class OAuth2Client {
|
|
|
88
96
|
this._as = null;
|
|
89
97
|
this._refreshToken = refreshToken || null;
|
|
90
98
|
this._onTokenRotated = (typeof onTokenRotated === 'function') ? onTokenRotated : null;
|
|
99
|
+
// DPoP (RFC 9449): lazily generated ES256 key pair + oauth4webapi handle,
|
|
100
|
+
// shared between the token-endpoint proofs (handle) and the resulting
|
|
101
|
+
// SignedConnection's per-request proofs (same key → same bound thumbprint).
|
|
102
|
+
this._dpop = dpop === true;
|
|
103
|
+
this._dpopKeyPair = null;
|
|
104
|
+
this._dpopHandle = null;
|
|
91
105
|
// In-flight refresh() promise — dedups concurrent callers onto one token
|
|
92
106
|
// request so they can't each present the same refresh token and have the
|
|
93
107
|
// loser rejected as reuse (invalid_grant) by an always-rotating server.
|
|
@@ -106,6 +120,26 @@ class OAuth2Client {
|
|
|
106
120
|
return this._refreshToken;
|
|
107
121
|
}
|
|
108
122
|
|
|
123
|
+
/**
|
|
124
|
+
* @private
|
|
125
|
+
* Lazily generate the DPoP ES256 key pair + oauth4webapi handle (once).
|
|
126
|
+
* Returns `undefined` when DPoP is off, so call sites can spread it as an
|
|
127
|
+
* absent option. The key is `extractable` so the resulting
|
|
128
|
+
* {@link SignedConnection} can export the public JWK into each proof.
|
|
129
|
+
* @returns {Promise<Object|undefined>} the DPoP handle, or undefined
|
|
130
|
+
*/
|
|
131
|
+
async _ensureDPoP () {
|
|
132
|
+
if (!this._dpop) return undefined;
|
|
133
|
+
if (this._dpopHandle == null) {
|
|
134
|
+
this._dpopKeyPair = await oauth.generateKeyPair('ES256', { extractable: true });
|
|
135
|
+
// DPoP() only reads the optional `clockSkew` symbol off the client; our
|
|
136
|
+
// public-client literal carries none, which trips oauth4webapi's
|
|
137
|
+
// weak-type guard on the branded param. Cast — the value is correct.
|
|
138
|
+
this._dpopHandle = oauth.DPoP(/** @type {any} */ (this._client), this._dpopKeyPair);
|
|
139
|
+
}
|
|
140
|
+
return this._dpopHandle;
|
|
141
|
+
}
|
|
142
|
+
|
|
109
143
|
/**
|
|
110
144
|
* @private
|
|
111
145
|
* Lazily discover + cache the authorization-server metadata (RFC 8414).
|
|
@@ -182,8 +216,10 @@ class OAuth2Client {
|
|
|
182
216
|
try {
|
|
183
217
|
// Throws on authorization errors (e.g. access_denied) or a state mismatch.
|
|
184
218
|
const callbackParams = oauth.validateAuthResponse(as, this._client, params, state);
|
|
219
|
+
const dpopHandle = await this._ensureDPoP();
|
|
185
220
|
const response = await oauth.authorizationCodeGrantRequest(
|
|
186
|
-
as, this._client, this._clientAuth, callbackParams, this.redirectUri, codeVerifier
|
|
221
|
+
as, this._client, this._clientAuth, callbackParams, this.redirectUri, codeVerifier,
|
|
222
|
+
dpopHandle ? { DPoP: dpopHandle } : undefined
|
|
187
223
|
);
|
|
188
224
|
const result = await oauth.processAuthorizationCodeResponse(as, this._client, response);
|
|
189
225
|
return this._connectionFromTokenResponse(result);
|
|
@@ -221,7 +257,11 @@ class OAuth2Client {
|
|
|
221
257
|
throw new Error('OAuth2Client: no refresh token available; call handleCallback() first');
|
|
222
258
|
}
|
|
223
259
|
const as = await this._discover();
|
|
224
|
-
const
|
|
260
|
+
const dpopHandle = await this._ensureDPoP();
|
|
261
|
+
const response = await oauth.refreshTokenGrantRequest(
|
|
262
|
+
as, this._client, this._clientAuth, this._refreshToken,
|
|
263
|
+
dpopHandle ? { DPoP: dpopHandle } : undefined
|
|
264
|
+
);
|
|
225
265
|
const result = await oauth.processRefreshTokenResponse(as, this._client, response);
|
|
226
266
|
return this._connectionFromTokenResponse(result);
|
|
227
267
|
}
|
|
@@ -252,6 +292,9 @@ class OAuth2Client {
|
|
|
252
292
|
// endpoint to close that parser-divergence gap.
|
|
253
293
|
const { endpoint } = utils.extractTokenAndAPIEndpoint(String(apiEndpoint));
|
|
254
294
|
assertSecureApiEndpoint(endpoint);
|
|
295
|
+
// DPoP flow → a SignedConnection bound to the same key the token was
|
|
296
|
+
// issued against; every request then carries a proof of possession.
|
|
297
|
+
if (this._dpop) return new SignedConnection(String(apiEndpoint), this._dpopKeyPair);
|
|
255
298
|
return new Connection(String(apiEndpoint));
|
|
256
299
|
}
|
|
257
300
|
|
|
@@ -0,0 +1,172 @@
|
|
|
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 — hand a secret to a third party by one-time key.
|
|
10
|
+
*
|
|
11
|
+
* The problem this solves: passing a secret (typically an apiEndpoint carrying
|
|
12
|
+
* an access token) to a third party usually means putting it in a URL, where it
|
|
13
|
+
* survives in browser history, referrer headers and server access logs. Instead,
|
|
14
|
+
* store the secret on the account and hand over a random key that can be
|
|
15
|
+
* redeemed exactly once.
|
|
16
|
+
*
|
|
17
|
+
* Redemption needs no credentials — the key IS the credential — so the third
|
|
18
|
+
* party can use `retrieve` with nothing but the URL you gave them.
|
|
19
|
+
*
|
|
20
|
+
* All crypto goes through the Web Crypto API rather than Node's `crypto`, so the
|
|
21
|
+
* same code runs in the browser bundle and in Node.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Reads `key` back into the pieces the API expects. */
|
|
25
|
+
function parseKey (key) {
|
|
26
|
+
if (typeof key !== 'string') return null;
|
|
27
|
+
const parts = key.split('.');
|
|
28
|
+
if (parts.length !== 2 || parts[0].length === 0 || parts[1].length === 0) return null;
|
|
29
|
+
return { id: parts[0], randomPart: parts[1] };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function base64url (bytes) {
|
|
33
|
+
let binary = '';
|
|
34
|
+
for (const b of new Uint8Array(bytes)) binary += String.fromCharCode(b);
|
|
35
|
+
const b64 = typeof btoa === 'function'
|
|
36
|
+
? btoa(binary)
|
|
37
|
+
: Buffer.from(binary, 'binary').toString('base64');
|
|
38
|
+
return b64.replace(/=+$/g, '').replace(/\+/g, '-').replace(/\//g, '_');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function toHex (buffer) {
|
|
42
|
+
return Array.from(new Uint8Array(buffer))
|
|
43
|
+
.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function subtle () {
|
|
47
|
+
const c = globalThis.crypto;
|
|
48
|
+
if (c == null || c.subtle == null) {
|
|
49
|
+
throw new Error('Web Crypto is unavailable — Node 20+ or a secure browser context is required.');
|
|
50
|
+
}
|
|
51
|
+
return c.subtle;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 24 random bytes (192 bits), base64url — the strength of the key itself. */
|
|
55
|
+
function randomPart () {
|
|
56
|
+
const bytes = new Uint8Array(24);
|
|
57
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
58
|
+
return base64url(bytes);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** SHA-256 of a string, hex — what the server stores in place of the key. */
|
|
62
|
+
async function sha256Hex (value) {
|
|
63
|
+
const data = new TextEncoder().encode(value);
|
|
64
|
+
return toHex(await subtle().digest('SHA-256', data));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* HMAC-SHA256 of `message` under `verifierSecret`, hex.
|
|
69
|
+
*
|
|
70
|
+
* Used for the `hmac-sha256` signature: creator and redeemer share the verifier
|
|
71
|
+
* secret out of band, and only the HMAC ever reaches the server — so the server
|
|
72
|
+
* can check the proof without being able to produce one.
|
|
73
|
+
*/
|
|
74
|
+
async function hmacSha256Hex (verifierSecret, message) {
|
|
75
|
+
const enc = new TextEncoder();
|
|
76
|
+
const cryptoKey = await subtle().importKey(
|
|
77
|
+
'raw', enc.encode(verifierSecret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
78
|
+
return toHex(await subtle().sign('HMAC', cryptoKey, enc.encode(message)));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Create a shared secret on the account `connection` is authenticated against.
|
|
83
|
+
*
|
|
84
|
+
* @param {Connection} connection
|
|
85
|
+
* @param {Object} params
|
|
86
|
+
* @param {number} params.ttl seconds the secret stays redeemable (required)
|
|
87
|
+
* @param {string} params.title shown to the account owner (required)
|
|
88
|
+
* @param {Object} params.onConsumed `{ message, returnUrl? }` shown once spent
|
|
89
|
+
* @param {*} params.secret the payload — any JSON value
|
|
90
|
+
* @param {Object} [params.signature] `{ type: 'secret', value }` or
|
|
91
|
+
* `{ type: 'hmac-sha256', verifierSecret }`. For the HMAC form the key
|
|
92
|
+
* material is generated here so the proof can be bound before creation.
|
|
93
|
+
* @returns {Promise<Object>} `{ id, key, expires, ... }` — `key` is returned
|
|
94
|
+
* only here and cannot be recovered later.
|
|
95
|
+
*/
|
|
96
|
+
async function create (connection, params) {
|
|
97
|
+
const { signature, ...rest } = params || {};
|
|
98
|
+
const body = { ...rest };
|
|
99
|
+
let key = null;
|
|
100
|
+
|
|
101
|
+
if (signature != null && signature.type === 'hmac-sha256') {
|
|
102
|
+
// The HMAC is bound to key material that must exist BEFORE the item does,
|
|
103
|
+
// so the client generates the random half and sends only its hash.
|
|
104
|
+
const random = randomPart();
|
|
105
|
+
body.keyHash = await sha256Hex(random);
|
|
106
|
+
body.signature = {
|
|
107
|
+
type: 'hmac-sha256',
|
|
108
|
+
value: await hmacSha256Hex(signature.verifierSecret, random)
|
|
109
|
+
};
|
|
110
|
+
const res = await connection.post('shared-secrets', body);
|
|
111
|
+
return { ...res.sharedSecret, key: res.sharedSecret.id + '.' + random };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (signature != null) body.signature = signature;
|
|
115
|
+
const res = await connection.post('shared-secrets', body);
|
|
116
|
+
key = res.sharedSecret.key;
|
|
117
|
+
return { ...res.sharedSecret, key };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Redeem a key. Needs no credentials, so a third party can call it directly.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} apiEndpoint the account's API endpoint (no token needed)
|
|
124
|
+
* @param {string} key
|
|
125
|
+
* @param {Object} [options]
|
|
126
|
+
* @param {string} [options.passphrase] for a `secret`-type signature
|
|
127
|
+
* @param {string} [options.verifierSecret] for an `hmac-sha256` signature
|
|
128
|
+
* @returns {Promise<{secret: *}>} on success. On refusal the API error carries
|
|
129
|
+
* the creator's `message` and optional `returnUrl` to show the end user.
|
|
130
|
+
*/
|
|
131
|
+
async function retrieve (apiEndpoint, key, options = {}) {
|
|
132
|
+
const body = { key };
|
|
133
|
+
if (options.passphrase != null) {
|
|
134
|
+
body.signature = { type: 'secret', payload: options.passphrase };
|
|
135
|
+
} else if (options.verifierSecret != null) {
|
|
136
|
+
const parsed = parseKey(key);
|
|
137
|
+
if (parsed == null) throw new Error('Malformed shared-secret key.');
|
|
138
|
+
body.signature = {
|
|
139
|
+
type: 'hmac-sha256',
|
|
140
|
+
payload: await hmacSha256Hex(options.verifierSecret, parsed.randomPart)
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const base = apiEndpoint.endsWith('/') ? apiEndpoint : apiEndpoint + '/';
|
|
144
|
+
const res = await fetch(base + 'shared-secrets/retrieve', {
|
|
145
|
+
method: 'POST',
|
|
146
|
+
headers: { 'Content-Type': 'application/json' },
|
|
147
|
+
body: JSON.stringify(body)
|
|
148
|
+
});
|
|
149
|
+
const parsed = await res.json();
|
|
150
|
+
if (!res.ok) {
|
|
151
|
+
const err = new Error(parsed?.error?.message || 'Shared secret unavailable.');
|
|
152
|
+
err.id = parsed?.error?.id;
|
|
153
|
+
err.returnUrl = parsed?.error?.data?.returnUrl;
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
156
|
+
return parsed;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Status of a shared secret, without consuming it. Creator or personal token. */
|
|
160
|
+
async function status (connection, key) {
|
|
161
|
+
const res = await connection.post('shared-secrets/status', { key });
|
|
162
|
+
return res.sharedSecret;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
module.exports = {
|
|
166
|
+
create,
|
|
167
|
+
retrieve,
|
|
168
|
+
status,
|
|
169
|
+
parseKey,
|
|
170
|
+
hmacSha256Hex,
|
|
171
|
+
sha256Hex
|
|
172
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
const Connection = require('./Connection');
|
|
6
|
+
const { createDPoPProof } = require('./dpopProof');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @class SignedConnection
|
|
10
|
+
* A {@link Connection} whose access token is sender-constrained via DPoP
|
|
11
|
+
* (RFC 9449): every request carries a fresh proof signed with a client-held
|
|
12
|
+
* key, and presents the token under the `DPoP` auth-scheme. A token stolen
|
|
13
|
+
* from this connection is useless to anyone who does not also hold the
|
|
14
|
+
* private key.
|
|
15
|
+
*
|
|
16
|
+
* Built by {@link OAuth2Client} with `dpop: true` (which binds the token to
|
|
17
|
+
* the key at issuance and hands the same key pair here). It can also be
|
|
18
|
+
* constructed directly from an `apiEndpoint` whose token was already bound
|
|
19
|
+
* to `keyPair` — mismatched keys make every request fail the server's
|
|
20
|
+
* proof-of-possession check.
|
|
21
|
+
*
|
|
22
|
+
* The key pair is ES256 (EC P-256), the only algorithm the server accepts;
|
|
23
|
+
* its public key must be exportable to JWK. Attachment downloads via a
|
|
24
|
+
* `readToken` need no proof (the server exempts that capability), so those
|
|
25
|
+
* keep working through a plain URL.
|
|
26
|
+
*
|
|
27
|
+
* @memberof pryv
|
|
28
|
+
*/
|
|
29
|
+
class SignedConnection extends Connection {
|
|
30
|
+
/**
|
|
31
|
+
* @param {string} apiEndpoint - Pryv API endpoint carrying the DPoP-bound token
|
|
32
|
+
* @param {CryptoKeyPair} keyPair - the ES256 key the token is bound to
|
|
33
|
+
* @param {Service} [service] - optional pre-built Service
|
|
34
|
+
*/
|
|
35
|
+
constructor (apiEndpoint, keyPair, service) {
|
|
36
|
+
super(apiEndpoint, service);
|
|
37
|
+
if (keyPair == null || keyPair.privateKey == null || keyPair.publicKey == null) {
|
|
38
|
+
throw new Error('SignedConnection: an ES256 CryptoKeyPair ({ publicKey, privateKey }) is required');
|
|
39
|
+
}
|
|
40
|
+
this._dpopKeyPair = keyPair;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @protected
|
|
45
|
+
* @override
|
|
46
|
+
* Attach a per-request DPoP proof and present the token under the DPoP
|
|
47
|
+
* scheme. `url` is the request URL without query — the proof's `htu`.
|
|
48
|
+
* @param {string} method
|
|
49
|
+
* @param {string} url
|
|
50
|
+
* @returns {Promise<Object>}
|
|
51
|
+
*/
|
|
52
|
+
async _authHeaders (method, url) {
|
|
53
|
+
const proof = await createDPoPProof({
|
|
54
|
+
keyPair: this._dpopKeyPair,
|
|
55
|
+
htm: method,
|
|
56
|
+
htu: url,
|
|
57
|
+
accessToken: this.token
|
|
58
|
+
});
|
|
59
|
+
return {
|
|
60
|
+
Authorization: 'DPoP ' + this.token,
|
|
61
|
+
DPoP: proof
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = SignedConnection;
|
package/src/dpopProof.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* DPoP (RFC 9449) proof builder — isomorphic (browser + Node ≥ 20), built
|
|
8
|
+
* only on WebCrypto (`globalThis.crypto.subtle`) so it survives the webpack
|
|
9
|
+
* browser bundle unchanged. ES256 only, matching the server
|
|
10
|
+
* (`open-pryv.io/components/oauth2/src/dpop.ts`).
|
|
11
|
+
*
|
|
12
|
+
* A proof is a compact JWS whose header carries the public JWK and whose
|
|
13
|
+
* payload binds the request: `htm` (method), `htu` (URL without query —
|
|
14
|
+
* the server compares in RFC 9449 §4.3 normalized form), `iat`, a fresh
|
|
15
|
+
* single-use `jti`, and — for resource-server requests — `ath`
|
|
16
|
+
* (`base64url(sha256(access_token))`).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const subtle = globalThis.crypto.subtle;
|
|
20
|
+
const textEncoder = new TextEncoder();
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @private
|
|
24
|
+
* base64url-encode raw bytes (no padding). `btoa` is available in browsers
|
|
25
|
+
* and Node ≥ 16.
|
|
26
|
+
* @param {Uint8Array} bytes
|
|
27
|
+
* @returns {string}
|
|
28
|
+
*/
|
|
29
|
+
function base64url (bytes) {
|
|
30
|
+
let binary = '';
|
|
31
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
32
|
+
return btoa(binary).replace(/=+$/g, '').replace(/\+/g, '-').replace(/\//g, '_');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @private
|
|
37
|
+
* @param {string} str
|
|
38
|
+
* @returns {Promise<string>} base64url(sha256(str))
|
|
39
|
+
*/
|
|
40
|
+
async function sha256base64url (str) {
|
|
41
|
+
const digest = await subtle.digest('SHA-256', textEncoder.encode(str));
|
|
42
|
+
return base64url(new Uint8Array(digest));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @private
|
|
47
|
+
* htu comparison form: scheme + host + path, query and fragment dropped
|
|
48
|
+
* (RFC 9449 §4.3 — the server normalizes the same way, so a signed URL that
|
|
49
|
+
* still carries a query would match too, but we strip for clarity).
|
|
50
|
+
* @param {string} url
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
function stripQuery (url) {
|
|
54
|
+
const parsed = new URL(url);
|
|
55
|
+
return parsed.origin + parsed.pathname;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build a DPoP proof for one request.
|
|
60
|
+
*
|
|
61
|
+
* @param {Object} params
|
|
62
|
+
* @param {CryptoKeyPair} params.keyPair - ES256 (EC P-256) key pair. The public
|
|
63
|
+
* key MUST be exportable to JWK; the private key is used to sign.
|
|
64
|
+
* @param {string} params.htm - HTTP method (e.g. `'GET'`, `'POST'`).
|
|
65
|
+
* @param {string} params.htu - full request URL; query/fragment are stripped.
|
|
66
|
+
* @param {string} [params.accessToken] - when present, the proof carries
|
|
67
|
+
* `ath = base64url(sha256(accessToken))`, binding it to that token
|
|
68
|
+
* (required on resource-server requests; omitted on the token endpoint).
|
|
69
|
+
* @returns {Promise<string>} the compact JWS proof for the `DPoP` header
|
|
70
|
+
*/
|
|
71
|
+
async function createDPoPProof ({ keyPair, htm, htu, accessToken }) {
|
|
72
|
+
const jwk = await subtle.exportKey('jwk', keyPair.publicKey);
|
|
73
|
+
const header = {
|
|
74
|
+
typ: 'dpop+jwt',
|
|
75
|
+
alg: 'ES256',
|
|
76
|
+
jwk: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }
|
|
77
|
+
};
|
|
78
|
+
const payload = {
|
|
79
|
+
jti: globalThis.crypto.randomUUID(),
|
|
80
|
+
htm,
|
|
81
|
+
htu: stripQuery(htu),
|
|
82
|
+
iat: Math.floor(Date.now() / 1000)
|
|
83
|
+
};
|
|
84
|
+
if (accessToken != null) payload.ath = await sha256base64url(accessToken);
|
|
85
|
+
|
|
86
|
+
const signingInput = base64url(textEncoder.encode(JSON.stringify(header))) +
|
|
87
|
+
'.' + base64url(textEncoder.encode(JSON.stringify(payload)));
|
|
88
|
+
// ECDSA WebCrypto signatures are the raw 64-byte r||s concatenation — exactly
|
|
89
|
+
// the JWS ES256 form (the server rejects DER).
|
|
90
|
+
const signature = await subtle.sign(
|
|
91
|
+
{ name: 'ECDSA', hash: 'SHA-256' }, keyPair.privateKey, textEncoder.encode(signingInput)
|
|
92
|
+
);
|
|
93
|
+
return signingInput + '.' + base64url(new Uint8Array(signature));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { createDPoPProof };
|
package/src/index.d.ts
CHANGED
|
@@ -763,6 +763,17 @@ declare module 'pryv' {
|
|
|
763
763
|
): Promise<any[]>;
|
|
764
764
|
}
|
|
765
765
|
|
|
766
|
+
/**
|
|
767
|
+
* A {@link Connection} whose access token is DPoP-sender-constrained
|
|
768
|
+
* (RFC 9449): every request carries a fresh proof signed with `keyPair`
|
|
769
|
+
* and presents the token under the `DPoP` scheme. Built by
|
|
770
|
+
* {@link OAuth2Client} with `dpop: true`, or directly from an apiEndpoint
|
|
771
|
+
* whose token was bound to `keyPair`.
|
|
772
|
+
*/
|
|
773
|
+
export class SignedConnection extends Connection {
|
|
774
|
+
constructor (apiEndpoint: string, keyPair: CryptoKeyPair, service?: Service);
|
|
775
|
+
}
|
|
776
|
+
|
|
766
777
|
export type serviceCustomizations = {
|
|
767
778
|
name?: string;
|
|
768
779
|
assets?: {
|
|
@@ -1079,6 +1090,12 @@ declare module 'pryv' {
|
|
|
1079
1090
|
scope?: string;
|
|
1080
1091
|
/** Defaults to `globalThis.sessionStorage` (browser) or an in-memory store. */
|
|
1081
1092
|
storage?: OAuth2ClientStorage;
|
|
1093
|
+
/** Seed a previously-persisted refresh token so `refresh()` works after a reload. */
|
|
1094
|
+
refreshToken?: string;
|
|
1095
|
+
/** Called with the NEW refresh token every time it rotates, to persist the minimal secret. */
|
|
1096
|
+
onTokenRotated?: (refreshToken: string) => void;
|
|
1097
|
+
/** Opt into RFC 9449 DPoP: token bound to a per-client ES256 key; `handleCallback()` / `refresh()` return a {@link SignedConnection}. */
|
|
1098
|
+
dpop?: boolean;
|
|
1082
1099
|
};
|
|
1083
1100
|
|
|
1084
1101
|
/**
|
package/src/index.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* @exports pryv
|
|
8
8
|
* @property {pryv.Service} Service - To interact with Pryv.io at a "Platform level"
|
|
9
9
|
* @property {pryv.Connection} Connection - To interact with an individual's (user) data set
|
|
10
|
+
* @property {pryv.SignedConnection} SignedConnection - A Connection whose token is DPoP-sender-constrained (RFC 9449)
|
|
10
11
|
* @property {pryv.Browser} Browser - Browser Tools - Access request helpers and visuals (button)
|
|
11
12
|
* @property {pryv.OAuth2Client} OAuth2Client - Browser-side OAuth2 authorization-code (PKCE) flow consumer
|
|
12
13
|
* @property {pryv.utils} utils - Exposes some utils for HTTP calls and tools to manipulate Pryv's API endpoints
|
|
@@ -20,9 +21,11 @@ const Service = require('./Service');
|
|
|
20
21
|
module.exports = {
|
|
21
22
|
Service,
|
|
22
23
|
Connection: require('./Connection'),
|
|
24
|
+
SignedConnection: require('./SignedConnection'),
|
|
23
25
|
Auth: require('./Auth'),
|
|
24
26
|
Browser: require('./Browser'),
|
|
25
27
|
OAuth2Client: require('./OAuth2Client'),
|
|
28
|
+
SharedSecrets: require('./SharedSecrets'),
|
|
26
29
|
utils: require('./utils'),
|
|
27
30
|
PryvError: require('./lib/PryvError'),
|
|
28
31
|
MfaRequiredError: require('./lib/MfaRequiredError'),
|
|
@@ -391,4 +391,49 @@ describe('[OAUTH-LIB] OAuth2Client', function () {
|
|
|
391
391
|
expect(conn.token).to.equal('T2');
|
|
392
392
|
});
|
|
393
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
|
+
});
|
|
394
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
|
+
});
|