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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pryv",
3
- "version": "3.8.1",
3
+ "version": "3.10.0",
4
4
  "description": "Pryv JavaScript library",
5
5
  "keywords": [
6
6
  "Pryv",
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
- Authorization: this.token,
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
- Authorization: this.token,
324
+ ...(await this._authHeaders('GET', this.endpoint + path)),
312
325
  Accept: 'application/json'
313
326
  }
314
327
  });
@@ -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 response = await oauth.refreshTokenGrantRequest(as, this._client, this._clientAuth, this._refreshToken);
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;
@@ -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
+ });