@withone/connect 0.12.2 → 0.13.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.
@@ -98,6 +98,27 @@ function tokenScopes(accessToken) {
98
98
  * the app's server and holds the client secret.
99
99
  */
100
100
 
101
+ /**
102
+ * How the app holds a user's grant.
103
+ *
104
+ * - `"key"`: the app's connect key plus a permanent id per user. Nothing
105
+ * expires, so there is nothing to refresh. The default.
106
+ * - `"token"`: an access token and a refresh token per user, which the
107
+ * SDK keeps fresh.
108
+ */
109
+
110
+ /** Key mode: who a stored user is to One, and where their grant lives. */
111
+
112
+ /**
113
+ * Key mode: where the app keeps the one value the SDK gives it per user.
114
+ * A single string, written when the user connects and the same until
115
+ * they connect again, so one column on the app's user row is enough.
116
+ * `userId` is the app's own id for its user.
117
+ *
118
+ * It is an identifier, not a credential: it does nothing without the
119
+ * app's connect key. No lock is needed, because nothing rotates.
120
+ */
121
+
101
122
  /** What the app stores per user after the exchange. */
102
123
 
103
124
  /**
@@ -106,6 +127,24 @@ function tokenScopes(accessToken) {
106
127
  * `userId` is the app's own id for its user.
107
128
  */
108
129
 
130
+ /** What every app configures, whichever mode it uses. */
131
+
132
+ /**
133
+ * Key mode, the default: pass the app's connect key and a place to keep
134
+ * one value per user.
135
+ */
136
+
137
+ /**
138
+ * Token mode: pass a token store and the SDK keeps each user's tokens
139
+ * fresh.
140
+ */
141
+
142
+ /**
143
+ * The mode is whichever credential is configured: a `connectKey` is key
144
+ * mode, a `tokenStore` alone is token mode. Set `mode` to say so
145
+ * explicitly.
146
+ */
147
+
109
148
  /** The transaction cookie the authorize leg sets and the callback reads. */
110
149
 
111
150
  /** One connection the grant reaches, with the access it confers. */
@@ -113,11 +152,15 @@ function tokenScopes(accessToken) {
113
152
  /** One catalog action for a platform. */
114
153
 
115
154
  /**
116
- * - `not_connected`: no tokens are stored for this user.
117
- * - `refresh_failed`: One declared the grant dead (revoked, expired or
118
- * reused). The tokens were cleared; ask the user to connect again.
155
+ * - `not_connected`: nothing is stored for this user.
156
+ * - `reconnect_required` (key mode): One will not act for this user.
157
+ * Their consent was revoked, or the app is deactivated. What the app
158
+ * stored is kept; ask the user to connect again.
159
+ * - `refresh_failed` (token mode): One declared the grant dead (revoked,
160
+ * expired or reused). The tokens were cleared; ask the user to connect
161
+ * again.
119
162
  * - `request_failed`: One answered with an error or could not be
120
- * reached. During a refresh the tokens are kept, so retry later.
163
+ * reached. Nothing stored was changed, so retry later.
121
164
  */
122
165
 
123
166
  class OneConnectError extends Error {
@@ -129,12 +172,293 @@ class OneConnectError extends Error {
129
172
  }
130
173
  }
131
174
 
175
+ /**
176
+ * Key mode: the app holds one connect key, and one permanent id per
177
+ * user. Both go on every call; One reads the user's consent live each
178
+ * time. Nothing expires, so there is nothing to refresh, rotate or lock.
179
+ *
180
+ * X-One-Secret: the app's connect key
181
+ * X-One-Connect-User-Id: cu_… for the user being acted for
182
+ *
183
+ * The id comes from the one code exchange the callback makes. It is the
184
+ * same for a user for the life of the app, through revocation and
185
+ * re-consent, so it is saved once and never deleted by the SDK.
186
+ */
187
+
188
+
189
+ /** `cu_` and a hex HMAC-SHA256, as One derives it. */
190
+ const CONNECT_USER_ID = /^cu_[0-9a-f]{64}$/;
191
+ const ORGANIZATION = "org=";
192
+ const PROJECT = "project=";
193
+ const SEPARATOR = ";";
194
+ const ORGANIZATION_HEADER = "X-One-Organization-Id";
195
+ const PROJECT_HEADER = "X-One-Project-Id";
196
+
197
+ /**
198
+ * The one value an app stores per user, as a string for one column:
199
+ * `cu_…`, then the space the user granted from when it is not their
200
+ * personal one (`cu_…;org=<id>;project=<id>`).
201
+ *
202
+ * The space travels with the id because One resolves a call's tenant
203
+ * from headers: a grant made from an organization lives there, and a
204
+ * call that names none runs in the user's personal space and reaches
205
+ * nothing. In token mode the access token names the space on every
206
+ * call; here there is no token after the exchange, so it is kept.
207
+ */
208
+ function encodeUserReference(reference) {
209
+ const parts = [reference.connectUserId];
210
+ if (reference.organizationId) parts.push(`${ORGANIZATION}${reference.organizationId}`);
211
+ if (reference.projectId) parts.push(`${PROJECT}${reference.projectId}`);
212
+ return parts.join(SEPARATOR);
213
+ }
214
+
215
+ /** Reads a stored value back. Null when it is not one this SDK wrote. */
216
+ function parseUserReference(value) {
217
+ const [connectUserId, ...rest] = value.trim().split(SEPARATOR);
218
+ if (!CONNECT_USER_ID.test(connectUserId)) return null;
219
+ const reference = {
220
+ connectUserId
221
+ };
222
+ for (const part of rest) {
223
+ if (part.startsWith(ORGANIZATION)) reference.organizationId = part.slice(ORGANIZATION.length);else if (part.startsWith(PROJECT)) reference.projectId = part.slice(PROJECT.length);
224
+ }
225
+ return reference;
226
+ }
227
+
228
+ /**
229
+ * One answers a credential it will not accept with the bare status line
230
+ * and nothing else. Anything richer came from the route or from the
231
+ * provider behind a passthrough, and is not a verdict on the credential.
232
+ */
233
+ const isBareRefusal = (status, body, reason) => body.trim() === `${status} ${reason}`;
234
+ function createKeyCredential(connectKey, userStore) {
235
+ const load = async userId => {
236
+ const stored = await userStore.loadUser(userId);
237
+ return stored ? parseUserReference(stored) : null;
238
+ };
239
+ return {
240
+ connected: async (userId, response) => {
241
+ const connectUserId = response.connect_user_id;
242
+ if (!connectUserId || !CONNECT_USER_ID.test(connectUserId)) {
243
+ throw new OneConnectError("request_failed", "One did not return a connect user id for this user, so key mode cannot act for them.");
244
+ }
245
+ // The space the user granted from, read once from the token that
246
+ // named it. The token itself is not kept.
247
+ const space = tenancyHeaders(response.access_token);
248
+ await userStore.saveUser(userId, encodeUserReference({
249
+ connectUserId,
250
+ organizationId: space[ORGANIZATION_HEADER],
251
+ projectId: space[PROJECT_HEADER]
252
+ }));
253
+ },
254
+ headers: async userId => {
255
+ const reference = await load(userId);
256
+ if (!reference) throw new OneConnectError("not_connected", "This user is not connected.");
257
+ const headers = {
258
+ "X-One-Secret": connectKey,
259
+ "X-One-Connect-User-Id": reference.connectUserId
260
+ };
261
+ if (reference.organizationId) headers[ORGANIZATION_HEADER] = reference.organizationId;
262
+ if (reference.projectId) headers[PROJECT_HEADER] = reference.projectId;
263
+ return headers;
264
+ },
265
+ isConnected: async userId => (await load(userId)) !== null,
266
+ disconnect: userId => userStore.clearUser(userId),
267
+ refusal: (status, body) => {
268
+ // One does not say which: the consent was revoked, was never given
269
+ // in this key's environment, or the app is deactivated. The saved id
270
+ // is kept either way, because it is the same id after a reconnect.
271
+ if (status === 403 && isBareRefusal(status, body, "Forbidden")) {
272
+ return new OneConnectError("reconnect_required", "One will not act for this user: their consent was revoked, or the app is deactivated. Ask the user to connect again. If every user fails, check that the connect key is for this environment and that the app is active.", status);
273
+ }
274
+ if (status === 401 && isBareRefusal(status, body, "Unauthorized")) {
275
+ return new OneConnectError("request_failed", "One did not accept the connect key. Check that it is this app's connect key, minted on the app's page in the One dashboard.", status);
276
+ }
277
+ return null;
278
+ },
279
+ getConnectUserId: async userId => (await load(userId))?.connectUserId ?? null
280
+ };
281
+ }
282
+
283
+ /**
284
+ * Token mode: the app holds an access token and a refresh token per
285
+ * user, and the SDK refreshes them with One.
286
+ *
287
+ * Lifetimes. The access token lives as long as the app's Token lifetime
288
+ * setting says (dashboard, Advanced, when creating or editing the app: 7
289
+ * days, 30 days, 90 days or 1 year; 30 days unless changed). An app that
290
+ * never had the setting gets an hour. The refresh token always lives 30
291
+ * days, whatever the access token's lifetime, and only a live refresh
292
+ * token can buy a new pair.
293
+ *
294
+ * Who refreshes. Before each call the client refreshes a pair that is
295
+ * within a minute of expiring. Refreshing earlier than that is the app's
296
+ * job: it runs `refreshIfExpiring` on a schedule, so the refresh token
297
+ * never runs out. When it has run out, the access token is used until it
298
+ * expires too, and only then is the user asked to connect again.
299
+ *
300
+ * Rotation. Every refresh rotates both tokens, and One treats a second
301
+ * use of a rotated refresh token as theft and revokes the whole grant.
302
+ * So the client refreshes one user at a time (in this process always,
303
+ * across processes through `tokenStore.withLock`), re-reads the store
304
+ * before spending a refresh token, clears tokens only when the grant is
305
+ * dead, and never lets a failing old pair delete a newer one.
306
+ */
307
+
308
+
309
+ /** Refresh this long before expiry, so a call never races the clock. */
310
+ const REFRESH_MARGIN_MS = 60_000;
311
+
312
+ /** The one refusal that means the grant is gone for good: revoked by the
313
+ * user, expired, or burned by a reused refresh token (RFC 6749 §5.2). */
314
+ const isDeadGrant = answer => !answer.ok && answer.status === 400 && answer.error === "invalid_grant";
315
+ function createTokenCredential(tokenStore, postToken) {
316
+ /** One refresh in flight per user: two concurrent refreshes with the
317
+ * same refresh token trip One's reuse detection. */
318
+ const refreshing = new Map();
319
+
320
+ /** Runs `run` under the app's cross-process lock for this user, when
321
+ * the store has one. */
322
+ const locked = (userId, run) => tokenStore.withLock ? tokenStore.withLock(userId, run) : run();
323
+
324
+ /** Whether either token of the pair stops working within `withinMs`. */
325
+ const expiresWithin = (tokens, withinMs) => {
326
+ const horizon = Date.now() + withinMs;
327
+ const refreshExpiresAt = refreshTokenExpiresAt(tokens.refreshToken);
328
+ return tokens.expiresAt <= horizon || refreshExpiresAt !== null && refreshExpiresAt <= horizon;
329
+ };
330
+
331
+ /** Whether the access token is too close to expiry to make a call with. */
332
+ const accessEnding = tokens => tokens.expiresAt <= Date.now() + REFRESH_MARGIN_MS;
333
+
334
+ /** Whether the refresh token has run out. One with no readable expiry
335
+ * counts as live: only One can say otherwise. */
336
+ const refreshSpent = tokens => {
337
+ const refreshExpiresAt = refreshTokenExpiresAt(tokens.refreshToken);
338
+ return refreshExpiresAt !== null && refreshExpiresAt <= Date.now();
339
+ };
340
+ const toTokens = response => ({
341
+ accessToken: response.access_token,
342
+ refreshToken: response.refresh_token,
343
+ expiresAt: Date.now() + response.expires_in * 1000
344
+ });
345
+ const notConnected = () => new OneConnectError("not_connected", "This user is not connected.");
346
+
347
+ /**
348
+ * The grant behind `failed` is dead. Clears it, unless a newer pair
349
+ * landed while it was failing (a reconnect's callback, another
350
+ * server's refresh): that pair is returned instead, because the user
351
+ * did nothing wrong and deleting it would disconnect them.
352
+ */
353
+ const retire = async (userId, failed, status) => {
354
+ const latest = await tokenStore.loadTokens(userId);
355
+ if (latest && latest.refreshToken !== failed.refreshToken) return latest;
356
+ await tokenStore.clearTokens(userId, failed);
357
+ throw new OneConnectError("refresh_failed", "The connection to One has expired or was revoked. Ask the user to connect again.", status);
358
+ };
359
+
360
+ /**
361
+ * The one place a refresh token is spent. Under the app's lock it
362
+ * re-reads the store, and refreshes only when `stillNeeded` says the
363
+ * stored pair still needs it: another process may have refreshed while
364
+ * this one waited, and spending the same refresh token twice makes One
365
+ * revoke the grant.
366
+ */
367
+ const refreshUnderLock = (userId, stillNeeded) => locked(userId, async () => {
368
+ const current = await tokenStore.loadTokens(userId);
369
+ if (!current) throw notConnected();
370
+ if (!stillNeeded(current)) return current;
371
+
372
+ // An expired refresh token cannot work, and One answers one with a
373
+ // server error rather than invalid_grant, so settle it here. The
374
+ // access token may outlive it (a 90-day or 1-year lifetime): that
375
+ // one still works, so the pair is kept until it ends too.
376
+ if (refreshSpent(current)) return accessEnding(current) ? retire(userId, current) : current;
377
+ let answer;
378
+ try {
379
+ answer = await postToken(new URLSearchParams({
380
+ grant_type: "refresh_token",
381
+ refresh_token: current.refreshToken
382
+ }));
383
+ } catch {
384
+ throw new OneConnectError("request_failed", "One could not be reached to refresh the connection. The tokens were kept; try again.");
385
+ }
386
+ if (answer.ok) {
387
+ // Both tokens: One rotates the pair on every refresh.
388
+ const next = toTokens(answer.body);
389
+ await tokenStore.saveTokens(userId, next);
390
+ return next;
391
+ }
392
+ if (isDeadGrant(answer)) return retire(userId, current, answer.status);
393
+ // A server error, a rate limit, a misconfigured secret: nothing says
394
+ // the grant is gone, so keep the tokens and let the caller retry.
395
+ throw new OneConnectError("request_failed", `One could not refresh the connection (HTTP ${answer.status}). The tokens were kept; try again.`, answer.status);
396
+ });
397
+
398
+ /** One refresh per user in this process; concurrent callers share it. */
399
+ const singleFlight = (userId, job) => {
400
+ const inFlight = refreshing.get(userId);
401
+ if (inFlight) return inFlight;
402
+ const running = job().finally(() => refreshing.delete(userId));
403
+ refreshing.set(userId, running);
404
+ return running;
405
+ };
406
+ const refreshTokens = userId => singleFlight(userId, async () => {
407
+ const before = await tokenStore.loadTokens(userId);
408
+ if (!before) throw notConnected();
409
+ // Rotate the pair seen now; a pair someone else rotated since is
410
+ // already fresh.
411
+ return refreshUnderLock(userId, current => current.refreshToken === before.refreshToken);
412
+ });
413
+ const refreshIfExpiring = async (userId, options = {}) => {
414
+ const withinMs = options.withinMs ?? REFRESH_MARGIN_MS;
415
+ const tokens = await tokenStore.loadTokens(userId);
416
+ if (!tokens) throw notConnected();
417
+ if (!expiresWithin(tokens, withinMs)) return tokens;
418
+ // Nothing left to refresh with, and the access token still works.
419
+ if (refreshSpent(tokens) && !accessEnding(tokens)) return tokens;
420
+ return singleFlight(userId, () => refreshUnderLock(userId, current => expiresWithin(current, withinMs)));
421
+ };
422
+ const getAccessToken = async userId => (await refreshIfExpiring(userId)).accessToken;
423
+ return {
424
+ // Under the lock, so a refresh in flight on another server cannot
425
+ // interleave with this save.
426
+ connected: (userId, response) => {
427
+ const tokens = toTokens(response);
428
+ return locked(userId, () => tokenStore.saveTokens(userId, tokens));
429
+ },
430
+ headers: async userId => {
431
+ const accessToken = await getAccessToken(userId);
432
+ return {
433
+ Authorization: `Bearer ${accessToken}`,
434
+ ...tenancyHeaders(accessToken)
435
+ };
436
+ },
437
+ isConnected: async userId => (await tokenStore.loadTokens(userId)) !== null,
438
+ disconnect: userId => tokenStore.clearTokens(userId),
439
+ // A dead grant surfaces at the refresh, before any call is made.
440
+ refusal: () => null,
441
+ getAccessToken,
442
+ getTokens: userId => tokenStore.loadTokens(userId),
443
+ refreshTokens,
444
+ refreshIfExpiring
445
+ };
446
+ }
447
+
132
448
  /**
133
449
  * `@withone/connect/server`: the half of One Connect that runs on the
134
- * app's server. It starts the flow, completes it, keeps the tokens fresh
135
- * and makes every call with the grant. The client secret never leaves
136
- * here.
450
+ * app's server. It starts the flow, completes it, and makes every call
451
+ * with the grant. The app's secrets never leave here.
137
452
  *
453
+ * An app holds a user's grant in one of two ways, and picks by what it
454
+ * configures. Everything else is the same in both.
455
+ *
456
+ * // key mode (default): one connect key for the app, one permanent id
457
+ * // per user, nothing to refresh
458
+ * const oneConnect = createOneConnect({ clientId, clientSecret,
459
+ * redirectUri, permissionSet, connectKey, userStore });
460
+ *
461
+ * // token mode: an access and a refresh token per user, kept fresh
138
462
  * const oneConnect = createOneConnect({ clientId, clientSecret,
139
463
  * redirectUri, permissionSet, tokenStore });
140
464
  *
@@ -147,40 +471,52 @@ class OneConnectError extends Error {
147
471
  * const reply = await oneConnect.runAction(userId, { connectionKey, actionId, method, path });
148
472
  *
149
473
  * The Next.js and Node adapters turn the first two into route handlers.
150
- *
151
- * Refresh. One's access token lives an hour by default, its refresh token 30
152
- * days; every refresh rotates both, and One treats a second use of a
153
- * rotated refresh token as theft and revokes the whole grant. So the
154
- * client refreshes one user at a time (in this process always, across
155
- * processes through `tokenStore.withLock`), re-reads the store before
156
- * spending a refresh token, clears tokens only when One declares the
157
- * grant dead, and never lets a failing old pair delete a newer one.
474
+ * See `./key` and `./token` for what each mode stores and sends.
158
475
  */
159
-
160
- /** Refresh this long before expiry, so a call never races the clock. */
161
- const REFRESH_MARGIN_MS = 60_000;
162
476
  const CATALOG_PAGE_SIZE = 100;
163
477
  const CATALOG_MAX_PAGES = 20;
164
478
 
165
- /** What One's token endpoint answered. A network failure throws instead. */
479
+ /** What an app does with One in either mode. */
480
+
481
+ /** The client key mode returns. */
482
+
483
+ /** The client token mode returns. */
484
+
485
+ /**
486
+ * Which mode a config asks for. A stated `mode` wins; otherwise a
487
+ * connect key means key mode and a token store alone means token mode,
488
+ * so an app written before key mode existed keeps working untouched.
489
+ */
490
+ function resolveMode(config) {
491
+ const given = config;
492
+ const mode = given.mode ?? (given.connectKey ? "key" : given.tokenStore ? "token" : undefined);
493
+ if (mode === undefined) {
494
+ throw new TypeError("createOneConnect needs a credential: pass `connectKey` and `userStore` (key mode, recommended), or `tokenStore` (token mode).");
495
+ }
496
+ if (mode !== "key" && mode !== "token") {
497
+ throw new TypeError('createOneConnect: `mode` is "key" or "token".');
498
+ }
499
+ if (mode === "key" && !(given.connectKey && given.userStore)) {
500
+ throw new TypeError("createOneConnect: key mode needs `connectKey` (mint one on the app's page in the One dashboard) and `userStore`.");
501
+ }
502
+ if (mode === "token" && !given.tokenStore) {
503
+ throw new TypeError("createOneConnect: token mode needs `tokenStore`.");
504
+ }
505
+ return mode;
506
+ }
507
+
508
+ /** Key mode: a connect key for the app and one permanent id per user. */
509
+
510
+ /** Token mode: an access and a refresh token per user, kept fresh. */
166
511
 
167
- /** The one refusal that means the grant is gone for good: revoked by the
168
- * user, expired, or burned by a reused refresh token (RFC 6749 §5.2). */
169
- const isDeadGrant = answer => !answer.ok && answer.status === 400 && answer.error === "invalid_grant";
170
512
  function createOneConnect(config) {
513
+ const mode = resolveMode(config);
171
514
  const oneApiUrl = (config.oneApiUrl ?? DEFAULT_ONE_API_URL).replace(/\/+$/, "");
172
515
  const authorizeUrl = `${oneApiUrl}/oauth/authorize`;
173
516
  const tokenUrl = `${oneApiUrl}/oauth/token`;
174
517
  const apiUrl = `${oneApiUrl}/v1`;
175
518
  const returnTo = config.returnTo ?? "/";
176
519
  const scopes = config.scopes ?? DEFAULT_SCOPES;
177
- const {
178
- tokenStore
179
- } = config;
180
-
181
- /** One refresh in flight per user: two concurrent refreshes with the
182
- * same refresh token trip One's reuse detection. */
183
- const refreshing = new Map();
184
520
  const returnUrl = (status, code) => {
185
521
  const url = new URL(returnTo, config.redirectUri);
186
522
  url.searchParams.set(RETURN_STATUS_PARAM, status);
@@ -221,21 +557,11 @@ function createOneConnect(config) {
221
557
  return answer.body;
222
558
  };
223
559
 
224
- /** Runs `run` under the app's cross-process lock for this user, when
225
- * the store has one. */
226
- const locked = (userId, run) => tokenStore.withLock ? tokenStore.withLock(userId, run) : run();
227
-
228
- /** Whether either token of the pair stops working within `withinMs`. */
229
- const expiresWithin = (tokens, withinMs) => {
230
- const horizon = Date.now() + withinMs;
231
- const refreshExpiresAt = refreshTokenExpiresAt(tokens.refreshToken);
232
- return tokens.expiresAt <= horizon || refreshExpiresAt !== null && refreshExpiresAt <= horizon;
233
- };
234
- const toTokens = response => ({
235
- accessToken: response.access_token,
236
- refreshToken: response.refresh_token,
237
- expiresAt: Date.now() + response.expires_in * 1000
238
- });
560
+ // The one thing the modes differ in: what is kept per user, and what
561
+ // is sent on a call. `resolveMode` already checked each has its parts.
562
+ const keyCredential = mode === "key" ? createKeyCredential(config.connectKey, config.userStore) : null;
563
+ const tokenCredential = mode === "token" ? createTokenCredential(config.tokenStore, postToken) : null;
564
+ const credential = keyCredential ?? tokenCredential;
239
565
  const startAuthorization = (input = {}) => {
240
566
  const state = createState();
241
567
  const verifier = createPkceVerifier();
@@ -274,18 +600,17 @@ function createOneConnect(config) {
274
600
  // foreign or forged state; the code is never exchanged in that case.
275
601
  if (!code || !state || !verifier) return fail("expired", "The attempt expired, or its state cookie was missing.");
276
602
  try {
277
- const tokens = toTokens(await exchange(new URLSearchParams({
603
+ const response = await exchange(new URLSearchParams({
278
604
  grant_type: "authorization_code",
279
605
  code,
280
606
  redirect_uri: config.redirectUri,
281
607
  code_verifier: verifier
282
- })));
283
- // Under the lock, so a refresh in flight on another server cannot
284
- // interleave with this save.
285
- await locked(input.userId, () => tokenStore.saveTokens(input.userId, tokens));
608
+ }));
609
+ // Tokens in token mode, the user's permanent id in key mode.
610
+ await credential.connected(input.userId, response);
286
611
  } catch (error) {
287
612
  const status = error instanceof OneConnectError ? error.status : undefined;
288
- return fail("failed", status ? `One rejected the code exchange (HTTP ${status}).` : "One could not be reached to complete the connection.");
613
+ return fail("failed", status ? `One rejected the code exchange (HTTP ${status}).` : error instanceof OneConnectError ? error.message : "One could not be reached to complete the connection.");
289
614
  }
290
615
  return {
291
616
  outcome: "connected",
@@ -293,94 +618,32 @@ function createOneConnect(config) {
293
618
  clearCookieName: cookieName
294
619
  };
295
620
  };
296
- const notConnected = () => new OneConnectError("not_connected", "This user is not connected.");
297
-
298
- /**
299
- * The grant behind `failed` is dead. Clears it, unless a newer pair
300
- * landed while it was failing (a reconnect's callback, another
301
- * server's refresh): that pair is returned instead, because the user
302
- * did nothing wrong and deleting it would disconnect them.
303
- */
304
- const retire = async (userId, failed, status) => {
305
- const latest = await tokenStore.loadTokens(userId);
306
- if (latest && latest.refreshToken !== failed.refreshToken) return latest;
307
- await tokenStore.clearTokens(userId, failed);
308
- throw new OneConnectError("refresh_failed", "The connection to One has expired or was revoked. Ask the user to connect again.", status);
309
- };
310
-
311
- /**
312
- * The one place a refresh token is spent. Under the app's lock it
313
- * re-reads the store, and refreshes only when `stillNeeded` says the
314
- * stored pair still needs it: another process may have refreshed while
315
- * this one waited, and spending the same refresh token twice makes One
316
- * revoke the grant.
317
- */
318
- const refreshUnderLock = (userId, stillNeeded) => locked(userId, async () => {
319
- const current = await tokenStore.loadTokens(userId);
320
- if (!current) throw notConnected();
321
- if (!stillNeeded(current)) return current;
322
-
323
- // An expired refresh token cannot work, and One answers one with a
324
- // server error rather than invalid_grant, so settle it here.
325
- const refreshExpiresAt = refreshTokenExpiresAt(current.refreshToken);
326
- if (refreshExpiresAt !== null && refreshExpiresAt <= Date.now()) return retire(userId, current);
327
- let answer;
328
- try {
329
- answer = await postToken(new URLSearchParams({
330
- grant_type: "refresh_token",
331
- refresh_token: current.refreshToken
332
- }));
333
- } catch {
334
- throw new OneConnectError("request_failed", "One could not be reached to refresh the connection. The tokens were kept; try again.");
335
- }
336
- if (answer.ok) {
337
- // Both tokens: One rotates the pair on every refresh.
338
- const next = toTokens(answer.body);
339
- await tokenStore.saveTokens(userId, next);
340
- return next;
341
- }
342
- if (isDeadGrant(answer)) return retire(userId, current, answer.status);
343
- // A server error, a rate limit, a misconfigured secret: nothing says
344
- // the grant is gone, so keep the tokens and let the caller retry.
345
- throw new OneConnectError("request_failed", `One could not refresh the connection (HTTP ${answer.status}). The tokens were kept; try again.`, answer.status);
346
- });
347
-
348
- /** One refresh per user in this process; concurrent callers share it. */
349
- const singleFlight = (userId, job) => {
350
- const inFlight = refreshing.get(userId);
351
- if (inFlight) return inFlight;
352
- const running = job().finally(() => refreshing.delete(userId));
353
- refreshing.set(userId, running);
354
- return running;
355
- };
356
- const refreshTokens = userId => singleFlight(userId, async () => {
357
- const before = await tokenStore.loadTokens(userId);
358
- if (!before) throw notConnected();
359
- // Rotate the pair seen now; a pair someone else rotated since is
360
- // already fresh.
361
- return refreshUnderLock(userId, current => current.refreshToken === before.refreshToken);
362
- });
363
- const refreshIfExpiring = async (userId, options = {}) => {
364
- const withinMs = options.withinMs ?? REFRESH_MARGIN_MS;
365
- const tokens = await tokenStore.loadTokens(userId);
366
- if (!tokens) throw notConnected();
367
- if (!expiresWithin(tokens, withinMs)) return tokens;
368
- return singleFlight(userId, () => refreshUnderLock(userId, current => expiresWithin(current, withinMs)));
369
- };
370
- const getAccessToken = async userId => (await refreshIfExpiring(userId)).accessToken;
371
621
  const oneFetch = async (userId, path, init = {}) => {
372
- const accessToken = await getAccessToken(userId);
373
622
  const headers = new Headers(init.headers);
374
- headers.set("Authorization", `Bearer ${accessToken}`);
375
- for (const [name, value] of Object.entries(tenancyHeaders(accessToken))) headers.set(name, value);
623
+ for (const [name, value] of Object.entries(await credential.headers(userId))) headers.set(name, value);
376
624
  return fetch(`${apiUrl}${path.startsWith("/") ? path : `/${path}`}`, {
377
625
  ...init,
378
626
  headers
379
627
  });
380
628
  };
629
+
630
+ /**
631
+ * Whether One still acts for this user at all, asked of the one route
632
+ * that needs nothing but the credential. Throws when the question
633
+ * itself cannot be answered, rather than guessing.
634
+ */
635
+ const consentStands = async (userId, refused) => {
636
+ const probe = await oneFetch(userId, "/connections/reachable?limit=1");
637
+ if (probe.ok) return true;
638
+ const verdict = credential.refusal(probe.status, await probe.text());
639
+ if (verdict?.code === refused.code) return false;
640
+ throw verdict ?? new OneConnectError("request_failed", `One could not confirm the user's consent (HTTP ${probe.status}). Nothing stored was changed; try again.`, probe.status);
641
+ };
381
642
  const listConnections = async userId => {
382
643
  const response = await oneFetch(userId, "/connections/reachable");
383
644
  if (!response.ok) {
645
+ const refused = credential.refusal(response.status, await response.text());
646
+ if (refused) throw refused;
384
647
  throw new OneConnectError("request_failed", `One refused the connections request (HTTP ${response.status}).`, response.status);
385
648
  }
386
649
  const body = await response.json();
@@ -396,6 +659,17 @@ function createOneConnect(config) {
396
659
  }));
397
660
  const first = await oneFetch(userId, pageUrl(1));
398
661
  if (!first.ok) {
662
+ const refused = credential.refusal(first.status, await first.text());
663
+ if (refused?.code === "reconnect_required") {
664
+ // The same bare 403 is what a One API answers when its catalog
665
+ // does not take the connect key. If the consent stands, that is
666
+ // what happened, and asking the user to reconnect would not help.
667
+ if (await consentStands(userId, refused)) {
668
+ throw new OneConnectError("request_failed", "One's action catalog refused the connect key (HTTP 403) although the user's consent stands: this One API does not accept the connect key on the catalog.", first.status);
669
+ }
670
+ throw refused;
671
+ }
672
+ if (refused) throw refused;
399
673
  throw new OneConnectError("request_failed", `One refused the catalog request (HTTP ${first.status}).`, first.status);
400
674
  }
401
675
  const page1 = await first.json();
@@ -420,6 +694,24 @@ function createOneConnect(config) {
420
694
  body: hasBody ? JSON.stringify(input.body) : undefined
421
695
  });
422
696
  const text = await response.text();
697
+ if (!response.ok) {
698
+ const refused = credential.refusal(response.status, text);
699
+ if (refused?.code === "reconnect_required") {
700
+ // One answers a call outside the grant with the same bare 403 it
701
+ // gives a consent that is gone. Asking what the grant reaches
702
+ // tells them apart: if that still answers, the consent stands
703
+ // and it was this call One refused.
704
+ if (await consentStands(userId, refused)) return {
705
+ status: response.status,
706
+ ok: false,
707
+ blockedByGrant: true,
708
+ data: text
709
+ };
710
+ throw refused;
711
+ }
712
+ // One refusing the credential is not an answer to the action.
713
+ if (refused) throw refused;
714
+ }
423
715
  let data = text;
424
716
  try {
425
717
  data = JSON.parse(text);
@@ -436,20 +728,32 @@ function createOneConnect(config) {
436
728
  data
437
729
  };
438
730
  };
439
- return {
731
+ const shared = {
440
732
  startAuthorization,
441
733
  completeAuthorization,
442
- isConnected: async userId => (await tokenStore.loadTokens(userId)) !== null,
443
- getAccessToken,
444
- getTokens: userId => tokenStore.loadTokens(userId),
445
- refreshTokens,
446
- refreshIfExpiring,
447
- disconnect: userId => tokenStore.clearTokens(userId),
734
+ isConnected: credential.isConnected,
735
+ disconnect: credential.disconnect,
448
736
  listConnections,
449
737
  listActions,
450
738
  runAction,
451
739
  fetch: oneFetch
452
740
  };
741
+ if (keyCredential) {
742
+ return {
743
+ mode: "key",
744
+ ...shared,
745
+ getConnectUserId: keyCredential.getConnectUserId
746
+ };
747
+ }
748
+ const tokens = tokenCredential;
749
+ return {
750
+ mode: "token",
751
+ ...shared,
752
+ getAccessToken: tokens.getAccessToken,
753
+ getTokens: tokens.getTokens,
754
+ refreshTokens: tokens.refreshTokens,
755
+ refreshIfExpiring: tokens.refreshIfExpiring
756
+ };
453
757
  }
454
758
 
455
- export { OneConnectError, createOneConnect, refreshTokenExpiresAt, tenancyHeaders, tokenScopes };
759
+ export { OneConnectError, createOneConnect, encodeUserReference, parseUserReference, refreshTokenExpiresAt, tenancyHeaders, tokenScopes };