pryv 3.7.1 → 3.8.1
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 -3
- package/package.json +6 -4
- package/src/Browser/LoginButton.js +14 -7
- package/src/OAuth2Client.js +348 -0
- package/src/index.d.ts +47 -9
- package/src/index.js +2 -0
- package/src/utils.js +19 -4
- package/test/Connection.authHeader.test.js +43 -0
- package/test/OAuth2Client.test.js +394 -0
- package/test/esm-deps.test.js +19 -0
- package/test/utils.test.js +30 -0
package/README.md
CHANGED
|
@@ -144,6 +144,44 @@ Here is an implementation of the [Pryv.io authentication process](https://api.pr
|
|
|
144
144
|
</html>
|
|
145
145
|
```
|
|
146
146
|
|
|
147
|
+
#### With OAuth2 (`pryv.OAuth2Client`)
|
|
148
|
+
|
|
149
|
+
For apps registered as OAuth2 clients on a Pryv.io deployment, `pryv.OAuth2Client`
|
|
150
|
+
drives the authorization-code flow (PKCE). The authorization-server endpoints are
|
|
151
|
+
discovered from the issuer via RFC 8414. The `scope` value is a consent-offer
|
|
152
|
+
reference (`cmc:<offer-name>`) registered on your OAuth client — the user grants
|
|
153
|
+
a granular permission set resolved from that offer, and may untick individual
|
|
154
|
+
permissions on the consent screen.
|
|
155
|
+
|
|
156
|
+
```js
|
|
157
|
+
const client = new pryv.OAuth2Client({
|
|
158
|
+
authorizationServer: 'https://host', // Pryv API base URL (issuer)
|
|
159
|
+
clientId: 'my-app', // from the app-account registration
|
|
160
|
+
redirectUri: 'https://my-app.example/callback',
|
|
161
|
+
scope: 'cmc:study-A' // your registered consent-offer reference
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// On "Login with Pryv":
|
|
165
|
+
await client.redirectToAuthorize();
|
|
166
|
+
|
|
167
|
+
// On the redirect_uri page:
|
|
168
|
+
const connection = await client.handleCallback(window.location.search);
|
|
169
|
+
// `connection` is a regular pryv.Connection
|
|
170
|
+
|
|
171
|
+
// Later, to renew the access token:
|
|
172
|
+
const renewed = await client.refresh();
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The PKCE verifier is generated per flow and stored in `sessionStorage` (pass
|
|
176
|
+
`storage` to override). This flow is browser-oriented; the polling flow below
|
|
177
|
+
stays fully supported.
|
|
178
|
+
|
|
179
|
+
> **Requirements.** `pryv.OAuth2Client` uses the ESM-only `oauth4webapi`
|
|
180
|
+
> dependency, so `pryv` now requires **Node >= 20.19 (or >= 22.12)**. Consumers
|
|
181
|
+
> who bundle `pryv` themselves need an `exports`-field-aware bundler
|
|
182
|
+
> (webpack 5 / vite / rollup / esbuild); webpack-4 / browserify users should use
|
|
183
|
+
> the prebuilt `dist/` bundle.
|
|
184
|
+
|
|
147
185
|
#### Fetching access info
|
|
148
186
|
|
|
149
187
|
[API reference](https://api.pryv.com/reference/#access-info).
|
|
@@ -501,7 +539,7 @@ To customize visual assets, please refer to the [pryv.me assets repository](http
|
|
|
501
539
|
You can customize the authentication process ([API reference](https://api.pryv.com/reference/#authenticate-your-app)) at different levels:
|
|
502
540
|
|
|
503
541
|
- Using a custom login button
|
|
504
|
-
- Using a custom UI, including the flow of [app-web-
|
|
542
|
+
- Using a custom UI, including the flow of [app-web-user-account](https://github.com/pryv/app-web-user-account)
|
|
505
543
|
|
|
506
544
|
#### Using a custom login button
|
|
507
545
|
|
|
@@ -651,7 +689,7 @@ There is a possibility that you would like to register the user in another page.
|
|
|
651
689
|
|
|
652
690
|
You can find HTML examples in the [`./examples`](https://github.com/pryv/lib-js/blob/master/examples) directory. You can run them in two ways:
|
|
653
691
|
|
|
654
|
-
1. With [backloop.dev](https://
|
|
692
|
+
1. With [backloop.dev](https://backloop.dev), which allows to run local code with a valid SSL certificate (you must have run `just build` beforehand):
|
|
655
693
|
```
|
|
656
694
|
just serve
|
|
657
695
|
```
|
|
@@ -702,7 +740,7 @@ just test <component> [...params]
|
|
|
702
740
|
- Extra parameters at the end are passed on to [Mocha](https://mochajs.org/) (default settings are defined in `.mocharc.js` files)
|
|
703
741
|
- Replace `test` with `test-debug`, `test-cover` for common presets
|
|
704
742
|
|
|
705
|
-
By default, tests are run against
|
|
743
|
+
By default, tests are run against Pryv Lab with service information URL `https://reg.pryv.me/service/info`.
|
|
706
744
|
|
|
707
745
|
To run the tests against another Pryv.io platform, set the `TEST_PRYVLIB_SERVICEINFO_URL` environment variable; for example:
|
|
708
746
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pryv",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.1",
|
|
4
4
|
"description": "Pryv JavaScript library",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"Pryv",
|
|
@@ -15,11 +15,13 @@
|
|
|
15
15
|
"url": "git://github.com/pryv/lib-js.git"
|
|
16
16
|
},
|
|
17
17
|
"license": "BSD-3-Clause",
|
|
18
|
-
"author": "Pryv
|
|
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
|
}
|
|
@@ -112,7 +112,8 @@ class LoginButton {
|
|
|
112
112
|
// this step should be applied only for the browser
|
|
113
113
|
if (!utils.isBrowser()) return;
|
|
114
114
|
|
|
115
|
-
// 3. Check if there is a
|
|
115
|
+
// 3. Check if there is a pryvKey / pryvPoll (or legacy prYvkey /
|
|
116
|
+
// prYvpoll) as result of "out of page login"
|
|
116
117
|
const url = window.location.href;
|
|
117
118
|
const pollUrl = retrievePollUrl(url);
|
|
118
119
|
if (pollUrl !== null) {
|
|
@@ -126,21 +127,27 @@ class LoginButton {
|
|
|
126
127
|
error: e
|
|
127
128
|
};
|
|
128
129
|
}
|
|
129
|
-
//
|
|
130
|
-
//
|
|
130
|
+
// These params are one-shot; leaving them in the visible URL puts
|
|
131
|
+
// stale auth state into bookmarks / copied links.
|
|
131
132
|
if (window.history && typeof window.history.replaceState === 'function') {
|
|
132
133
|
window.history.replaceState(null, '', utils.cleanURLFromPrYvParams(url));
|
|
133
134
|
}
|
|
134
135
|
}
|
|
135
136
|
|
|
136
137
|
function retrievePollUrl (url) {
|
|
138
|
+
// Modern lowercase form (pryvKey / pryvPoll) is preferred; the
|
|
139
|
+
// capital-Y form (prYvkey / prYvpoll) is accepted for back-compat
|
|
140
|
+
// with apps emitting the legacy URL contract — see
|
|
141
|
+
// [DEPRECATED] notes on cleanURLFromPrYvParams.
|
|
137
142
|
const params = utils.getQueryParamsFromURL(url);
|
|
138
143
|
let pollUrl = null;
|
|
139
|
-
|
|
140
|
-
|
|
144
|
+
const key = params.pryvKey || params.prYvkey;
|
|
145
|
+
if (key) {
|
|
146
|
+
pollUrl = authController.serviceInfo.access + key;
|
|
141
147
|
}
|
|
142
|
-
|
|
143
|
-
|
|
148
|
+
const poll = params.pryvPoll || params.prYvpoll;
|
|
149
|
+
if (poll) {
|
|
150
|
+
pollUrl = poll;
|
|
144
151
|
}
|
|
145
152
|
return pollUrl;
|
|
146
153
|
}
|
|
@@ -0,0 +1,348 @@
|
|
|
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 utils = require('./utils');
|
|
8
|
+
|
|
9
|
+
// sessionStorage key prefix for the per-flow PKCE verifier, keyed by `state`.
|
|
10
|
+
const VERIFIER_KEY_PREFIX = 'pryv-oauth2-verifier:';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Minimal Web-Storage-like contract used to persist the per-flow PKCE verifier.
|
|
14
|
+
* A browser `sessionStorage` satisfies it structurally.
|
|
15
|
+
* @typedef {Object} OAuth2Storage
|
|
16
|
+
* @property {(key: string) => (string | null)} getItem
|
|
17
|
+
* @property {(key: string, value: string) => void} setItem
|
|
18
|
+
* @property {(key: string) => void} removeItem
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @class OAuth2Client
|
|
23
|
+
* Browser-side consumer of the Pryv OAuth2 authorization-code flow (PKCE).
|
|
24
|
+
*
|
|
25
|
+
* Sibling to {@link Browser}: an OAuth-aware app runs
|
|
26
|
+
* `redirectToAuthorize()` → (the browser bounces through `/oauth2/authorize`
|
|
27
|
+
* and back to `redirectUri`) → `handleCallback()`, which returns a ready
|
|
28
|
+
* {@link Connection}. `refresh()` swaps the refresh token for a fresh one.
|
|
29
|
+
*
|
|
30
|
+
* PKCE is handled internally: a random `code_verifier` is generated per flow,
|
|
31
|
+
* stored in `sessionStorage` keyed by `state`, and consumed on callback.
|
|
32
|
+
*
|
|
33
|
+
* The authorization-server endpoints are discovered from the issuer via
|
|
34
|
+
* RFC 8414 (`GET <issuer>/.well-known/oauth-authorization-server`).
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* const client = new pryv.OAuth2Client({
|
|
38
|
+
* authorizationServer: 'https://host', // Pryv API base (issuer)
|
|
39
|
+
* clientId: 'my-app',
|
|
40
|
+
* redirectUri: 'https://my-app.example/callback',
|
|
41
|
+
* scope: 'cmc:study-A'
|
|
42
|
+
* });
|
|
43
|
+
* // on "Login with Pryv":
|
|
44
|
+
* await client.redirectToAuthorize();
|
|
45
|
+
* // on the redirect_uri page:
|
|
46
|
+
* const connection = await client.handleCallback(window.location.search);
|
|
47
|
+
*
|
|
48
|
+
* @memberof pryv
|
|
49
|
+
*/
|
|
50
|
+
class OAuth2Client {
|
|
51
|
+
/**
|
|
52
|
+
* @param {Object} [options]
|
|
53
|
+
* @param {string} [options.authorizationServer] - Issuer / Pryv API base URL. The
|
|
54
|
+
* discovery document is fetched from `<authorizationServer>/.well-known/oauth-authorization-server`.
|
|
55
|
+
* (This is the concrete issuer URL, not the `/service/info` URL — client-side
|
|
56
|
+
* derivation from the per-user `service:api` template is unreliable for multi-core.)
|
|
57
|
+
* Required at runtime.
|
|
58
|
+
* @param {string} [options.clientId] - App-account client id. Required at runtime.
|
|
59
|
+
* @param {string} [options.redirectUri] - Registered redirect URI. Required at runtime.
|
|
60
|
+
* @param {string} [options.scope] - Consent-offer reference registered on the client, e.g. `'cmc:study-A'`.
|
|
61
|
+
* @param {OAuth2Storage} [options.storage] - Web-Storage-like `{ getItem, setItem, removeItem }`.
|
|
62
|
+
* Defaults to `globalThis.sessionStorage` in a browser, else an in-memory store.
|
|
63
|
+
* @param {string} [options.refreshToken] - Seed the client with a previously-persisted
|
|
64
|
+
* refresh token so `refresh()` works after a page reload WITHOUT re-running the
|
|
65
|
+
* authorization flow. Persist ONLY this value (read it from `client.refreshToken`
|
|
66
|
+
* or the `onTokenRotated` callback) — never the whole `lastTokenResponse`, which
|
|
67
|
+
* also carries the access token and so is a larger XSS surface.
|
|
68
|
+
* @param {(refreshToken: string) => void} [options.onTokenRotated] - Called with the
|
|
69
|
+
* NEW refresh token every time it rotates (after `handleCallback()` and each
|
|
70
|
+
* `refresh()`), so the app can persist the minimal secret. Exceptions it throws
|
|
71
|
+
* are swallowed (persistence must not break the token exchange).
|
|
72
|
+
*/
|
|
73
|
+
constructor (options = {}) {
|
|
74
|
+
const { authorizationServer, clientId, redirectUri, scope, storage, refreshToken, onTokenRotated } = options;
|
|
75
|
+
if (!authorizationServer) throw new Error('OAuth2Client: "authorizationServer" is required');
|
|
76
|
+
if (!clientId) throw new Error('OAuth2Client: "clientId" is required');
|
|
77
|
+
if (!redirectUri) throw new Error('OAuth2Client: "redirectUri" is required');
|
|
78
|
+
|
|
79
|
+
this.issuer = new URL(authorizationServer);
|
|
80
|
+
this.clientId = clientId;
|
|
81
|
+
this.redirectUri = redirectUri;
|
|
82
|
+
this.scope = scope;
|
|
83
|
+
this.storage = storage || defaultStorage();
|
|
84
|
+
|
|
85
|
+
// Public client (PKCE, no secret) — token_endpoint auth method "none".
|
|
86
|
+
this._client = { client_id: clientId };
|
|
87
|
+
this._clientAuth = oauth.None();
|
|
88
|
+
this._as = null;
|
|
89
|
+
this._refreshToken = refreshToken || null;
|
|
90
|
+
this._onTokenRotated = (typeof onTokenRotated === 'function') ? onTokenRotated : null;
|
|
91
|
+
// In-flight refresh() promise — dedups concurrent callers onto one token
|
|
92
|
+
// request so they can't each present the same refresh token and have the
|
|
93
|
+
// loser rejected as reuse (invalid_grant) by an always-rotating server.
|
|
94
|
+
this._refreshInFlight = null;
|
|
95
|
+
/** Last raw token-endpoint response (access_token, scope, apiEndpoint, …). */
|
|
96
|
+
this.lastTokenResponse = null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The current refresh token (rotates on every `handleCallback()` / `refresh()`).
|
|
101
|
+
* Read it to persist the minimal secret across reloads; re-seed via the
|
|
102
|
+
* `refreshToken` constructor option. `null` before the first exchange.
|
|
103
|
+
* @returns {string | null}
|
|
104
|
+
*/
|
|
105
|
+
get refreshToken () {
|
|
106
|
+
return this._refreshToken;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @private
|
|
111
|
+
* Lazily discover + cache the authorization-server metadata (RFC 8414).
|
|
112
|
+
* @returns {Promise<Object>} the `AuthorizationServer` metadata
|
|
113
|
+
*/
|
|
114
|
+
async _discover () {
|
|
115
|
+
if (this._as) return this._as;
|
|
116
|
+
const response = await oauth.discoveryRequest(this.issuer, { algorithm: 'oauth2' });
|
|
117
|
+
this._as = await oauth.processDiscoveryResponse(this.issuer, response);
|
|
118
|
+
return this._as;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Build the `/oauth2/authorize` URL (generating + storing the PKCE verifier)
|
|
123
|
+
* and navigate to it. Returns the URL so non-browser callers can drive the
|
|
124
|
+
* redirect themselves.
|
|
125
|
+
*
|
|
126
|
+
* @param {Object} [options]
|
|
127
|
+
* @param {string} [options.state] - CSRF/correlation value; a random one is generated when omitted.
|
|
128
|
+
* @param {(url: string) => void} [options.redirect] - Navigation function; defaults to
|
|
129
|
+
* `globalThis.location.assign` when available, else a no-op (URL still returned).
|
|
130
|
+
* @returns {Promise<string>} the authorization URL
|
|
131
|
+
*/
|
|
132
|
+
async redirectToAuthorize (options = {}) {
|
|
133
|
+
const as = await this._discover();
|
|
134
|
+
const state = options.state || oauth.generateRandomState();
|
|
135
|
+
const codeVerifier = oauth.generateRandomCodeVerifier();
|
|
136
|
+
const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier);
|
|
137
|
+
this.storage.setItem(VERIFIER_KEY_PREFIX + state, codeVerifier);
|
|
138
|
+
|
|
139
|
+
const url = new URL(as.authorization_endpoint);
|
|
140
|
+
// The browser is about to be navigated to this URL. `oauth4webapi` only
|
|
141
|
+
// enforces https on endpoints it fetches itself, not on this navigation
|
|
142
|
+
// target — a tampered/MITM discovery document could return an http:/other
|
|
143
|
+
// `authorization_endpoint` and send the user somewhere hostile. Assert the
|
|
144
|
+
// scheme with the same https-or-loopback rule used for the apiEndpoint.
|
|
145
|
+
assertHttpsOrLoopback(url, 'authorization_endpoint');
|
|
146
|
+
url.searchParams.set('response_type', 'code');
|
|
147
|
+
url.searchParams.set('client_id', this.clientId);
|
|
148
|
+
url.searchParams.set('redirect_uri', this.redirectUri);
|
|
149
|
+
if (this.scope) url.searchParams.set('scope', this.scope);
|
|
150
|
+
url.searchParams.set('code_challenge', codeChallenge);
|
|
151
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
152
|
+
url.searchParams.set('state', state);
|
|
153
|
+
|
|
154
|
+
const authorizationUrl = url.href;
|
|
155
|
+
const redirect = options.redirect || defaultRedirect;
|
|
156
|
+
redirect(authorizationUrl);
|
|
157
|
+
return authorizationUrl;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Validate the redirect_uri callback, exchange the code for tokens (PKCE),
|
|
162
|
+
* and build a {@link Connection} from the Pryv `apiEndpoint` extension.
|
|
163
|
+
*
|
|
164
|
+
* @param {string} queryString - `window.location.search` (with or without leading `?`).
|
|
165
|
+
* @returns {Promise<Connection>} an authenticated Pryv connection
|
|
166
|
+
*/
|
|
167
|
+
async handleCallback (queryString) {
|
|
168
|
+
const as = await this._discover();
|
|
169
|
+
const params = new URLSearchParams(stripLeadingQuestionMark(queryString));
|
|
170
|
+
|
|
171
|
+
const state = params.get('state');
|
|
172
|
+
if (!state) throw new Error('OAuth2Client: callback is missing "state"');
|
|
173
|
+
const storageKey = VERIFIER_KEY_PREFIX + state;
|
|
174
|
+
const codeVerifier = this.storage.getItem(storageKey);
|
|
175
|
+
if (!codeVerifier) {
|
|
176
|
+
throw new Error('OAuth2Client: no stored PKCE verifier for this state (expired session or forged callback)');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// The verifier is single-use and bound to this state; drop it on every
|
|
180
|
+
// exit path (success OR error) so a failed/denied callback never leaves it
|
|
181
|
+
// behind in sessionStorage.
|
|
182
|
+
try {
|
|
183
|
+
// Throws on authorization errors (e.g. access_denied) or a state mismatch.
|
|
184
|
+
const callbackParams = oauth.validateAuthResponse(as, this._client, params, state);
|
|
185
|
+
const response = await oauth.authorizationCodeGrantRequest(
|
|
186
|
+
as, this._client, this._clientAuth, callbackParams, this.redirectUri, codeVerifier
|
|
187
|
+
);
|
|
188
|
+
const result = await oauth.processAuthorizationCodeResponse(as, this._client, response);
|
|
189
|
+
return this._connectionFromTokenResponse(result);
|
|
190
|
+
} finally {
|
|
191
|
+
this.storage.removeItem(storageKey);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Exchange the stored refresh token for a fresh access token and return a
|
|
197
|
+
* new {@link Connection}. Requires a prior successful `handleCallback()`.
|
|
198
|
+
*
|
|
199
|
+
* @returns {Promise<Connection>}
|
|
200
|
+
*/
|
|
201
|
+
async refresh () {
|
|
202
|
+
// Serialize concurrent callers onto a single in-flight exchange (F1). An
|
|
203
|
+
// always-rotating server consumes the refresh token on first use, so two
|
|
204
|
+
// parallel refresh() calls presenting the same token would rotate once and
|
|
205
|
+
// have the loser rejected as reuse. Dedup to one request; both callers get
|
|
206
|
+
// the same fresh Connection.
|
|
207
|
+
if (this._refreshInFlight) return this._refreshInFlight;
|
|
208
|
+
this._refreshInFlight = this._doRefresh();
|
|
209
|
+
// Clear the slot once settled (success OR failure) so the next call retries.
|
|
210
|
+
this._refreshInFlight.catch(() => {}).finally(() => { this._refreshInFlight = null; });
|
|
211
|
+
return this._refreshInFlight;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* @private
|
|
216
|
+
* The actual refresh exchange, wrapped by `refresh()`'s in-flight dedup.
|
|
217
|
+
* @returns {Promise<Connection>}
|
|
218
|
+
*/
|
|
219
|
+
async _doRefresh () {
|
|
220
|
+
if (!this._refreshToken) {
|
|
221
|
+
throw new Error('OAuth2Client: no refresh token available; call handleCallback() first');
|
|
222
|
+
}
|
|
223
|
+
const as = await this._discover();
|
|
224
|
+
const response = await oauth.refreshTokenGrantRequest(as, this._client, this._clientAuth, this._refreshToken);
|
|
225
|
+
const result = await oauth.processRefreshTokenResponse(as, this._client, response);
|
|
226
|
+
return this._connectionFromTokenResponse(result);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* @private
|
|
231
|
+
* @param {Object} tokenResponse - a processed token-endpoint response
|
|
232
|
+
* @returns {Connection}
|
|
233
|
+
*/
|
|
234
|
+
_connectionFromTokenResponse (tokenResponse) {
|
|
235
|
+
// Persist the rotated refresh token FIRST — before any validation that can
|
|
236
|
+
// throw (F2). The server has already committed the rotation (old token
|
|
237
|
+
// consumed, new one issued in this response). If we validated first and it
|
|
238
|
+
// threw (e.g. a momentarily-missing or http: apiEndpoint), we would strand
|
|
239
|
+
// the client on the now-dead old token AND lose the new one — permanently
|
|
240
|
+
// bricking the session. Storing first lets a later refresh() retry with the
|
|
241
|
+
// current token.
|
|
242
|
+
this._ingestTokens(tokenResponse);
|
|
243
|
+
|
|
244
|
+
const apiEndpoint = tokenResponse.apiEndpoint;
|
|
245
|
+
if (!apiEndpoint) {
|
|
246
|
+
throw new Error('OAuth2Client: token response is missing the Pryv "apiEndpoint" extension');
|
|
247
|
+
}
|
|
248
|
+
// Validate the endpoint the Connection will actually send the token to, not
|
|
249
|
+
// the raw apiEndpoint string: Connection splits token/endpoint on the LAST
|
|
250
|
+
// `@` (utils regex), so a crafted `http://tok@127.0.0.1/x@evil/` parses as
|
|
251
|
+
// loopback here but posts the token to `evil` there. Assert on the extracted
|
|
252
|
+
// endpoint to close that parser-divergence gap.
|
|
253
|
+
const { endpoint } = utils.extractTokenAndAPIEndpoint(String(apiEndpoint));
|
|
254
|
+
assertSecureApiEndpoint(endpoint);
|
|
255
|
+
return new Connection(String(apiEndpoint));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* @private
|
|
260
|
+
* Non-throwing: record the rotated refresh token + raw response and notify the
|
|
261
|
+
* app so it can persist the minimal secret. Runs before validation so a
|
|
262
|
+
* validation failure never loses the rotation (see `_connectionFromTokenResponse`).
|
|
263
|
+
* @param {Object} tokenResponse - a processed token-endpoint response
|
|
264
|
+
*/
|
|
265
|
+
_ingestTokens (tokenResponse) {
|
|
266
|
+
this.lastTokenResponse = tokenResponse;
|
|
267
|
+
if (!tokenResponse.refresh_token) return;
|
|
268
|
+
this._refreshToken = tokenResponse.refresh_token;
|
|
269
|
+
if (this._onTokenRotated) {
|
|
270
|
+
// Persistence must never break the exchange — swallow app callback errors.
|
|
271
|
+
try { this._onTokenRotated(this._refreshToken); } catch (_) { /* ignore */ }
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* @private
|
|
278
|
+
* Refuse an `apiEndpoint` that would send the bearer token in cleartext. The
|
|
279
|
+
* token is embedded in the endpoint (`https://<token>@host/`) and then sent on
|
|
280
|
+
* every request, so a non-https endpoint leaks it on the wire. Defense-in-depth
|
|
281
|
+
* against a compromised/misconfigured authorization server.
|
|
282
|
+
* @param {string} apiEndpoint
|
|
283
|
+
*/
|
|
284
|
+
function assertSecureApiEndpoint (apiEndpoint) {
|
|
285
|
+
assertHttpsOrLoopback(apiEndpoint, 'apiEndpoint');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* @private
|
|
290
|
+
* Enforce a "must be transport-secure" rule on a URL that is either navigated
|
|
291
|
+
* to (the authorize redirect) or used to send the bearer token (the
|
|
292
|
+
* apiEndpoint): allow `https:` everywhere, and `http:` only for loopback hosts
|
|
293
|
+
* (local development). Single source of truth so both call sites stay in sync.
|
|
294
|
+
* @param {string | URL} rawUrl - the URL to check (string or a parsed `URL`)
|
|
295
|
+
* @param {string} label - human-readable name of the value, used in errors
|
|
296
|
+
*/
|
|
297
|
+
function assertHttpsOrLoopback (rawUrl, label) {
|
|
298
|
+
let url;
|
|
299
|
+
try {
|
|
300
|
+
url = (rawUrl instanceof URL) ? rawUrl : new URL(rawUrl);
|
|
301
|
+
} catch {
|
|
302
|
+
throw new Error('OAuth2Client: ' + label + ' is not a valid URL');
|
|
303
|
+
}
|
|
304
|
+
const host = url.hostname;
|
|
305
|
+
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || host === '::1';
|
|
306
|
+
if (url.protocol === 'https:') return;
|
|
307
|
+
if (url.protocol === 'http:' && isLoopback) return;
|
|
308
|
+
throw new Error('OAuth2Client: refusing insecure ' + label + ' (' + url.protocol + '//' + host + '); https is required');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* @private
|
|
313
|
+
* Default navigation: use the browser's `location.assign` when present,
|
|
314
|
+
* otherwise a no-op (the caller gets the URL back from `redirectToAuthorize`).
|
|
315
|
+
* @param {string} url
|
|
316
|
+
*/
|
|
317
|
+
function defaultRedirect (url) {
|
|
318
|
+
const loc = (typeof globalThis !== 'undefined') ? globalThis.location : undefined;
|
|
319
|
+
if (loc && typeof loc.assign === 'function') loc.assign(url);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* @private
|
|
324
|
+
* `globalThis.sessionStorage` in a browser, else a process-local in-memory store
|
|
325
|
+
* (sufficient for tests and non-browser callers that inject nothing).
|
|
326
|
+
* @returns {OAuth2Storage}
|
|
327
|
+
*/
|
|
328
|
+
function defaultStorage () {
|
|
329
|
+
if (typeof globalThis !== 'undefined' && globalThis.sessionStorage) return globalThis.sessionStorage;
|
|
330
|
+
const map = new Map();
|
|
331
|
+
return {
|
|
332
|
+
getItem: (k) => (map.has(k) ? map.get(k) : null),
|
|
333
|
+
setItem: (k, v) => { map.set(k, String(v)); },
|
|
334
|
+
removeItem: (k) => { map.delete(k); }
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* @private
|
|
340
|
+
* @param {string} queryString
|
|
341
|
+
* @returns {string}
|
|
342
|
+
*/
|
|
343
|
+
function stripLeadingQuestionMark (queryString) {
|
|
344
|
+
const s = queryString || '';
|
|
345
|
+
return s.charAt(0) === '?' ? s.slice(1) : s;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
module.exports = OAuth2Client;
|
package/src/index.d.ts
CHANGED
|
@@ -1059,6 +1059,47 @@ declare module 'pryv' {
|
|
|
1059
1059
|
get state(): AuthStatePayload;
|
|
1060
1060
|
}
|
|
1061
1061
|
|
|
1062
|
+
/**
|
|
1063
|
+
* Web-Storage-like store used by OAuth2Client to hold the per-flow PKCE verifier.
|
|
1064
|
+
*/
|
|
1065
|
+
export type OAuth2ClientStorage = {
|
|
1066
|
+
getItem(key: string): string | null;
|
|
1067
|
+
setItem(key: string, value: string): void;
|
|
1068
|
+
removeItem(key: string): void;
|
|
1069
|
+
};
|
|
1070
|
+
|
|
1071
|
+
export type OAuth2ClientOptions = {
|
|
1072
|
+
/** Issuer / Pryv API base URL; discovery doc read from `<url>/.well-known/oauth-authorization-server`. */
|
|
1073
|
+
authorizationServer: string;
|
|
1074
|
+
/** App-account client id. */
|
|
1075
|
+
clientId: string;
|
|
1076
|
+
/** Registered redirect URI. */
|
|
1077
|
+
redirectUri: string;
|
|
1078
|
+
/** Consent-offer reference registered on the client, e.g. `'cmc:study-A'`. */
|
|
1079
|
+
scope?: string;
|
|
1080
|
+
/** Defaults to `globalThis.sessionStorage` (browser) or an in-memory store. */
|
|
1081
|
+
storage?: OAuth2ClientStorage;
|
|
1082
|
+
};
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* Browser-side consumer of the Pryv OAuth2 authorization-code (PKCE) flow.
|
|
1086
|
+
*/
|
|
1087
|
+
export class OAuth2Client {
|
|
1088
|
+
constructor(options: OAuth2ClientOptions);
|
|
1089
|
+
issuer: URL;
|
|
1090
|
+
clientId: string;
|
|
1091
|
+
redirectUri: string;
|
|
1092
|
+
scope?: string;
|
|
1093
|
+
/** Last raw token-endpoint response (access_token, scope, apiEndpoint, …). */
|
|
1094
|
+
lastTokenResponse: { [key: string]: unknown } | null;
|
|
1095
|
+
/** Build + navigate to the `/oauth2/authorize` URL; returns the URL. */
|
|
1096
|
+
redirectToAuthorize(options?: { state?: string; redirect?: (url: string) => void }): Promise<string>;
|
|
1097
|
+
/** Validate the callback, exchange the code (PKCE), return a Connection. */
|
|
1098
|
+
handleCallback(queryString: string): Promise<Connection>;
|
|
1099
|
+
/** Exchange the stored refresh token for a fresh Connection. */
|
|
1100
|
+
refresh(): Promise<Connection>;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1062
1103
|
export const Auth: {
|
|
1063
1104
|
setupAuth: SetupAuth;
|
|
1064
1105
|
AuthStates: AuthStates;
|
|
@@ -1116,6 +1157,7 @@ declare module 'pryv' {
|
|
|
1116
1157
|
let pryv: {
|
|
1117
1158
|
Service: typeof Service;
|
|
1118
1159
|
Connection: typeof Connection;
|
|
1160
|
+
OAuth2Client: typeof OAuth2Client;
|
|
1119
1161
|
Auth: {
|
|
1120
1162
|
setupAuth: SetupAuth;
|
|
1121
1163
|
AuthStates: AuthStates;
|
|
@@ -1132,15 +1174,11 @@ declare module 'pryv' {
|
|
|
1132
1174
|
setupAuth: SetupAuth;
|
|
1133
1175
|
serviceInfoFromUrl: getServiceInfoFromURL;
|
|
1134
1176
|
};
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
browserIsMobileOrTablet(navigator?: string | Navigator): boolean;
|
|
1141
|
-
cleanURLFromPrYvParams(url: string): string;
|
|
1142
|
-
getQueryParamsFromURL(url: string): KeyValue;
|
|
1143
|
-
};
|
|
1177
|
+
// Reference the named export's type — a duplicated inline shape here
|
|
1178
|
+
// drifted from it once already (the deprecated aliases were missing),
|
|
1179
|
+
// which broke structural typing for add-ons taking the default export
|
|
1180
|
+
// (e.g. @pryv/socket.io's PryvLibrary).
|
|
1181
|
+
utils: typeof utils;
|
|
1144
1182
|
PryvError: typeof PryvError;
|
|
1145
1183
|
MfaRequiredError: typeof MfaRequiredError;
|
|
1146
1184
|
ERRORS: typeof ERRORS;
|
package/src/index.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
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
10
|
* @property {pryv.Browser} Browser - Browser Tools - Access request helpers and visuals (button)
|
|
11
|
+
* @property {pryv.OAuth2Client} OAuth2Client - Browser-side OAuth2 authorization-code (PKCE) flow consumer
|
|
11
12
|
* @property {pryv.utils} utils - Exposes some utils for HTTP calls and tools to manipulate Pryv's API endpoints
|
|
12
13
|
* @property {pryv.PryvError} PryvError - Custom error class with innerObject + structured API-error fields
|
|
13
14
|
* @property {pryv.MfaRequiredError} MfaRequiredError - Thrown by Service.login when the platform returns an mfaToken instead of a token. Carries `.mfaToken`.
|
|
@@ -21,6 +22,7 @@ module.exports = {
|
|
|
21
22
|
Connection: require('./Connection'),
|
|
22
23
|
Auth: require('./Auth'),
|
|
23
24
|
Browser: require('./Browser'),
|
|
25
|
+
OAuth2Client: require('./OAuth2Client'),
|
|
24
26
|
utils: require('./utils'),
|
|
25
27
|
PryvError: require('./lib/PryvError'),
|
|
26
28
|
MfaRequiredError: require('./lib/MfaRequiredError'),
|
package/src/utils.js
CHANGED
|
@@ -215,14 +215,29 @@ const utils = module.exports = {
|
|
|
215
215
|
},
|
|
216
216
|
|
|
217
217
|
/**
|
|
218
|
-
* Remove Pryv-
|
|
218
|
+
* Remove the one-shot Pryv auth-completion query parameters from a URL,
|
|
219
|
+
* keeping any long-lived `pryv*` params intact.
|
|
220
|
+
*
|
|
221
|
+
* Both casings are stripped:
|
|
222
|
+
* - Modern lowercase: `pryvKey`, `pryvPoll`
|
|
223
|
+
* - Legacy capital-Y: `prYv<anything>` (anything starting with `prYv`)
|
|
224
|
+
*
|
|
225
|
+
* `pryv*` params that are NOT in the modern allowlist (e.g.
|
|
226
|
+
* `pryvServiceInfoUrl`, `pryvApiEndpoint`) are intentionally preserved —
|
|
227
|
+
* they are not one-shot.
|
|
228
|
+
*
|
|
219
229
|
* @memberof pryv.utils
|
|
220
230
|
* @param {string} url - URL to clean
|
|
221
|
-
* @returns {string} URL without
|
|
231
|
+
* @returns {string} URL without the one-shot auth-completion params
|
|
222
232
|
*/
|
|
223
233
|
cleanURLFromPrYvParams: function (url) {
|
|
224
|
-
|
|
225
|
-
|
|
234
|
+
// Legacy form: kept for back-compat with apps still emitting
|
|
235
|
+
// `prYv<anything>=...` (notably app-web-user-account's close_or_redirect).
|
|
236
|
+
const LEGACY = /[?#&]+prYv([^=&]+)=([^&]*)/g;
|
|
237
|
+
// Modern form: an explicit allowlist of one-shot params so we never
|
|
238
|
+
// wipe long-lived camelCase `pryv*` params by accident.
|
|
239
|
+
const MODERN = /[?#&]+(pryvKey|pryvPoll)=([^&]*)/g;
|
|
240
|
+
return url.replace(LEGACY, '').replace(MODERN, '');
|
|
226
241
|
},
|
|
227
242
|
|
|
228
243
|
/**
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
/* global describe, it, beforeEach, afterEach, expect */
|
|
6
|
+
|
|
7
|
+
const OAuth2Client = require('../src/OAuth2Client');
|
|
8
|
+
const oauth = require('oauth4webapi');
|
|
9
|
+
|
|
10
|
+
const ISSUER = 'https://host';
|
|
11
|
+
const DISCOVERY = {
|
|
12
|
+
issuer: ISSUER,
|
|
13
|
+
authorization_endpoint: ISSUER + '/oauth2/authorize',
|
|
14
|
+
token_endpoint: ISSUER + '/oauth2/token',
|
|
15
|
+
response_types_supported: ['code'],
|
|
16
|
+
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
17
|
+
token_endpoint_auth_methods_supported: ['client_secret_basic', 'none'],
|
|
18
|
+
code_challenge_methods_supported: ['S256']
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// A Pryv token-endpoint response (RFC 6749 §5.1 + the `apiEndpoint` extension).
|
|
22
|
+
function tokenBody (overrides = {}) {
|
|
23
|
+
return Object.assign({
|
|
24
|
+
access_token: 'TOKEN123',
|
|
25
|
+
token_type: 'Bearer',
|
|
26
|
+
expires_in: 3600,
|
|
27
|
+
refresh_token: 'REFRESH456',
|
|
28
|
+
scope: 'cmc:study-A',
|
|
29
|
+
apiEndpoint: 'https://TOKEN123@host/path/'
|
|
30
|
+
}, overrides);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function jsonResponse (body, status = 200) {
|
|
34
|
+
return new Response(JSON.stringify(body), {
|
|
35
|
+
status,
|
|
36
|
+
headers: { 'content-type': 'application/json' }
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function memoryStorage () {
|
|
41
|
+
const map = new Map();
|
|
42
|
+
return {
|
|
43
|
+
map,
|
|
44
|
+
getItem: (k) => (map.has(k) ? map.get(k) : null),
|
|
45
|
+
setItem: (k, v) => { map.set(k, String(v)); },
|
|
46
|
+
removeItem: (k) => { map.delete(k); }
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Install a global.fetch mock serving discovery + a configurable token response.
|
|
51
|
+
// Returns a handle exposing the captured token request and lets tests override
|
|
52
|
+
// the token response.
|
|
53
|
+
function installFetchMock () {
|
|
54
|
+
const state = { tokenResponse: jsonResponse(tokenBody()), lastTokenRequest: null, discovery: null, tokenRequestCount: 0 };
|
|
55
|
+
const original = globalThis.fetch;
|
|
56
|
+
globalThis.fetch = async (input, init) => {
|
|
57
|
+
const url = String(input && input.url ? input.url : input);
|
|
58
|
+
if (url.includes('.well-known/oauth-authorization-server')) {
|
|
59
|
+
return jsonResponse(state.discovery || DISCOVERY);
|
|
60
|
+
}
|
|
61
|
+
if (url === DISCOVERY.token_endpoint) {
|
|
62
|
+
state.tokenRequestCount++;
|
|
63
|
+
state.lastTokenRequest = { url, init, body: init && init.body ? init.body.toString() : '' };
|
|
64
|
+
// A Response body is single-use; clone so a test can reuse one response
|
|
65
|
+
// object across sequential requests without an "already read" error.
|
|
66
|
+
return (state.tokenResponse instanceof Response) ? state.tokenResponse.clone() : state.tokenResponse;
|
|
67
|
+
}
|
|
68
|
+
throw new Error('unexpected fetch: ' + url);
|
|
69
|
+
};
|
|
70
|
+
state.restore = () => { globalThis.fetch = original; };
|
|
71
|
+
return state;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function newClient (storage, overrides = {}) {
|
|
75
|
+
return new OAuth2Client(Object.assign({
|
|
76
|
+
authorizationServer: ISSUER,
|
|
77
|
+
clientId: 'app1',
|
|
78
|
+
redirectUri: 'https://app.example/cb',
|
|
79
|
+
scope: 'cmc:study-A',
|
|
80
|
+
storage
|
|
81
|
+
}, overrides));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe('[OAUTH-LIB] OAuth2Client', function () {
|
|
85
|
+
let fetchMock;
|
|
86
|
+
beforeEach(function () { fetchMock = installFetchMock(); });
|
|
87
|
+
afterEach(function () { fetchMock.restore(); });
|
|
88
|
+
|
|
89
|
+
describe('[OAL-CTOR] constructor validation', function () {
|
|
90
|
+
it('[OAL-C1] throws without authorizationServer', function () {
|
|
91
|
+
expect(() => new OAuth2Client({ clientId: 'a', redirectUri: 'b' }))
|
|
92
|
+
.to.throw(/authorizationServer/);
|
|
93
|
+
});
|
|
94
|
+
it('[OAL-C2] throws without clientId', function () {
|
|
95
|
+
expect(() => new OAuth2Client({ authorizationServer: ISSUER, redirectUri: 'b' }))
|
|
96
|
+
.to.throw(/clientId/);
|
|
97
|
+
});
|
|
98
|
+
it('[OAL-C3] throws without redirectUri', function () {
|
|
99
|
+
expect(() => new OAuth2Client({ authorizationServer: ISSUER, clientId: 'a' }))
|
|
100
|
+
.to.throw(/redirectUri/);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('[OAL-AUTH] redirectToAuthorize', function () {
|
|
105
|
+
it('[OAL-A1] builds the authorize URL with all required params', async function () {
|
|
106
|
+
const storage = memoryStorage();
|
|
107
|
+
const client = newClient(storage);
|
|
108
|
+
const url = await client.redirectToAuthorize({ state: 'STATE123' });
|
|
109
|
+
const u = new URL(url);
|
|
110
|
+
expect(u.origin + u.pathname).to.equal(DISCOVERY.authorization_endpoint);
|
|
111
|
+
expect(u.searchParams.get('response_type')).to.equal('code');
|
|
112
|
+
expect(u.searchParams.get('client_id')).to.equal('app1');
|
|
113
|
+
expect(u.searchParams.get('redirect_uri')).to.equal('https://app.example/cb');
|
|
114
|
+
expect(u.searchParams.get('scope')).to.equal('cmc:study-A');
|
|
115
|
+
expect(u.searchParams.get('state')).to.equal('STATE123');
|
|
116
|
+
expect(u.searchParams.get('code_challenge_method')).to.equal('S256');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('[OAL-A2] stores a PKCE verifier keyed by state; challenge = base64url(sha256(verifier))', async function () {
|
|
120
|
+
const storage = memoryStorage();
|
|
121
|
+
const client = newClient(storage);
|
|
122
|
+
const url = await client.redirectToAuthorize({ state: 'S' });
|
|
123
|
+
const verifier = storage.getItem('pryv-oauth2-verifier:S');
|
|
124
|
+
expect(verifier).to.be.a('string').with.length.greaterThan(42);
|
|
125
|
+
const challenge = new URL(url).searchParams.get('code_challenge');
|
|
126
|
+
expect(challenge).to.match(/^[A-Za-z0-9_-]{43}$/);
|
|
127
|
+
const expected = await oauth.calculatePKCECodeChallenge(verifier);
|
|
128
|
+
expect(challenge).to.equal(expected);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('[OAL-A3] generates a random state when none is supplied', async function () {
|
|
132
|
+
const storage = memoryStorage();
|
|
133
|
+
const client = newClient(storage);
|
|
134
|
+
const url = await client.redirectToAuthorize();
|
|
135
|
+
const state = new URL(url).searchParams.get('state');
|
|
136
|
+
expect(state).to.be.a('string').with.length.greaterThan(0);
|
|
137
|
+
expect(storage.getItem('pryv-oauth2-verifier:' + state)).to.be.a('string');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('[OAL-A4] invokes the provided redirect function with the URL', async function () {
|
|
141
|
+
const storage = memoryStorage();
|
|
142
|
+
const client = newClient(storage);
|
|
143
|
+
let redirected = null;
|
|
144
|
+
const url = await client.redirectToAuthorize({ state: 'S', redirect: (u) => { redirected = u; } });
|
|
145
|
+
expect(redirected).to.equal(url);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('[OAL-A5] omits scope from the URL when not configured', async function () {
|
|
149
|
+
const storage = memoryStorage();
|
|
150
|
+
const client = newClient(storage, { scope: undefined });
|
|
151
|
+
const url = await client.redirectToAuthorize({ state: 'S' });
|
|
152
|
+
expect(new URL(url).searchParams.has('scope')).to.equal(false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('[OAL-A6] allows an http authorization_endpoint on a loopback host (local dev)', async function () {
|
|
156
|
+
const storage = memoryStorage();
|
|
157
|
+
fetchMock.discovery = Object.assign({}, DISCOVERY, {
|
|
158
|
+
authorization_endpoint: 'http://127.0.0.1:3000/oauth2/authorize'
|
|
159
|
+
});
|
|
160
|
+
const client = newClient(storage);
|
|
161
|
+
const url = await client.redirectToAuthorize({ state: 'S', redirect: () => {} });
|
|
162
|
+
expect(new URL(url).protocol).to.equal('http:');
|
|
163
|
+
expect(new URL(url).hostname).to.equal('127.0.0.1');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('[OAL-A7] refuses a non-loopback http authorization_endpoint (MITM discovery)', async function () {
|
|
167
|
+
const storage = memoryStorage();
|
|
168
|
+
fetchMock.discovery = Object.assign({}, DISCOVERY, {
|
|
169
|
+
authorization_endpoint: 'http://evil.example/oauth2/authorize'
|
|
170
|
+
});
|
|
171
|
+
const client = newClient(storage);
|
|
172
|
+
let navigated = null;
|
|
173
|
+
await expect(client.redirectToAuthorize({ state: 'S', redirect: (u) => { navigated = u; } }))
|
|
174
|
+
.to.be.rejectedWith(/insecure|https/);
|
|
175
|
+
expect(navigated).to.equal(null); // never navigated the browser
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('[OAL-A8] refuses a javascript: authorization_endpoint', async function () {
|
|
179
|
+
const storage = memoryStorage();
|
|
180
|
+
fetchMock.discovery = Object.assign({}, DISCOVERY, {
|
|
181
|
+
authorization_endpoint: 'javascript:alert(document.cookie)'
|
|
182
|
+
});
|
|
183
|
+
const client = newClient(storage);
|
|
184
|
+
await expect(client.redirectToAuthorize({ state: 'S', redirect: () => {} }))
|
|
185
|
+
.to.be.rejectedWith(/insecure|https/);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('[OAL-CB] handleCallback', function () {
|
|
190
|
+
async function primedClient (storage) {
|
|
191
|
+
const client = newClient(storage);
|
|
192
|
+
await client.redirectToAuthorize({ state: 'S', redirect: () => {} });
|
|
193
|
+
return client;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
it('[OAL-B1] exchanges the code and returns a Connection built from apiEndpoint', async function () {
|
|
197
|
+
const storage = memoryStorage();
|
|
198
|
+
const client = await primedClient(storage);
|
|
199
|
+
const connection = await client.handleCallback('?code=CODE&state=S');
|
|
200
|
+
expect(connection.constructor.name).to.equal('Connection');
|
|
201
|
+
expect(connection.token).to.equal('TOKEN123');
|
|
202
|
+
expect(connection.endpoint).to.equal('https://host/path/');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('[OAL-B2] sends grant_type=authorization_code + the PKCE verifier to the token endpoint', async function () {
|
|
206
|
+
const storage = memoryStorage();
|
|
207
|
+
const client = await primedClient(storage);
|
|
208
|
+
const verifier = storage.getItem('pryv-oauth2-verifier:S');
|
|
209
|
+
await client.handleCallback('?code=CODE&state=S');
|
|
210
|
+
const body = new URLSearchParams(fetchMock.lastTokenRequest.body);
|
|
211
|
+
expect(body.get('grant_type')).to.equal('authorization_code');
|
|
212
|
+
expect(body.get('code')).to.equal('CODE');
|
|
213
|
+
expect(body.get('code_verifier')).to.equal(verifier);
|
|
214
|
+
expect(body.get('redirect_uri')).to.equal('https://app.example/cb');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('[OAL-B3] removes the stored verifier after a successful exchange', async function () {
|
|
218
|
+
const storage = memoryStorage();
|
|
219
|
+
const client = await primedClient(storage);
|
|
220
|
+
await client.handleCallback('?code=CODE&state=S');
|
|
221
|
+
expect(storage.getItem('pryv-oauth2-verifier:S')).to.equal(null);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('[OAL-B4] accepts a query string without a leading question mark', async function () {
|
|
225
|
+
const storage = memoryStorage();
|
|
226
|
+
const client = await primedClient(storage);
|
|
227
|
+
const connection = await client.handleCallback('code=CODE&state=S');
|
|
228
|
+
expect(connection.token).to.equal('TOKEN123');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('[OAL-B5] throws when the callback has no state', async function () {
|
|
232
|
+
const storage = memoryStorage();
|
|
233
|
+
const client = await primedClient(storage);
|
|
234
|
+
await expect(client.handleCallback('?code=CODE')).to.be.rejectedWith(/state/);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('[OAL-B6] throws when no verifier is stored for the state (forged/expired)', async function () {
|
|
238
|
+
const storage = memoryStorage();
|
|
239
|
+
const client = await primedClient(storage);
|
|
240
|
+
await expect(client.handleCallback('?code=CODE&state=OTHER')).to.be.rejectedWith(/verifier/);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('[OAL-B7] throws when the token response lacks the apiEndpoint extension', async function () {
|
|
244
|
+
const storage = memoryStorage();
|
|
245
|
+
const client = await primedClient(storage);
|
|
246
|
+
const body = tokenBody();
|
|
247
|
+
delete body.apiEndpoint;
|
|
248
|
+
fetchMock.tokenResponse = jsonResponse(body);
|
|
249
|
+
await expect(client.handleCallback('?code=CODE&state=S')).to.be.rejectedWith(/apiEndpoint/);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('[OAL-B8] removes the stored verifier even when the exchange fails', async function () {
|
|
253
|
+
const storage = memoryStorage();
|
|
254
|
+
const client = await primedClient(storage);
|
|
255
|
+
const body = tokenBody();
|
|
256
|
+
delete body.apiEndpoint; // force _connectionFromTokenResponse to throw
|
|
257
|
+
fetchMock.tokenResponse = jsonResponse(body);
|
|
258
|
+
await expect(client.handleCallback('?code=CODE&state=S')).to.be.rejected;
|
|
259
|
+
expect(storage.getItem('pryv-oauth2-verifier:S')).to.equal(null);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('[OAL-B9] refuses an insecure (non-https, non-loopback) apiEndpoint', async function () {
|
|
263
|
+
const storage = memoryStorage();
|
|
264
|
+
const client = await primedClient(storage);
|
|
265
|
+
fetchMock.tokenResponse = jsonResponse(tokenBody({ apiEndpoint: 'http://TOKEN123@evil.example/path/' }));
|
|
266
|
+
await expect(client.handleCallback('?code=CODE&state=S')).to.be.rejectedWith(/insecure|https/);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('[OAL-B10] allows an http apiEndpoint on a loopback host (local dev)', async function () {
|
|
270
|
+
const storage = memoryStorage();
|
|
271
|
+
const client = await primedClient(storage);
|
|
272
|
+
fetchMock.tokenResponse = jsonResponse(tokenBody({ apiEndpoint: 'http://TOKEN123@127.0.0.1:3000/path/' }));
|
|
273
|
+
const connection = await client.handleCallback('?code=CODE&state=S');
|
|
274
|
+
expect(connection.token).to.equal('TOKEN123');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('[OAL-B11] refuses a loopback-spoofing apiEndpoint whose real endpoint is a remote http host', async function () {
|
|
278
|
+
// Connection splits token/endpoint on the LAST `@`, so this parses as
|
|
279
|
+
// token `tok@127.0.0.1/x`, endpoint `http://evil.example/` — the token
|
|
280
|
+
// would go to evil.example in cleartext. The guard must validate the
|
|
281
|
+
// extracted endpoint, not the loopback-looking raw string.
|
|
282
|
+
const storage = memoryStorage();
|
|
283
|
+
const client = await primedClient(storage);
|
|
284
|
+
fetchMock.tokenResponse = jsonResponse(tokenBody({ apiEndpoint: 'http://tok@127.0.0.1/x@evil.example/' }));
|
|
285
|
+
await expect(client.handleCallback('?code=CODE&state=S')).to.be.rejectedWith(/insecure|https/);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe('[OAL-RF] refresh', function () {
|
|
290
|
+
it('[OAL-R1] throws before any successful callback', async function () {
|
|
291
|
+
const storage = memoryStorage();
|
|
292
|
+
const client = newClient(storage);
|
|
293
|
+
await expect(client.refresh()).to.be.rejectedWith(/refresh token/);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('[OAL-R2] exchanges the stored refresh token for a fresh Connection', async function () {
|
|
297
|
+
const storage = memoryStorage();
|
|
298
|
+
const client = newClient(storage);
|
|
299
|
+
await client.redirectToAuthorize({ state: 'S', redirect: () => {} });
|
|
300
|
+
await client.handleCallback('?code=CODE&state=S');
|
|
301
|
+
fetchMock.tokenResponse = jsonResponse(tokenBody({
|
|
302
|
+
access_token: 'TOKEN789',
|
|
303
|
+
apiEndpoint: 'https://TOKEN789@host/path/'
|
|
304
|
+
}));
|
|
305
|
+
const connection = await client.refresh();
|
|
306
|
+
expect(connection.token).to.equal('TOKEN789');
|
|
307
|
+
const body = new URLSearchParams(fetchMock.lastTokenRequest.body);
|
|
308
|
+
expect(body.get('grant_type')).to.equal('refresh_token');
|
|
309
|
+
expect(body.get('refresh_token')).to.equal('REFRESH456');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
async function primed (storage, overrides = {}) {
|
|
313
|
+
const client = newClient(storage, overrides);
|
|
314
|
+
await client.redirectToAuthorize({ state: 'S', redirect: () => {} });
|
|
315
|
+
await client.handleCallback('?code=CODE&state=S');
|
|
316
|
+
return client;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
it('[OAL-R3-F1] serializes concurrent refresh() onto ONE token request', async function () {
|
|
320
|
+
const client = await primed(memoryStorage());
|
|
321
|
+
fetchMock.tokenResponse = jsonResponse(tokenBody({ access_token: 'T2', refresh_token: 'R2', apiEndpoint: 'https://T2@host/p/' }));
|
|
322
|
+
fetchMock.tokenRequestCount = 0;
|
|
323
|
+
const [c1, c2] = await Promise.all([client.refresh(), client.refresh()]);
|
|
324
|
+
expect(fetchMock.tokenRequestCount).to.equal(1); // deduped, no reuse loser
|
|
325
|
+
expect(c1.token).to.equal('T2');
|
|
326
|
+
expect(c2.token).to.equal('T2');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('[OAL-R4-F1] a new refresh() after the in-flight one settles issues a fresh request', async function () {
|
|
330
|
+
const client = await primed(memoryStorage());
|
|
331
|
+
fetchMock.tokenResponse = jsonResponse(tokenBody({ refresh_token: 'R2', apiEndpoint: 'https://T2@host/p/' }));
|
|
332
|
+
await client.refresh();
|
|
333
|
+
fetchMock.tokenRequestCount = 0;
|
|
334
|
+
await client.refresh();
|
|
335
|
+
expect(fetchMock.tokenRequestCount).to.equal(1); // slot cleared, not stuck
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('[OAL-R5-F2] persists the rotated refresh token even when apiEndpoint validation throws', async function () {
|
|
339
|
+
const client = await primed(memoryStorage());
|
|
340
|
+
// Server rotates (REFRESH456 -> ROTATED1) but the response has no apiEndpoint
|
|
341
|
+
// => _connectionFromTokenResponse throws AFTER the token was ingested.
|
|
342
|
+
const bad = tokenBody({ refresh_token: 'ROTATED1' });
|
|
343
|
+
delete bad.apiEndpoint;
|
|
344
|
+
fetchMock.tokenResponse = jsonResponse(bad);
|
|
345
|
+
await expect(client.refresh()).to.be.rejectedWith(/apiEndpoint/);
|
|
346
|
+
// The old token is dead server-side; the client must now hold ROTATED1,
|
|
347
|
+
// and a subsequent refresh() must present it (not the stale REFRESH456).
|
|
348
|
+
expect(client.refreshToken).to.equal('ROTATED1');
|
|
349
|
+
fetchMock.tokenResponse = jsonResponse(tokenBody({ access_token: 'T3', refresh_token: 'R3', apiEndpoint: 'https://T3@host/p/' }));
|
|
350
|
+
const conn = await client.refresh();
|
|
351
|
+
expect(conn.token).to.equal('T3');
|
|
352
|
+
expect(new URLSearchParams(fetchMock.lastTokenRequest.body).get('refresh_token')).to.equal('ROTATED1');
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
describe('[OAL-SEAM] refresh-token persistence seam (F3)', function () {
|
|
357
|
+
it('[OAL-S1] refresh() works from a constructor-seeded refresh token (no handleCallback)', async function () {
|
|
358
|
+
const client = newClient(memoryStorage(), { refreshToken: 'SEEDED' });
|
|
359
|
+
fetchMock.tokenResponse = jsonResponse(tokenBody({ access_token: 'T2', apiEndpoint: 'https://T2@host/p/' }));
|
|
360
|
+
const conn = await client.refresh();
|
|
361
|
+
expect(conn.token).to.equal('T2');
|
|
362
|
+
expect(new URLSearchParams(fetchMock.lastTokenRequest.body).get('refresh_token')).to.equal('SEEDED');
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('[OAL-S2] the refreshToken getter reflects the rotated value', async function () {
|
|
366
|
+
const client = newClient(memoryStorage(), { refreshToken: 'SEEDED' });
|
|
367
|
+
expect(client.refreshToken).to.equal('SEEDED');
|
|
368
|
+
fetchMock.tokenResponse = jsonResponse(tokenBody({ refresh_token: 'ROTATED', apiEndpoint: 'https://T2@host/p/' }));
|
|
369
|
+
await client.refresh();
|
|
370
|
+
expect(client.refreshToken).to.equal('ROTATED');
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('[OAL-S3] onTokenRotated fires with the new token on callback and each refresh', async function () {
|
|
374
|
+
const storage = memoryStorage();
|
|
375
|
+
const rotations = [];
|
|
376
|
+
const client = newClient(storage, { onTokenRotated: (t) => rotations.push(t) });
|
|
377
|
+
await client.redirectToAuthorize({ state: 'S', redirect: () => {} });
|
|
378
|
+
await client.handleCallback('?code=CODE&state=S'); // REFRESH456
|
|
379
|
+
fetchMock.tokenResponse = jsonResponse(tokenBody({ refresh_token: 'ROTATED', apiEndpoint: 'https://T2@host/p/' }));
|
|
380
|
+
await client.refresh();
|
|
381
|
+
expect(rotations).to.deep.equal(['REFRESH456', 'ROTATED']);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('[OAL-S4] a throwing onTokenRotated never breaks the exchange', async function () {
|
|
385
|
+
const client = newClient(memoryStorage(), {
|
|
386
|
+
refreshToken: 'SEEDED',
|
|
387
|
+
onTokenRotated: () => { throw new Error('persist boom'); }
|
|
388
|
+
});
|
|
389
|
+
fetchMock.tokenResponse = jsonResponse(tokenBody({ access_token: 'T2', apiEndpoint: 'https://T2@host/p/' }));
|
|
390
|
+
const conn = await client.refresh();
|
|
391
|
+
expect(conn.token).to.equal('T2');
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
|
|
4
|
+
*/
|
|
5
|
+
/* global describe, it, expect */
|
|
6
|
+
|
|
7
|
+
// `pryv` consumes the ESM-only `oauth4webapi` package via Node's require(esm)
|
|
8
|
+
// (unflagged on Node >= 20.19 / >= 22.12 — hence the engines floor). That only
|
|
9
|
+
// works while nothing in the package's import graph uses top-level await; a
|
|
10
|
+
// future dependency upgrade that introduced TLA would throw
|
|
11
|
+
// ERR_REQUIRE_ASYNC_MODULE at require time. This guard makes such an upgrade
|
|
12
|
+
// fail here in CI instead of at consumer runtime.
|
|
13
|
+
describe('[ESMD] ESM-only deps load via require(esm)', function () {
|
|
14
|
+
it('[ESMD1] oauth4webapi requires synchronously (no top-level await in its graph)', function () {
|
|
15
|
+
const oauth = require('oauth4webapi');
|
|
16
|
+
expect(typeof oauth.generateRandomCodeVerifier).to.equal('function');
|
|
17
|
+
expect(typeof oauth.calculatePKCECodeChallenge).to.equal('function');
|
|
18
|
+
});
|
|
19
|
+
});
|
package/test/utils.test.js
CHANGED
|
@@ -104,6 +104,36 @@ describe('[UTLX] utils', function () {
|
|
|
104
104
|
expect(err.name).to.equal('StaleAccessIdError');
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
+
describe('[UTLN] cleanURLFromPrYvParams', function () {
|
|
108
|
+
it('[UTLNA] strips legacy capital-Y prYv* params', function () {
|
|
109
|
+
const out = pryv.utils.cleanURLFromPrYvParams(
|
|
110
|
+
'http://example.com/page?keep=1&prYvkey=abc&prYvpoll=http%3A%2F%2Fpoll&prYvstatus=ACCEPTED'
|
|
111
|
+
);
|
|
112
|
+
expect(out).to.equal('http://example.com/page?keep=1');
|
|
113
|
+
});
|
|
114
|
+
it('[UTLNB] strips modern lowercase one-shot params (pryvKey, pryvPoll)', function () {
|
|
115
|
+
const out = pryv.utils.cleanURLFromPrYvParams(
|
|
116
|
+
'http://example.com/page?keep=1&pryvKey=abc&pryvPoll=http%3A%2F%2Fpoll'
|
|
117
|
+
);
|
|
118
|
+
expect(out).to.equal('http://example.com/page?keep=1');
|
|
119
|
+
});
|
|
120
|
+
it('[UTLNC] PRESERVES long-lived modern params (pryvServiceInfoUrl, pryvApiEndpoint)', function () {
|
|
121
|
+
const out = pryv.utils.cleanURLFromPrYvParams(
|
|
122
|
+
'http://example.com/page?pryvServiceInfoUrl=https%3A%2F%2Freg.pryv.me%2Fservice%2Finfo&pryvApiEndpoint=https%3A%2F%2Fuser.pryv.me%2F&pryvKey=abc'
|
|
123
|
+
);
|
|
124
|
+
// pryvKey is stripped (one-shot), the other two stay (long-lived).
|
|
125
|
+
expect(out).to.contain('pryvServiceInfoUrl=');
|
|
126
|
+
expect(out).to.contain('pryvApiEndpoint=');
|
|
127
|
+
expect(out).not.to.contain('pryvKey=');
|
|
128
|
+
});
|
|
129
|
+
it('[UTLND] strips both forms in a single URL', function () {
|
|
130
|
+
const out = pryv.utils.cleanURLFromPrYvParams(
|
|
131
|
+
'http://example.com/page?keep=1&prYvkey=legacy&pryvKey=modern&prYvpoll=oldpoll&pryvPoll=newpoll'
|
|
132
|
+
);
|
|
133
|
+
expect(out).to.equal('http://example.com/page?keep=1');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
107
137
|
describe('[UTLM] decomposeAPIEndpoint', function () {
|
|
108
138
|
it('[UTLMA] subdomain template — host strips the {username}. prefix', function () {
|
|
109
139
|
const r = pryv.utils.decomposeAPIEndpoint(
|