@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.
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The seam between the two ways an app can hold a user's grant.
3
+ *
4
+ * Everything around it is shared: the authorize leg, the callback, and
5
+ * the calls made with the grant. A credential answers only what differs:
6
+ * what to keep when a user connects, what to send on a call, and how to
7
+ * read One refusing it.
8
+ *
9
+ * - key mode (`./key`): the app's connect key plus a permanent id for
10
+ * the user. Nothing expires, so nothing is refreshed.
11
+ * - token mode (`./token`): an access token and a refresh token per
12
+ * user, rotated before they expire.
13
+ */
14
+ import type { OneConnectError } from "./types";
15
+
16
+ /** What One's token endpoint answers on success. `connect_user_id` is
17
+ * present whenever a durable grant backs the token. */
18
+ export interface TokenResponse {
19
+ access_token: string;
20
+ refresh_token: string;
21
+ expires_in: number;
22
+ connect_user_id?: string;
23
+ }
24
+
25
+ /** What One's token endpoint answered. A network failure throws instead. */
26
+ export type TokenAnswer =
27
+ | { ok: true; body: TokenResponse }
28
+ | { ok: false; status: number; error?: string };
29
+
30
+ export type PostToken = (body: URLSearchParams) => Promise<TokenAnswer>;
31
+
32
+ export interface Credential {
33
+ /** The callback exchanged the code: keep what this mode needs. */
34
+ connected: (userId: string, response: TokenResponse) => Promise<void>;
35
+ /** The headers that make a /v1 call act for this user. Throws
36
+ * `not_connected` when the user has nothing stored. */
37
+ headers: (userId: string) => Promise<Record<string, string>>;
38
+ isConnected: (userId: string) => Promise<boolean>;
39
+ /** Drops the app's copy. The user revokes the grant itself in One. */
40
+ disconnect: (userId: string) => Promise<void>;
41
+ /** One refused the credential itself, rather than the call it carried:
42
+ * the error to throw, or null when this answer is not that. */
43
+ refusal: (status: number, body: string) => OneConnectError | null;
44
+ }
@@ -1,9 +1,17 @@
1
1
  /**
2
2
  * `@withone/connect/server`: the half of One Connect that runs on the
3
- * app's server. It starts the flow, completes it, keeps the tokens fresh
4
- * and makes every call with the grant. The client secret never leaves
5
- * here.
3
+ * app's server. It starts the flow, completes it, and makes every call
4
+ * with the grant. The app's secrets never leave here.
6
5
  *
6
+ * An app holds a user's grant in one of two ways, and picks by what it
7
+ * configures. Everything else is the same in both.
8
+ *
9
+ * // key mode (default): one connect key for the app, one permanent id
10
+ * // per user, nothing to refresh
11
+ * const oneConnect = createOneConnect({ clientId, clientSecret,
12
+ * redirectUri, permissionSet, connectKey, userStore });
13
+ *
14
+ * // token mode: an access and a refresh token per user, kept fresh
7
15
  * const oneConnect = createOneConnect({ clientId, clientSecret,
8
16
  * redirectUri, permissionSet, tokenStore });
9
17
  *
@@ -16,33 +24,30 @@
16
24
  * const reply = await oneConnect.runAction(userId, { connectionKey, actionId, method, path });
17
25
  *
18
26
  * The Next.js and Node adapters turn the first two into route handlers.
19
- *
20
- * Refresh. One's access token lives an hour by default, its refresh token 30
21
- * days; every refresh rotates both, and One treats a second use of a
22
- * rotated refresh token as theft and revokes the whole grant. So the
23
- * client refreshes one user at a time (in this process always, across
24
- * processes through `tokenStore.withLock`), re-reads the store before
25
- * spending a refresh token, clears tokens only when One declares the
26
- * grant dead, and never lets a failing old pair delete a newer one.
27
+ * See `./key` and `./token` for what each mode stores and sends.
27
28
  */
28
29
  import { DEFAULT_ONE_API_URL, RETURN_ERROR_PARAM, RETURN_STATUS_PARAM } from "../constants";
30
+ import type { Credential, PostToken, TokenResponse } from "./credential";
31
+ import { createKeyCredential } from "./key";
29
32
  import {
30
33
  DEFAULT_SCOPES,
31
34
  basicAuthorization,
32
35
  createPkceVerifier,
33
36
  createState,
34
37
  pkceChallenge,
35
- refreshTokenExpiresAt,
36
- tenancyHeaders,
37
38
  txCookie,
38
39
  txCookieName,
39
40
  } from "./oauth";
41
+ import { createTokenCredential } from "./token";
40
42
  import {
41
43
  OneConnectError,
42
44
  type CompleteAuthorizationInput,
43
45
  type CompleteAuthorizationResult,
44
46
  type ConnectFailureCode,
47
+ type OneConnectKeyConfig,
48
+ type OneConnectMode,
45
49
  type OneConnectServerConfig,
50
+ type OneConnectTokenConfig,
46
51
  type OneConnectTokens,
47
52
  type PlatformAction,
48
53
  type ReachableConnection,
@@ -54,60 +59,31 @@ import {
54
59
  } from "./types";
55
60
 
56
61
  export * from "./types";
62
+ export { encodeUserReference, parseUserReference } from "./key";
57
63
  export { refreshTokenExpiresAt, tenancyHeaders, tokenScopes } from "./oauth";
58
64
 
59
- /** Refresh this long before expiry, so a call never races the clock. */
60
- const REFRESH_MARGIN_MS = 60_000;
61
65
  const CATALOG_PAGE_SIZE = 100;
62
66
  const CATALOG_MAX_PAGES = 20;
63
67
 
64
- interface TokenResponse {
65
- access_token: string;
66
- refresh_token: string;
67
- expires_in: number;
68
- }
69
-
70
- /** What One's token endpoint answered. A network failure throws instead. */
71
- type TokenAnswer =
72
- | { ok: true; body: TokenResponse }
73
- | { ok: false; status: number; error?: string };
74
-
75
- /** The one refusal that means the grant is gone for good: revoked by the
76
- * user, expired, or burned by a reused refresh token (RFC 6749 §5.2). */
77
- const isDeadGrant = (answer: TokenAnswer): boolean =>
78
- !answer.ok && answer.status === 400 && answer.error === "invalid_grant";
79
-
80
- export interface OneConnect {
68
+ /** What an app does with One in either mode. */
69
+ export interface OneConnectClient {
70
+ /** How this client holds each user's grant. */
71
+ readonly mode: OneConnectMode;
81
72
  /** The authorize leg: where to send the browser and the cookie to set. */
82
73
  startAuthorization: (
83
74
  input?: StartAuthorizationInput,
84
75
  ) => StartAuthorizationResult;
85
- /** The callback leg: verifies state, exchanges the code, stores the
86
- * tokens, and says where to send the browser next. Never throws for
87
- * a failed flow; read `outcome`. */
76
+ /** The callback leg: verifies state, exchanges the code, stores what
77
+ * the mode keeps, and says where to send the browser next. Never
78
+ * throws for a failed flow; read `outcome`. */
88
79
  completeAuthorization: (
89
80
  input: CompleteAuthorizationInput,
90
81
  ) => Promise<CompleteAuthorizationResult>;
91
- /** Whether the user has tokens stored. */
82
+ /** Whether the app has something stored for the user. In key mode One
83
+ * confirms the consent on each call, so a user who revoked still
84
+ * reads as connected here until a call throws `reconnect_required`. */
92
85
  isConnected: (userId: string) => Promise<boolean>;
93
- /** A live access token, refreshed first when it is about to expire. */
94
- getAccessToken: (userId: string) => Promise<string>;
95
- /** The stored tokens, for display. Null when not connected. */
96
- getTokens: (userId: string) => Promise<OneConnectTokens | null>;
97
- /** Refreshes now and returns the new pair. When another caller rotated
98
- * the pair a moment earlier, returns that pair instead of rotating it
99
- * a second time. */
100
- refreshTokens: (userId: string) => Promise<OneConnectTokens>;
101
- /** Refreshes only when the access token or the refresh token expires
102
- * within `withinMs`, and returns the pair that is current afterwards.
103
- * For background jobs: a frequent run keeps access tokens warm, and a
104
- * daily run with a window of days keeps idle users' 30-day refresh
105
- * tokens from running out. */
106
- refreshIfExpiring: (
107
- userId: string,
108
- options?: RefreshIfExpiringOptions,
109
- ) => Promise<OneConnectTokens>;
110
- /** Drops the app's copy of the tokens. The user revokes the grant
86
+ /** Drops the app's copy of what it stored. The user revokes the grant
111
87
  * itself from their One dashboard. */
112
88
  disconnect: (userId: string) => Promise<void>;
113
89
  /** The connections the grant reaches, each with its access. */
@@ -125,18 +101,80 @@ export interface OneConnect {
125
101
  ) => Promise<Response>;
126
102
  }
127
103
 
128
- export function createOneConnect(config: OneConnectServerConfig): OneConnect {
104
+ /** The client key mode returns. */
105
+ export interface OneConnectKeyClient extends OneConnectClient {
106
+ readonly mode: "key";
107
+ /** One's permanent id for this user (`cu_…`), or null when they have
108
+ * never connected. */
109
+ getConnectUserId: (userId: string) => Promise<string | null>;
110
+ }
111
+
112
+ /** The client token mode returns. */
113
+ export interface OneConnect extends OneConnectClient {
114
+ readonly mode: "token";
115
+ /** A live access token, refreshed first when it is about to expire. */
116
+ getAccessToken: (userId: string) => Promise<string>;
117
+ /** The stored tokens, for display. Null when not connected. */
118
+ getTokens: (userId: string) => Promise<OneConnectTokens | null>;
119
+ /** Refreshes now and returns the new pair. When another caller rotated
120
+ * the pair a moment earlier, returns that pair instead of rotating it
121
+ * a second time. */
122
+ refreshTokens: (userId: string) => Promise<OneConnectTokens>;
123
+ /** Refreshes only when the access token or the refresh token expires
124
+ * within `withinMs`, and returns the pair that is current afterwards.
125
+ * For the app's scheduled job: a daily run with a window of days
126
+ * renews each user's pair before the 30-day refresh token runs out. */
127
+ refreshIfExpiring: (
128
+ userId: string,
129
+ options?: RefreshIfExpiringOptions,
130
+ ) => Promise<OneConnectTokens>;
131
+ }
132
+
133
+ /**
134
+ * Which mode a config asks for. A stated `mode` wins; otherwise a
135
+ * connect key means key mode and a token store alone means token mode,
136
+ * so an app written before key mode existed keeps working untouched.
137
+ */
138
+ function resolveMode(config: OneConnectServerConfig): OneConnectMode {
139
+ const given = config as Partial<OneConnectKeyConfig> & Partial<OneConnectTokenConfig>;
140
+ const mode: OneConnectMode | undefined =
141
+ given.mode ?? (given.connectKey ? "key" : given.tokenStore ? "token" : undefined);
142
+ if (mode === undefined) {
143
+ throw new TypeError(
144
+ "createOneConnect needs a credential: pass `connectKey` and `userStore` (key mode, recommended), or `tokenStore` (token mode).",
145
+ );
146
+ }
147
+ if (mode !== "key" && mode !== "token") {
148
+ throw new TypeError('createOneConnect: `mode` is "key" or "token".');
149
+ }
150
+ if (mode === "key" && !(given.connectKey && given.userStore)) {
151
+ throw new TypeError(
152
+ "createOneConnect: key mode needs `connectKey` (mint one on the app's page in the One dashboard) and `userStore`.",
153
+ );
154
+ }
155
+ if (mode === "token" && !given.tokenStore) {
156
+ throw new TypeError("createOneConnect: token mode needs `tokenStore`.");
157
+ }
158
+ return mode;
159
+ }
160
+
161
+ /** Key mode: a connect key for the app and one permanent id per user. */
162
+ export function createOneConnect(config: OneConnectKeyConfig): OneConnectKeyClient;
163
+ /** Token mode: an access and a refresh token per user, kept fresh. */
164
+ export function createOneConnect(config: OneConnectTokenConfig): OneConnect;
165
+ export function createOneConnect(
166
+ config: OneConnectServerConfig,
167
+ ): OneConnectKeyClient | OneConnect;
168
+ export function createOneConnect(
169
+ config: OneConnectServerConfig,
170
+ ): OneConnectKeyClient | OneConnect {
171
+ const mode = resolveMode(config);
129
172
  const oneApiUrl = (config.oneApiUrl ?? DEFAULT_ONE_API_URL).replace(/\/+$/, "");
130
173
  const authorizeUrl = `${oneApiUrl}/oauth/authorize`;
131
174
  const tokenUrl = `${oneApiUrl}/oauth/token`;
132
175
  const apiUrl = `${oneApiUrl}/v1`;
133
176
  const returnTo = config.returnTo ?? "/";
134
177
  const scopes = config.scopes ?? DEFAULT_SCOPES;
135
- const { tokenStore } = config;
136
-
137
- /** One refresh in flight per user: two concurrent refreshes with the
138
- * same refresh token trip One's reuse detection. */
139
- const refreshing = new Map<string, Promise<OneConnectTokens>>();
140
178
 
141
179
  const returnUrl = (status: "success" | "error", code?: ConnectFailureCode): string => {
142
180
  const url = new URL(returnTo, config.redirectUri);
@@ -145,7 +183,7 @@ export function createOneConnect(config: OneConnectServerConfig): OneConnect {
145
183
  return url.toString();
146
184
  };
147
185
 
148
- const postToken = async (body: URLSearchParams): Promise<TokenAnswer> => {
186
+ const postToken: PostToken = async (body) => {
149
187
  const response = await fetch(tokenUrl, {
150
188
  method: "POST",
151
189
  headers: {
@@ -178,26 +216,20 @@ export function createOneConnect(config: OneConnectServerConfig): OneConnect {
178
216
  return answer.body;
179
217
  };
180
218
 
181
- /** Runs `run` under the app's cross-process lock for this user, when
182
- * the store has one. */
183
- const locked = <T>(userId: string, run: () => Promise<T>): Promise<T> =>
184
- tokenStore.withLock ? tokenStore.withLock(userId, run) : run();
185
-
186
- /** Whether either token of the pair stops working within `withinMs`. */
187
- const expiresWithin = (tokens: OneConnectTokens, withinMs: number): boolean => {
188
- const horizon = Date.now() + withinMs;
189
- const refreshExpiresAt = refreshTokenExpiresAt(tokens.refreshToken);
190
- return (
191
- tokens.expiresAt <= horizon ||
192
- (refreshExpiresAt !== null && refreshExpiresAt <= horizon)
193
- );
194
- };
195
-
196
- const toTokens = (response: TokenResponse): OneConnectTokens => ({
197
- accessToken: response.access_token,
198
- refreshToken: response.refresh_token,
199
- expiresAt: Date.now() + response.expires_in * 1000,
200
- });
219
+ // The one thing the modes differ in: what is kept per user, and what
220
+ // is sent on a call. `resolveMode` already checked each has its parts.
221
+ const keyCredential =
222
+ mode === "key"
223
+ ? createKeyCredential(
224
+ (config as OneConnectKeyConfig).connectKey,
225
+ (config as OneConnectKeyConfig).userStore,
226
+ )
227
+ : null;
228
+ const tokenCredential =
229
+ mode === "token"
230
+ ? createTokenCredential((config as OneConnectTokenConfig).tokenStore, postToken)
231
+ : null;
232
+ const credential = (keyCredential ?? tokenCredential) as Credential;
201
233
 
202
234
  const startAuthorization = (
203
235
  input: StartAuthorizationInput = {},
@@ -252,26 +284,25 @@ export function createOneConnect(config: OneConnectServerConfig): OneConnect {
252
284
  return fail("expired", "The attempt expired, or its state cookie was missing.");
253
285
 
254
286
  try {
255
- const tokens = toTokens(
256
- await exchange(
257
- new URLSearchParams({
258
- grant_type: "authorization_code",
259
- code,
260
- redirect_uri: config.redirectUri,
261
- code_verifier: verifier,
262
- }),
263
- ),
287
+ const response = await exchange(
288
+ new URLSearchParams({
289
+ grant_type: "authorization_code",
290
+ code,
291
+ redirect_uri: config.redirectUri,
292
+ code_verifier: verifier,
293
+ }),
264
294
  );
265
- // Under the lock, so a refresh in flight on another server cannot
266
- // interleave with this save.
267
- await locked(input.userId, () => tokenStore.saveTokens(input.userId, tokens));
295
+ // Tokens in token mode, the user's permanent id in key mode.
296
+ await credential.connected(input.userId, response);
268
297
  } catch (error) {
269
298
  const status = error instanceof OneConnectError ? error.status : undefined;
270
299
  return fail(
271
300
  "failed",
272
301
  status
273
302
  ? `One rejected the code exchange (HTTP ${status}).`
274
- : "One could not be reached to complete the connection.",
303
+ : error instanceof OneConnectError
304
+ ? error.message
305
+ : "One could not be reached to complete the connection.",
275
306
  );
276
307
  }
277
308
 
@@ -282,132 +313,13 @@ export function createOneConnect(config: OneConnectServerConfig): OneConnect {
282
313
  };
283
314
  };
284
315
 
285
- const notConnected = () =>
286
- new OneConnectError("not_connected", "This user is not connected.");
287
-
288
- /**
289
- * The grant behind `failed` is dead. Clears it, unless a newer pair
290
- * landed while it was failing (a reconnect's callback, another
291
- * server's refresh): that pair is returned instead, because the user
292
- * did nothing wrong and deleting it would disconnect them.
293
- */
294
- const retire = async (
295
- userId: string,
296
- failed: OneConnectTokens,
297
- status?: number,
298
- ): Promise<OneConnectTokens> => {
299
- const latest = await tokenStore.loadTokens(userId);
300
- if (latest && latest.refreshToken !== failed.refreshToken) return latest;
301
- await tokenStore.clearTokens(userId, failed);
302
- throw new OneConnectError(
303
- "refresh_failed",
304
- "The connection to One has expired or was revoked. Ask the user to connect again.",
305
- status,
306
- );
307
- };
308
-
309
- /**
310
- * The one place a refresh token is spent. Under the app's lock it
311
- * re-reads the store, and refreshes only when `stillNeeded` says the
312
- * stored pair still needs it: another process may have refreshed while
313
- * this one waited, and spending the same refresh token twice makes One
314
- * revoke the grant.
315
- */
316
- const refreshUnderLock = (
317
- userId: string,
318
- stillNeeded: (current: OneConnectTokens) => boolean,
319
- ): Promise<OneConnectTokens> =>
320
- 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())
329
- return retire(userId, current);
330
-
331
- let answer: TokenAnswer;
332
- try {
333
- answer = await postToken(
334
- new URLSearchParams({
335
- grant_type: "refresh_token",
336
- refresh_token: current.refreshToken,
337
- }),
338
- );
339
- } catch {
340
- throw new OneConnectError(
341
- "request_failed",
342
- "One could not be reached to refresh the connection. The tokens were kept; try again.",
343
- );
344
- }
345
-
346
- if (answer.ok) {
347
- // Both tokens: One rotates the pair on every refresh.
348
- const next = toTokens(answer.body);
349
- await tokenStore.saveTokens(userId, next);
350
- return next;
351
- }
352
- if (isDeadGrant(answer)) return retire(userId, current, answer.status);
353
- // A server error, a rate limit, a misconfigured secret: nothing says
354
- // the grant is gone, so keep the tokens and let the caller retry.
355
- throw new OneConnectError(
356
- "request_failed",
357
- `One could not refresh the connection (HTTP ${answer.status}). The tokens were kept; try again.`,
358
- answer.status,
359
- );
360
- });
361
-
362
- /** One refresh per user in this process; concurrent callers share it. */
363
- const singleFlight = (
364
- userId: string,
365
- job: () => Promise<OneConnectTokens>,
366
- ): Promise<OneConnectTokens> => {
367
- const inFlight = refreshing.get(userId);
368
- if (inFlight) return inFlight;
369
- const running = job().finally(() => refreshing.delete(userId));
370
- refreshing.set(userId, running);
371
- return running;
372
- };
373
-
374
- const refreshTokens = (userId: string): Promise<OneConnectTokens> =>
375
- singleFlight(userId, async () => {
376
- const before = await tokenStore.loadTokens(userId);
377
- if (!before) throw notConnected();
378
- // Rotate the pair seen now; a pair someone else rotated since is
379
- // already fresh.
380
- return refreshUnderLock(
381
- userId,
382
- (current) => current.refreshToken === before.refreshToken,
383
- );
384
- });
385
-
386
- const refreshIfExpiring = async (
387
- userId: string,
388
- options: RefreshIfExpiringOptions = {},
389
- ): Promise<OneConnectTokens> => {
390
- const withinMs = options.withinMs ?? REFRESH_MARGIN_MS;
391
- const tokens = await tokenStore.loadTokens(userId);
392
- if (!tokens) throw notConnected();
393
- if (!expiresWithin(tokens, withinMs)) return tokens;
394
- return singleFlight(userId, () =>
395
- refreshUnderLock(userId, (current) => expiresWithin(current, withinMs)),
396
- );
397
- };
398
-
399
- const getAccessToken = async (userId: string): Promise<string> =>
400
- (await refreshIfExpiring(userId)).accessToken;
401
-
402
316
  const oneFetch = async (
403
317
  userId: string,
404
318
  path: string,
405
319
  init: RequestInit = {},
406
320
  ): Promise<Response> => {
407
- const accessToken = await getAccessToken(userId);
408
321
  const headers = new Headers(init.headers);
409
- headers.set("Authorization", `Bearer ${accessToken}`);
410
- for (const [name, value] of Object.entries(tenancyHeaders(accessToken)))
322
+ for (const [name, value] of Object.entries(await credential.headers(userId)))
411
323
  headers.set(name, value);
412
324
  return fetch(`${apiUrl}${path.startsWith("/") ? path : `/${path}`}`, {
413
325
  ...init,
@@ -415,9 +327,31 @@ export function createOneConnect(config: OneConnectServerConfig): OneConnect {
415
327
  });
416
328
  };
417
329
 
330
+ /**
331
+ * Whether One still acts for this user at all, asked of the one route
332
+ * that needs nothing but the credential. Throws when the question
333
+ * itself cannot be answered, rather than guessing.
334
+ */
335
+ const consentStands = async (userId: string, refused: OneConnectError): Promise<boolean> => {
336
+ const probe = await oneFetch(userId, "/connections/reachable?limit=1");
337
+ if (probe.ok) return true;
338
+ const verdict = credential.refusal(probe.status, await probe.text());
339
+ if (verdict?.code === refused.code) return false;
340
+ throw (
341
+ verdict ??
342
+ new OneConnectError(
343
+ "request_failed",
344
+ `One could not confirm the user's consent (HTTP ${probe.status}). Nothing stored was changed; try again.`,
345
+ probe.status,
346
+ )
347
+ );
348
+ };
349
+
418
350
  const listConnections = async (userId: string): Promise<ReachableConnection[]> => {
419
351
  const response = await oneFetch(userId, "/connections/reachable");
420
352
  if (!response.ok) {
353
+ const refused = credential.refusal(response.status, await response.text());
354
+ if (refused) throw refused;
421
355
  throw new OneConnectError(
422
356
  "request_failed",
423
357
  `One refused the connections request (HTTP ${response.status}).`,
@@ -443,6 +377,21 @@ export function createOneConnect(config: OneConnectServerConfig): OneConnect {
443
377
  }));
444
378
  const first = await oneFetch(userId, pageUrl(1));
445
379
  if (!first.ok) {
380
+ const refused = credential.refusal(first.status, await first.text());
381
+ if (refused?.code === "reconnect_required") {
382
+ // The same bare 403 is what a One API answers when its catalog
383
+ // does not take the connect key. If the consent stands, that is
384
+ // what happened, and asking the user to reconnect would not help.
385
+ if (await consentStands(userId, refused)) {
386
+ throw new OneConnectError(
387
+ "request_failed",
388
+ "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.",
389
+ first.status,
390
+ );
391
+ }
392
+ throw refused;
393
+ }
394
+ if (refused) throw refused;
446
395
  throw new OneConnectError(
447
396
  "request_failed",
448
397
  `One refused the catalog request (HTTP ${first.status}).`,
@@ -486,6 +435,20 @@ export function createOneConnect(config: OneConnectServerConfig): OneConnect {
486
435
  body: hasBody ? JSON.stringify(input.body) : undefined,
487
436
  });
488
437
  const text = await response.text();
438
+ if (!response.ok) {
439
+ const refused = credential.refusal(response.status, text);
440
+ if (refused?.code === "reconnect_required") {
441
+ // One answers a call outside the grant with the same bare 403 it
442
+ // gives a consent that is gone. Asking what the grant reaches
443
+ // tells them apart: if that still answers, the consent stands
444
+ // and it was this call One refused.
445
+ if (await consentStands(userId, refused))
446
+ return { status: response.status, ok: false, blockedByGrant: true, data: text };
447
+ throw refused;
448
+ }
449
+ // One refusing the credential is not an answer to the action.
450
+ if (refused) throw refused;
451
+ }
489
452
  let data: unknown = text;
490
453
  try {
491
454
  data = JSON.parse(text);
@@ -502,18 +465,31 @@ export function createOneConnect(config: OneConnectServerConfig): OneConnect {
502
465
  return { status: response.status, ok: response.ok, blockedByGrant, data };
503
466
  };
504
467
 
505
- return {
468
+ const shared = {
506
469
  startAuthorization,
507
470
  completeAuthorization,
508
- isConnected: async (userId) => (await tokenStore.loadTokens(userId)) !== null,
509
- getAccessToken,
510
- getTokens: (userId) => tokenStore.loadTokens(userId),
511
- refreshTokens,
512
- refreshIfExpiring,
513
- disconnect: (userId) => tokenStore.clearTokens(userId),
471
+ isConnected: credential.isConnected,
472
+ disconnect: credential.disconnect,
514
473
  listConnections,
515
474
  listActions,
516
475
  runAction,
517
476
  fetch: oneFetch,
518
477
  };
478
+
479
+ if (keyCredential) {
480
+ return {
481
+ mode: "key",
482
+ ...shared,
483
+ getConnectUserId: keyCredential.getConnectUserId,
484
+ };
485
+ }
486
+ const tokens = tokenCredential!;
487
+ return {
488
+ mode: "token",
489
+ ...shared,
490
+ getAccessToken: tokens.getAccessToken,
491
+ getTokens: tokens.getTokens,
492
+ refreshTokens: tokens.refreshTokens,
493
+ refreshIfExpiring: tokens.refreshIfExpiring,
494
+ };
519
495
  }