pryv 3.8.0 → 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 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-auth3](https://github.com/pryv/app-web-auth3)
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pryv",
3
- "version": "3.8.0",
3
+ "version": "3.8.1",
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.0.0"
25
+ "node": ">=20.19.0"
24
26
  }
25
27
  }
@@ -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;
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
@@ -232,7 +232,7 @@ const utils = module.exports = {
232
232
  */
233
233
  cleanURLFromPrYvParams: function (url) {
234
234
  // Legacy form: kept for back-compat with apps still emitting
235
- // `prYv<anything>=...` (notably app-web-auth3's close_or_redirect).
235
+ // `prYv<anything>=...` (notably app-web-user-account's close_or_redirect).
236
236
  const LEGACY = /[?#&]+prYv([^=&]+)=([^&]*)/g;
237
237
  // Modern form: an explicit allowlist of one-shot params so we never
238
238
  // wipe long-lived camelCase `pryv*` params by accident.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @license
3
+ * [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
4
+ */
5
+ /* global describe, it, beforeEach, afterEach, expect, pryv */
6
+
7
+ // Pure unit check of the Authorization header shape. The API expects the bare
8
+ // access token (optionally suffixed with a caller id) in the Authorization
9
+ // header — NOT an RFC 6750 `Bearer <token>` scheme. Older/released cores parse
10
+ // the header by splitting on the first space, so a `Bearer ` prefix would be
11
+ // taken as the token and break auth; the bare token is what every core version
12
+ // accepts. This asserts no scheme prefix is added on the GET or POST path.
13
+ describe('[CAUTH] Connection Authorization header', function () {
14
+ let originalFetch;
15
+ let captured;
16
+
17
+ beforeEach(function () {
18
+ captured = [];
19
+ originalFetch = globalThis.fetch;
20
+ globalThis.fetch = async (url, init) => {
21
+ captured.push({ url: String(url), headers: (init && init.headers) || {} });
22
+ return new Response(JSON.stringify({ meta: { serverTime: 1 } }), {
23
+ status: 200,
24
+ headers: { 'content-type': 'application/json' }
25
+ });
26
+ };
27
+ });
28
+ afterEach(function () { globalThis.fetch = originalFetch; });
29
+
30
+ it('[CAU1] GET requests send the bare token (no scheme prefix)', async function () {
31
+ const conn = new pryv.Connection('https://TESTTOKEN@user.example.com/');
32
+ await conn.get('events');
33
+ expect(captured).to.have.length(1);
34
+ expect(captured[0].headers.Authorization).to.equal('TESTTOKEN');
35
+ });
36
+
37
+ it('[CAU2] POST requests send the bare token (no scheme prefix)', async function () {
38
+ const conn = new pryv.Connection('https://TESTTOKEN@user.example.com/');
39
+ await conn.post('events', { streamIds: ['x'], type: 'note/txt', content: 'hi' });
40
+ expect(captured).to.have.length(1);
41
+ expect(captured[0].headers.Authorization).to.equal('TESTTOKEN');
42
+ });
43
+ });
@@ -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
+ });