pryv 3.8.0 → 3.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -1
- package/package.json +5 -3
- package/src/Connection.js +15 -2
- package/src/OAuth2Client.js +391 -0
- package/src/SharedSecrets.js +172 -0
- package/src/SignedConnection.js +66 -0
- package/src/dpopProof.js +96 -0
- package/src/index.d.ts +59 -0
- package/src/index.js +5 -0
- package/src/utils.js +1 -1
- package/test/Connection.authHeader.test.js +43 -0
- package/test/OAuth2Client.test.js +439 -0
- package/test/SharedSecrets.test.js +110 -0
- package/test/SignedConnection.test.js +97 -0
- package/test/esm-deps.test.js +19 -0
|
@@ -0,0 +1,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?: {
|
|
@@ -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-
|
|
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
|
+
});
|