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
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
|
|
@@ -144,6 +145,44 @@ Here is an implementation of the [Pryv.io authentication process](https://api.pr
|
|
|
144
145
|
</html>
|
|
145
146
|
```
|
|
146
147
|
|
|
148
|
+
#### With OAuth2 (`pryv.OAuth2Client`)
|
|
149
|
+
|
|
150
|
+
For apps registered as OAuth2 clients on a Pryv.io deployment, `pryv.OAuth2Client`
|
|
151
|
+
drives the authorization-code flow (PKCE). The authorization-server endpoints are
|
|
152
|
+
discovered from the issuer via RFC 8414. The `scope` value is a consent-offer
|
|
153
|
+
reference (`cmc:<offer-name>`) registered on your OAuth client — the user grants
|
|
154
|
+
a granular permission set resolved from that offer, and may untick individual
|
|
155
|
+
permissions on the consent screen.
|
|
156
|
+
|
|
157
|
+
```js
|
|
158
|
+
const client = new pryv.OAuth2Client({
|
|
159
|
+
authorizationServer: 'https://host', // Pryv API base URL (issuer)
|
|
160
|
+
clientId: 'my-app', // from the app-account registration
|
|
161
|
+
redirectUri: 'https://my-app.example/callback',
|
|
162
|
+
scope: 'cmc:study-A' // your registered consent-offer reference
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// On "Login with Pryv":
|
|
166
|
+
await client.redirectToAuthorize();
|
|
167
|
+
|
|
168
|
+
// On the redirect_uri page:
|
|
169
|
+
const connection = await client.handleCallback(window.location.search);
|
|
170
|
+
// `connection` is a regular pryv.Connection
|
|
171
|
+
|
|
172
|
+
// Later, to renew the access token:
|
|
173
|
+
const renewed = await client.refresh();
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The PKCE verifier is generated per flow and stored in `sessionStorage` (pass
|
|
177
|
+
`storage` to override). This flow is browser-oriented; the polling flow below
|
|
178
|
+
stays fully supported.
|
|
179
|
+
|
|
180
|
+
> **Requirements.** `pryv.OAuth2Client` uses the ESM-only `oauth4webapi`
|
|
181
|
+
> dependency, so `pryv` now requires **Node >= 20.19 (or >= 22.12)**. Consumers
|
|
182
|
+
> who bundle `pryv` themselves need an `exports`-field-aware bundler
|
|
183
|
+
> (webpack 5 / vite / rollup / esbuild); webpack-4 / browserify users should use
|
|
184
|
+
> the prebuilt `dist/` bundle.
|
|
185
|
+
|
|
147
186
|
#### Fetching access info
|
|
148
187
|
|
|
149
188
|
[API reference](https://api.pryv.com/reference/#access-info).
|
|
@@ -501,7 +540,7 @@ To customize visual assets, please refer to the [pryv.me assets repository](http
|
|
|
501
540
|
You can customize the authentication process ([API reference](https://api.pryv.com/reference/#authenticate-your-app)) at different levels:
|
|
502
541
|
|
|
503
542
|
- Using a custom login button
|
|
504
|
-
- Using a custom UI, including the flow of [app-web-
|
|
543
|
+
- Using a custom UI, including the flow of [app-web-user-account](https://github.com/pryv/app-web-user-account)
|
|
505
544
|
|
|
506
545
|
#### Using a custom login button
|
|
507
546
|
|
|
@@ -679,6 +718,7 @@ The project is structured as a monorepo with components (a.k.a. workspaces in NP
|
|
|
679
718
|
- `pryv-socket.io`: Socket.IO add-on
|
|
680
719
|
- `pryv-monitor`: Monitor add-on
|
|
681
720
|
- `pryv-cmc`: CMC (Cross-account Messaging & Consent) client helpers
|
|
721
|
+
- `pryv-encryption`: client-side event encryption/decryption add-on
|
|
682
722
|
|
|
683
723
|
|
|
684
724
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pryv",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.10.0",
|
|
4
4
|
"description": "Pryv JavaScript library",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"Pryv",
|
|
@@ -18,8 +18,10 @@
|
|
|
18
18
|
"author": "Pryv <info@pryv.com> (https://pryv.com)",
|
|
19
19
|
"main": "src/index.js",
|
|
20
20
|
"types": "src/index.d.ts",
|
|
21
|
-
"dependencies": {
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"oauth4webapi": "^3.8.6"
|
|
23
|
+
},
|
|
22
24
|
"engines": {
|
|
23
|
-
"node": ">=20.
|
|
25
|
+
"node": ">=20.19.0"
|
|
24
26
|
}
|
|
25
27
|
}
|
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
|
});
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
const oauth = require('oauth4webapi');
|
|
6
|
+
const Connection = require('./Connection');
|
|
7
|
+
const SignedConnection = require('./SignedConnection');
|
|
8
|
+
const utils = require('./utils');
|
|
9
|
+
|
|
10
|
+
// sessionStorage key prefix for the per-flow PKCE verifier, keyed by `state`.
|
|
11
|
+
const VERIFIER_KEY_PREFIX = 'pryv-oauth2-verifier:';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Minimal Web-Storage-like contract used to persist the per-flow PKCE verifier.
|
|
15
|
+
* A browser `sessionStorage` satisfies it structurally.
|
|
16
|
+
* @typedef {Object} OAuth2Storage
|
|
17
|
+
* @property {(key: string) => (string | null)} getItem
|
|
18
|
+
* @property {(key: string, value: string) => void} setItem
|
|
19
|
+
* @property {(key: string) => void} removeItem
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @class OAuth2Client
|
|
24
|
+
* Browser-side consumer of the Pryv OAuth2 authorization-code flow (PKCE).
|
|
25
|
+
*
|
|
26
|
+
* Sibling to {@link Browser}: an OAuth-aware app runs
|
|
27
|
+
* `redirectToAuthorize()` → (the browser bounces through `/oauth2/authorize`
|
|
28
|
+
* and back to `redirectUri`) → `handleCallback()`, which returns a ready
|
|
29
|
+
* {@link Connection}. `refresh()` swaps the refresh token for a fresh one.
|
|
30
|
+
*
|
|
31
|
+
* PKCE is handled internally: a random `code_verifier` is generated per flow,
|
|
32
|
+
* stored in `sessionStorage` keyed by `state`, and consumed on callback.
|
|
33
|
+
*
|
|
34
|
+
* The authorization-server endpoints are discovered from the issuer via
|
|
35
|
+
* RFC 8414 (`GET <issuer>/.well-known/oauth-authorization-server`).
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* const client = new pryv.OAuth2Client({
|
|
39
|
+
* authorizationServer: 'https://host', // Pryv API base (issuer)
|
|
40
|
+
* clientId: 'my-app',
|
|
41
|
+
* redirectUri: 'https://my-app.example/callback',
|
|
42
|
+
* scope: 'cmc:study-A'
|
|
43
|
+
* });
|
|
44
|
+
* // on "Login with Pryv":
|
|
45
|
+
* await client.redirectToAuthorize();
|
|
46
|
+
* // on the redirect_uri page:
|
|
47
|
+
* const connection = await client.handleCallback(window.location.search);
|
|
48
|
+
*
|
|
49
|
+
* @memberof pryv
|
|
50
|
+
*/
|
|
51
|
+
class OAuth2Client {
|
|
52
|
+
/**
|
|
53
|
+
* @param {Object} [options]
|
|
54
|
+
* @param {string} [options.authorizationServer] - Issuer / Pryv API base URL. The
|
|
55
|
+
* discovery document is fetched from `<authorizationServer>/.well-known/oauth-authorization-server`.
|
|
56
|
+
* (This is the concrete issuer URL, not the `/service/info` URL — client-side
|
|
57
|
+
* derivation from the per-user `service:api` template is unreliable for multi-core.)
|
|
58
|
+
* Required at runtime.
|
|
59
|
+
* @param {string} [options.clientId] - App-account client id. Required at runtime.
|
|
60
|
+
* @param {string} [options.redirectUri] - Registered redirect URI. Required at runtime.
|
|
61
|
+
* @param {string} [options.scope] - Consent-offer reference registered on the client, e.g. `'cmc:study-A'`.
|
|
62
|
+
* @param {OAuth2Storage} [options.storage] - Web-Storage-like `{ getItem, setItem, removeItem }`.
|
|
63
|
+
* Defaults to `globalThis.sessionStorage` in a browser, else an in-memory store.
|
|
64
|
+
* @param {string} [options.refreshToken] - Seed the client with a previously-persisted
|
|
65
|
+
* refresh token so `refresh()` works after a page reload WITHOUT re-running the
|
|
66
|
+
* authorization flow. Persist ONLY this value (read it from `client.refreshToken`
|
|
67
|
+
* or the `onTokenRotated` callback) — never the whole `lastTokenResponse`, which
|
|
68
|
+
* also carries the access token and so is a larger XSS surface.
|
|
69
|
+
* @param {(refreshToken: string) => void} [options.onTokenRotated] - Called with the
|
|
70
|
+
* NEW refresh token every time it rotates (after `handleCallback()` and each
|
|
71
|
+
* `refresh()`), so the app can persist the minimal secret. Exceptions it throws
|
|
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.
|
|
80
|
+
*/
|
|
81
|
+
constructor (options = {}) {
|
|
82
|
+
const { authorizationServer, clientId, redirectUri, scope, storage, refreshToken, onTokenRotated, dpop } = options;
|
|
83
|
+
if (!authorizationServer) throw new Error('OAuth2Client: "authorizationServer" is required');
|
|
84
|
+
if (!clientId) throw new Error('OAuth2Client: "clientId" is required');
|
|
85
|
+
if (!redirectUri) throw new Error('OAuth2Client: "redirectUri" is required');
|
|
86
|
+
|
|
87
|
+
this.issuer = new URL(authorizationServer);
|
|
88
|
+
this.clientId = clientId;
|
|
89
|
+
this.redirectUri = redirectUri;
|
|
90
|
+
this.scope = scope;
|
|
91
|
+
this.storage = storage || defaultStorage();
|
|
92
|
+
|
|
93
|
+
// Public client (PKCE, no secret) — token_endpoint auth method "none".
|
|
94
|
+
this._client = { client_id: clientId };
|
|
95
|
+
this._clientAuth = oauth.None();
|
|
96
|
+
this._as = null;
|
|
97
|
+
this._refreshToken = refreshToken || null;
|
|
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;
|
|
105
|
+
// In-flight refresh() promise — dedups concurrent callers onto one token
|
|
106
|
+
// request so they can't each present the same refresh token and have the
|
|
107
|
+
// loser rejected as reuse (invalid_grant) by an always-rotating server.
|
|
108
|
+
this._refreshInFlight = null;
|
|
109
|
+
/** Last raw token-endpoint response (access_token, scope, apiEndpoint, …). */
|
|
110
|
+
this.lastTokenResponse = null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The current refresh token (rotates on every `handleCallback()` / `refresh()`).
|
|
115
|
+
* Read it to persist the minimal secret across reloads; re-seed via the
|
|
116
|
+
* `refreshToken` constructor option. `null` before the first exchange.
|
|
117
|
+
* @returns {string | null}
|
|
118
|
+
*/
|
|
119
|
+
get refreshToken () {
|
|
120
|
+
return this._refreshToken;
|
|
121
|
+
}
|
|
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
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @private
|
|
145
|
+
* Lazily discover + cache the authorization-server metadata (RFC 8414).
|
|
146
|
+
* @returns {Promise<Object>} the `AuthorizationServer` metadata
|
|
147
|
+
*/
|
|
148
|
+
async _discover () {
|
|
149
|
+
if (this._as) return this._as;
|
|
150
|
+
const response = await oauth.discoveryRequest(this.issuer, { algorithm: 'oauth2' });
|
|
151
|
+
this._as = await oauth.processDiscoveryResponse(this.issuer, response);
|
|
152
|
+
return this._as;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Build the `/oauth2/authorize` URL (generating + storing the PKCE verifier)
|
|
157
|
+
* and navigate to it. Returns the URL so non-browser callers can drive the
|
|
158
|
+
* redirect themselves.
|
|
159
|
+
*
|
|
160
|
+
* @param {Object} [options]
|
|
161
|
+
* @param {string} [options.state] - CSRF/correlation value; a random one is generated when omitted.
|
|
162
|
+
* @param {(url: string) => void} [options.redirect] - Navigation function; defaults to
|
|
163
|
+
* `globalThis.location.assign` when available, else a no-op (URL still returned).
|
|
164
|
+
* @returns {Promise<string>} the authorization URL
|
|
165
|
+
*/
|
|
166
|
+
async redirectToAuthorize (options = {}) {
|
|
167
|
+
const as = await this._discover();
|
|
168
|
+
const state = options.state || oauth.generateRandomState();
|
|
169
|
+
const codeVerifier = oauth.generateRandomCodeVerifier();
|
|
170
|
+
const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier);
|
|
171
|
+
this.storage.setItem(VERIFIER_KEY_PREFIX + state, codeVerifier);
|
|
172
|
+
|
|
173
|
+
const url = new URL(as.authorization_endpoint);
|
|
174
|
+
// The browser is about to be navigated to this URL. `oauth4webapi` only
|
|
175
|
+
// enforces https on endpoints it fetches itself, not on this navigation
|
|
176
|
+
// target — a tampered/MITM discovery document could return an http:/other
|
|
177
|
+
// `authorization_endpoint` and send the user somewhere hostile. Assert the
|
|
178
|
+
// scheme with the same https-or-loopback rule used for the apiEndpoint.
|
|
179
|
+
assertHttpsOrLoopback(url, 'authorization_endpoint');
|
|
180
|
+
url.searchParams.set('response_type', 'code');
|
|
181
|
+
url.searchParams.set('client_id', this.clientId);
|
|
182
|
+
url.searchParams.set('redirect_uri', this.redirectUri);
|
|
183
|
+
if (this.scope) url.searchParams.set('scope', this.scope);
|
|
184
|
+
url.searchParams.set('code_challenge', codeChallenge);
|
|
185
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
186
|
+
url.searchParams.set('state', state);
|
|
187
|
+
|
|
188
|
+
const authorizationUrl = url.href;
|
|
189
|
+
const redirect = options.redirect || defaultRedirect;
|
|
190
|
+
redirect(authorizationUrl);
|
|
191
|
+
return authorizationUrl;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Validate the redirect_uri callback, exchange the code for tokens (PKCE),
|
|
196
|
+
* and build a {@link Connection} from the Pryv `apiEndpoint` extension.
|
|
197
|
+
*
|
|
198
|
+
* @param {string} queryString - `window.location.search` (with or without leading `?`).
|
|
199
|
+
* @returns {Promise<Connection>} an authenticated Pryv connection
|
|
200
|
+
*/
|
|
201
|
+
async handleCallback (queryString) {
|
|
202
|
+
const as = await this._discover();
|
|
203
|
+
const params = new URLSearchParams(stripLeadingQuestionMark(queryString));
|
|
204
|
+
|
|
205
|
+
const state = params.get('state');
|
|
206
|
+
if (!state) throw new Error('OAuth2Client: callback is missing "state"');
|
|
207
|
+
const storageKey = VERIFIER_KEY_PREFIX + state;
|
|
208
|
+
const codeVerifier = this.storage.getItem(storageKey);
|
|
209
|
+
if (!codeVerifier) {
|
|
210
|
+
throw new Error('OAuth2Client: no stored PKCE verifier for this state (expired session or forged callback)');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// The verifier is single-use and bound to this state; drop it on every
|
|
214
|
+
// exit path (success OR error) so a failed/denied callback never leaves it
|
|
215
|
+
// behind in sessionStorage.
|
|
216
|
+
try {
|
|
217
|
+
// Throws on authorization errors (e.g. access_denied) or a state mismatch.
|
|
218
|
+
const callbackParams = oauth.validateAuthResponse(as, this._client, params, state);
|
|
219
|
+
const dpopHandle = await this._ensureDPoP();
|
|
220
|
+
const response = await oauth.authorizationCodeGrantRequest(
|
|
221
|
+
as, this._client, this._clientAuth, callbackParams, this.redirectUri, codeVerifier,
|
|
222
|
+
dpopHandle ? { DPoP: dpopHandle } : undefined
|
|
223
|
+
);
|
|
224
|
+
const result = await oauth.processAuthorizationCodeResponse(as, this._client, response);
|
|
225
|
+
return this._connectionFromTokenResponse(result);
|
|
226
|
+
} finally {
|
|
227
|
+
this.storage.removeItem(storageKey);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Exchange the stored refresh token for a fresh access token and return a
|
|
233
|
+
* new {@link Connection}. Requires a prior successful `handleCallback()`.
|
|
234
|
+
*
|
|
235
|
+
* @returns {Promise<Connection>}
|
|
236
|
+
*/
|
|
237
|
+
async refresh () {
|
|
238
|
+
// Serialize concurrent callers onto a single in-flight exchange (F1). An
|
|
239
|
+
// always-rotating server consumes the refresh token on first use, so two
|
|
240
|
+
// parallel refresh() calls presenting the same token would rotate once and
|
|
241
|
+
// have the loser rejected as reuse. Dedup to one request; both callers get
|
|
242
|
+
// the same fresh Connection.
|
|
243
|
+
if (this._refreshInFlight) return this._refreshInFlight;
|
|
244
|
+
this._refreshInFlight = this._doRefresh();
|
|
245
|
+
// Clear the slot once settled (success OR failure) so the next call retries.
|
|
246
|
+
this._refreshInFlight.catch(() => {}).finally(() => { this._refreshInFlight = null; });
|
|
247
|
+
return this._refreshInFlight;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* @private
|
|
252
|
+
* The actual refresh exchange, wrapped by `refresh()`'s in-flight dedup.
|
|
253
|
+
* @returns {Promise<Connection>}
|
|
254
|
+
*/
|
|
255
|
+
async _doRefresh () {
|
|
256
|
+
if (!this._refreshToken) {
|
|
257
|
+
throw new Error('OAuth2Client: no refresh token available; call handleCallback() first');
|
|
258
|
+
}
|
|
259
|
+
const as = await this._discover();
|
|
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
|
+
);
|
|
265
|
+
const result = await oauth.processRefreshTokenResponse(as, this._client, response);
|
|
266
|
+
return this._connectionFromTokenResponse(result);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* @private
|
|
271
|
+
* @param {Object} tokenResponse - a processed token-endpoint response
|
|
272
|
+
* @returns {Connection}
|
|
273
|
+
*/
|
|
274
|
+
_connectionFromTokenResponse (tokenResponse) {
|
|
275
|
+
// Persist the rotated refresh token FIRST — before any validation that can
|
|
276
|
+
// throw (F2). The server has already committed the rotation (old token
|
|
277
|
+
// consumed, new one issued in this response). If we validated first and it
|
|
278
|
+
// threw (e.g. a momentarily-missing or http: apiEndpoint), we would strand
|
|
279
|
+
// the client on the now-dead old token AND lose the new one — permanently
|
|
280
|
+
// bricking the session. Storing first lets a later refresh() retry with the
|
|
281
|
+
// current token.
|
|
282
|
+
this._ingestTokens(tokenResponse);
|
|
283
|
+
|
|
284
|
+
const apiEndpoint = tokenResponse.apiEndpoint;
|
|
285
|
+
if (!apiEndpoint) {
|
|
286
|
+
throw new Error('OAuth2Client: token response is missing the Pryv "apiEndpoint" extension');
|
|
287
|
+
}
|
|
288
|
+
// Validate the endpoint the Connection will actually send the token to, not
|
|
289
|
+
// the raw apiEndpoint string: Connection splits token/endpoint on the LAST
|
|
290
|
+
// `@` (utils regex), so a crafted `http://tok@127.0.0.1/x@evil/` parses as
|
|
291
|
+
// loopback here but posts the token to `evil` there. Assert on the extracted
|
|
292
|
+
// endpoint to close that parser-divergence gap.
|
|
293
|
+
const { endpoint } = utils.extractTokenAndAPIEndpoint(String(apiEndpoint));
|
|
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);
|
|
298
|
+
return new Connection(String(apiEndpoint));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* @private
|
|
303
|
+
* Non-throwing: record the rotated refresh token + raw response and notify the
|
|
304
|
+
* app so it can persist the minimal secret. Runs before validation so a
|
|
305
|
+
* validation failure never loses the rotation (see `_connectionFromTokenResponse`).
|
|
306
|
+
* @param {Object} tokenResponse - a processed token-endpoint response
|
|
307
|
+
*/
|
|
308
|
+
_ingestTokens (tokenResponse) {
|
|
309
|
+
this.lastTokenResponse = tokenResponse;
|
|
310
|
+
if (!tokenResponse.refresh_token) return;
|
|
311
|
+
this._refreshToken = tokenResponse.refresh_token;
|
|
312
|
+
if (this._onTokenRotated) {
|
|
313
|
+
// Persistence must never break the exchange — swallow app callback errors.
|
|
314
|
+
try { this._onTokenRotated(this._refreshToken); } catch (_) { /* ignore */ }
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* @private
|
|
321
|
+
* Refuse an `apiEndpoint` that would send the bearer token in cleartext. The
|
|
322
|
+
* token is embedded in the endpoint (`https://<token>@host/`) and then sent on
|
|
323
|
+
* every request, so a non-https endpoint leaks it on the wire. Defense-in-depth
|
|
324
|
+
* against a compromised/misconfigured authorization server.
|
|
325
|
+
* @param {string} apiEndpoint
|
|
326
|
+
*/
|
|
327
|
+
function assertSecureApiEndpoint (apiEndpoint) {
|
|
328
|
+
assertHttpsOrLoopback(apiEndpoint, 'apiEndpoint');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* @private
|
|
333
|
+
* Enforce a "must be transport-secure" rule on a URL that is either navigated
|
|
334
|
+
* to (the authorize redirect) or used to send the bearer token (the
|
|
335
|
+
* apiEndpoint): allow `https:` everywhere, and `http:` only for loopback hosts
|
|
336
|
+
* (local development). Single source of truth so both call sites stay in sync.
|
|
337
|
+
* @param {string | URL} rawUrl - the URL to check (string or a parsed `URL`)
|
|
338
|
+
* @param {string} label - human-readable name of the value, used in errors
|
|
339
|
+
*/
|
|
340
|
+
function assertHttpsOrLoopback (rawUrl, label) {
|
|
341
|
+
let url;
|
|
342
|
+
try {
|
|
343
|
+
url = (rawUrl instanceof URL) ? rawUrl : new URL(rawUrl);
|
|
344
|
+
} catch {
|
|
345
|
+
throw new Error('OAuth2Client: ' + label + ' is not a valid URL');
|
|
346
|
+
}
|
|
347
|
+
const host = url.hostname;
|
|
348
|
+
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || host === '::1';
|
|
349
|
+
if (url.protocol === 'https:') return;
|
|
350
|
+
if (url.protocol === 'http:' && isLoopback) return;
|
|
351
|
+
throw new Error('OAuth2Client: refusing insecure ' + label + ' (' + url.protocol + '//' + host + '); https is required');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* @private
|
|
356
|
+
* Default navigation: use the browser's `location.assign` when present,
|
|
357
|
+
* otherwise a no-op (the caller gets the URL back from `redirectToAuthorize`).
|
|
358
|
+
* @param {string} url
|
|
359
|
+
*/
|
|
360
|
+
function defaultRedirect (url) {
|
|
361
|
+
const loc = (typeof globalThis !== 'undefined') ? globalThis.location : undefined;
|
|
362
|
+
if (loc && typeof loc.assign === 'function') loc.assign(url);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* @private
|
|
367
|
+
* `globalThis.sessionStorage` in a browser, else a process-local in-memory store
|
|
368
|
+
* (sufficient for tests and non-browser callers that inject nothing).
|
|
369
|
+
* @returns {OAuth2Storage}
|
|
370
|
+
*/
|
|
371
|
+
function defaultStorage () {
|
|
372
|
+
if (typeof globalThis !== 'undefined' && globalThis.sessionStorage) return globalThis.sessionStorage;
|
|
373
|
+
const map = new Map();
|
|
374
|
+
return {
|
|
375
|
+
getItem: (k) => (map.has(k) ? map.get(k) : null),
|
|
376
|
+
setItem: (k, v) => { map.set(k, String(v)); },
|
|
377
|
+
removeItem: (k) => { map.delete(k); }
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* @private
|
|
383
|
+
* @param {string} queryString
|
|
384
|
+
* @returns {string}
|
|
385
|
+
*/
|
|
386
|
+
function stripLeadingQuestionMark (queryString) {
|
|
387
|
+
const s = queryString || '';
|
|
388
|
+
return s.charAt(0) === '?' ? s.slice(1) : s;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
module.exports = OAuth2Client;
|