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