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.
@@ -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?: {
@@ -1059,6 +1070,53 @@ declare module 'pryv' {
1059
1070
  get state(): AuthStatePayload;
1060
1071
  }
1061
1072
 
1073
+ /**
1074
+ * Web-Storage-like store used by OAuth2Client to hold the per-flow PKCE verifier.
1075
+ */
1076
+ export type OAuth2ClientStorage = {
1077
+ getItem(key: string): string | null;
1078
+ setItem(key: string, value: string): void;
1079
+ removeItem(key: string): void;
1080
+ };
1081
+
1082
+ export type OAuth2ClientOptions = {
1083
+ /** Issuer / Pryv API base URL; discovery doc read from `<url>/.well-known/oauth-authorization-server`. */
1084
+ authorizationServer: string;
1085
+ /** App-account client id. */
1086
+ clientId: string;
1087
+ /** Registered redirect URI. */
1088
+ redirectUri: string;
1089
+ /** Consent-offer reference registered on the client, e.g. `'cmc:study-A'`. */
1090
+ scope?: string;
1091
+ /** Defaults to `globalThis.sessionStorage` (browser) or an in-memory store. */
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;
1099
+ };
1100
+
1101
+ /**
1102
+ * Browser-side consumer of the Pryv OAuth2 authorization-code (PKCE) flow.
1103
+ */
1104
+ export class OAuth2Client {
1105
+ constructor(options: OAuth2ClientOptions);
1106
+ issuer: URL;
1107
+ clientId: string;
1108
+ redirectUri: string;
1109
+ scope?: string;
1110
+ /** Last raw token-endpoint response (access_token, scope, apiEndpoint, …). */
1111
+ lastTokenResponse: { [key: string]: unknown } | null;
1112
+ /** Build + navigate to the `/oauth2/authorize` URL; returns the URL. */
1113
+ redirectToAuthorize(options?: { state?: string; redirect?: (url: string) => void }): Promise<string>;
1114
+ /** Validate the callback, exchange the code (PKCE), return a Connection. */
1115
+ handleCallback(queryString: string): Promise<Connection>;
1116
+ /** Exchange the stored refresh token for a fresh Connection. */
1117
+ refresh(): Promise<Connection>;
1118
+ }
1119
+
1062
1120
  export const Auth: {
1063
1121
  setupAuth: SetupAuth;
1064
1122
  AuthStates: AuthStates;
@@ -1116,6 +1174,7 @@ declare module 'pryv' {
1116
1174
  let pryv: {
1117
1175
  Service: typeof Service;
1118
1176
  Connection: typeof Connection;
1177
+ OAuth2Client: typeof OAuth2Client;
1119
1178
  Auth: {
1120
1179
  setupAuth: SetupAuth;
1121
1180
  AuthStates: AuthStates;
package/src/index.js CHANGED
@@ -7,7 +7,9 @@
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)
12
+ * @property {pryv.OAuth2Client} OAuth2Client - Browser-side OAuth2 authorization-code (PKCE) flow consumer
11
13
  * @property {pryv.utils} utils - Exposes some utils for HTTP calls and tools to manipulate Pryv's API endpoints
12
14
  * @property {pryv.PryvError} PryvError - Custom error class with innerObject + structured API-error fields
13
15
  * @property {pryv.MfaRequiredError} MfaRequiredError - Thrown by Service.login when the platform returns an mfaToken instead of a token. Carries `.mfaToken`.
@@ -19,8 +21,11 @@ const Service = require('./Service');
19
21
  module.exports = {
20
22
  Service,
21
23
  Connection: require('./Connection'),
24
+ SignedConnection: require('./SignedConnection'),
22
25
  Auth: require('./Auth'),
23
26
  Browser: require('./Browser'),
27
+ OAuth2Client: require('./OAuth2Client'),
28
+ SharedSecrets: require('./SharedSecrets'),
24
29
  utils: require('./utils'),
25
30
  PryvError: require('./lib/PryvError'),
26
31
  MfaRequiredError: require('./lib/MfaRequiredError'),
package/src/utils.js CHANGED
@@ -232,7 +232,7 @@ const utils = module.exports = {
232
232
  */
233
233
  cleanURLFromPrYvParams: function (url) {
234
234
  // Legacy form: kept for back-compat with apps still emitting
235
- // `prYv<anything>=...` (notably app-web-auth3's close_or_redirect).
235
+ // `prYv<anything>=...` (notably app-web-user-account's close_or_redirect).
236
236
  const LEGACY = /[?#&]+prYv([^=&]+)=([^&]*)/g;
237
237
  // Modern form: an explicit allowlist of one-shot params so we never
238
238
  // wipe long-lived camelCase `pryv*` params by accident.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @license
3
+ * [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
4
+ */
5
+ /* global describe, it, beforeEach, afterEach, expect, pryv */
6
+
7
+ // Pure unit check of the Authorization header shape. The API expects the bare
8
+ // access token (optionally suffixed with a caller id) in the Authorization
9
+ // header — NOT an RFC 6750 `Bearer <token>` scheme. Older/released cores parse
10
+ // the header by splitting on the first space, so a `Bearer ` prefix would be
11
+ // taken as the token and break auth; the bare token is what every core version
12
+ // accepts. This asserts no scheme prefix is added on the GET or POST path.
13
+ describe('[CAUTH] Connection Authorization header', function () {
14
+ let originalFetch;
15
+ let captured;
16
+
17
+ beforeEach(function () {
18
+ captured = [];
19
+ originalFetch = globalThis.fetch;
20
+ globalThis.fetch = async (url, init) => {
21
+ captured.push({ url: String(url), headers: (init && init.headers) || {} });
22
+ return new Response(JSON.stringify({ meta: { serverTime: 1 } }), {
23
+ status: 200,
24
+ headers: { 'content-type': 'application/json' }
25
+ });
26
+ };
27
+ });
28
+ afterEach(function () { globalThis.fetch = originalFetch; });
29
+
30
+ it('[CAU1] GET requests send the bare token (no scheme prefix)', async function () {
31
+ const conn = new pryv.Connection('https://TESTTOKEN@user.example.com/');
32
+ await conn.get('events');
33
+ expect(captured).to.have.length(1);
34
+ expect(captured[0].headers.Authorization).to.equal('TESTTOKEN');
35
+ });
36
+
37
+ it('[CAU2] POST requests send the bare token (no scheme prefix)', async function () {
38
+ const conn = new pryv.Connection('https://TESTTOKEN@user.example.com/');
39
+ await conn.post('events', { streamIds: ['x'], type: 'note/txt', content: 'hi' });
40
+ expect(captured).to.have.length(1);
41
+ expect(captured[0].headers.Authorization).to.equal('TESTTOKEN');
42
+ });
43
+ });