pryv 3.8.0 → 3.10.0

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