@withone/connect 0.9.0 → 0.11.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.
@@ -16,6 +16,14 @@
16
16
  * const reply = await oneConnect.runAction(userId, { connectionKey, actionId, method, path });
17
17
  *
18
18
  * 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.
19
27
  */
20
28
  import { DEFAULT_ONE_API_URL, RETURN_MESSAGE_PARAM, RETURN_STATUS_PARAM } from "../constants";
21
29
  import {
@@ -24,6 +32,7 @@ import {
24
32
  createPkceVerifier,
25
33
  createState,
26
34
  pkceChallenge,
35
+ refreshTokenExpiresAt,
27
36
  tenancyHeaders,
28
37
  txCookie,
29
38
  txCookieName,
@@ -36,6 +45,7 @@ import {
36
45
  type OneConnectTokens,
37
46
  type PlatformAction,
38
47
  type ReachableConnection,
48
+ type RefreshIfExpiringOptions,
39
49
  type RunActionInput,
40
50
  type RunActionResult,
41
51
  type StartAuthorizationInput,
@@ -43,7 +53,7 @@ import {
43
53
  } from "./types";
44
54
 
45
55
  export * from "./types";
46
- export { tenancyHeaders, tokenScopes } from "./oauth";
56
+ export { refreshTokenExpiresAt, tenancyHeaders, tokenScopes } from "./oauth";
47
57
 
48
58
  /** Refresh this long before expiry, so a call never races the clock. */
49
59
  const REFRESH_MARGIN_MS = 60_000;
@@ -56,6 +66,16 @@ interface TokenResponse {
56
66
  expires_in: number;
57
67
  }
58
68
 
69
+ /** What One's token endpoint answered. A network failure throws instead. */
70
+ type TokenAnswer =
71
+ | { ok: true; body: TokenResponse }
72
+ | { ok: false; status: number; error?: string };
73
+
74
+ /** The one refusal that means the grant is gone for good: revoked by the
75
+ * user, expired, or burned by a reused refresh token (RFC 6749 §5.2). */
76
+ const isDeadGrant = (answer: TokenAnswer): boolean =>
77
+ !answer.ok && answer.status === 400 && answer.error === "invalid_grant";
78
+
59
79
  export interface OneConnect {
60
80
  /** The authorize leg: where to send the browser and the cookie to set. */
61
81
  startAuthorization: (
@@ -73,8 +93,19 @@ export interface OneConnect {
73
93
  getAccessToken: (userId: string) => Promise<string>;
74
94
  /** The stored tokens, for display. Null when not connected. */
75
95
  getTokens: (userId: string) => Promise<OneConnectTokens | null>;
76
- /** Forces a refresh now. */
96
+ /** Refreshes now and returns the new pair. When another caller rotated
97
+ * the pair a moment earlier, returns that pair instead of rotating it
98
+ * a second time. */
77
99
  refreshTokens: (userId: string) => Promise<OneConnectTokens>;
100
+ /** Refreshes only when the access token or the refresh token expires
101
+ * within `withinMs`, and returns the pair that is current afterwards.
102
+ * For background jobs: a frequent run keeps access tokens warm, and a
103
+ * daily run with a window of days keeps idle users' 30-day refresh
104
+ * tokens from running out. */
105
+ refreshIfExpiring: (
106
+ userId: string,
107
+ options?: RefreshIfExpiringOptions,
108
+ ) => Promise<OneConnectTokens>;
78
109
  /** Drops the app's copy of the tokens. The user revokes the grant
79
110
  * itself from their One dashboard. */
80
111
  disconnect: (userId: string) => Promise<void>;
@@ -113,7 +144,7 @@ export function createOneConnect(config: OneConnectServerConfig): OneConnect {
113
144
  return url.toString();
114
145
  };
115
146
 
116
- const exchange = async (body: URLSearchParams): Promise<TokenResponse> => {
147
+ const postToken = async (body: URLSearchParams): Promise<TokenAnswer> => {
117
148
  const response = await fetch(tokenUrl, {
118
149
  method: "POST",
119
150
  headers: {
@@ -122,14 +153,43 @@ export function createOneConnect(config: OneConnectServerConfig): OneConnect {
122
153
  },
123
154
  body,
124
155
  });
125
- if (!response.ok) {
156
+ if (response.ok)
157
+ return { ok: true, body: (await response.json()) as TokenResponse };
158
+ let error: string | undefined;
159
+ try {
160
+ const refusal = (await response.json()) as { error?: unknown };
161
+ if (typeof refusal.error === "string") error = refusal.error;
162
+ } catch {
163
+ /* not an OAuth error body: a proxy page or a server error */
164
+ }
165
+ return { ok: false, status: response.status, error };
166
+ };
167
+
168
+ const exchange = async (body: URLSearchParams): Promise<TokenResponse> => {
169
+ const answer = await postToken(body);
170
+ if (!answer.ok) {
126
171
  throw new OneConnectError(
127
172
  "request_failed",
128
- `One refused the token request (HTTP ${response.status}).`,
129
- response.status,
173
+ `One refused the token request (HTTP ${answer.status}).`,
174
+ answer.status,
130
175
  );
131
176
  }
132
- return (await response.json()) as TokenResponse;
177
+ return answer.body;
178
+ };
179
+
180
+ /** Runs `run` under the app's cross-process lock for this user, when
181
+ * the store has one. */
182
+ const locked = <T>(userId: string, run: () => Promise<T>): Promise<T> =>
183
+ tokenStore.withLock ? tokenStore.withLock(userId, run) : run();
184
+
185
+ /** Whether either token of the pair stops working within `withinMs`. */
186
+ const expiresWithin = (tokens: OneConnectTokens, withinMs: number): boolean => {
187
+ const horizon = Date.now() + withinMs;
188
+ const refreshExpiresAt = refreshTokenExpiresAt(tokens.refreshToken);
189
+ return (
190
+ tokens.expiresAt <= horizon ||
191
+ (refreshExpiresAt !== null && refreshExpiresAt <= horizon)
192
+ );
133
193
  };
134
194
 
135
195
  const toTokens = (response: TokenResponse): OneConnectTokens => ({
@@ -200,7 +260,9 @@ export function createOneConnect(config: OneConnectServerConfig): OneConnect {
200
260
  }),
201
261
  ),
202
262
  );
203
- await tokenStore.saveTokens(input.userId, tokens);
263
+ // Under the lock, so a refresh in flight on another server cannot
264
+ // interleave with this save.
265
+ await locked(input.userId, () => tokenStore.saveTokens(input.userId, tokens));
204
266
  } catch (error) {
205
267
  const status = error instanceof OneConnectError ? error.status : undefined;
206
268
  return fail(
@@ -218,51 +280,123 @@ export function createOneConnect(config: OneConnectServerConfig): OneConnect {
218
280
  };
219
281
  };
220
282
 
221
- const refreshTokens = (userId: string): Promise<OneConnectTokens> => {
222
- const inFlight = refreshing.get(userId);
223
- if (inFlight) return inFlight;
224
- const job = (async () => {
283
+ const notConnected = () =>
284
+ new OneConnectError("not_connected", "This user is not connected.");
285
+
286
+ /**
287
+ * The grant behind `failed` is dead. Clears it, unless a newer pair
288
+ * landed while it was failing (a reconnect's callback, another
289
+ * server's refresh): that pair is returned instead, because the user
290
+ * did nothing wrong and deleting it would disconnect them.
291
+ */
292
+ const retire = async (
293
+ userId: string,
294
+ failed: OneConnectTokens,
295
+ status?: number,
296
+ ): Promise<OneConnectTokens> => {
297
+ const latest = await tokenStore.loadTokens(userId);
298
+ if (latest && latest.refreshToken !== failed.refreshToken) return latest;
299
+ await tokenStore.clearTokens(userId, failed);
300
+ throw new OneConnectError(
301
+ "refresh_failed",
302
+ "The connection to One has expired or was revoked. Ask the user to connect again.",
303
+ status,
304
+ );
305
+ };
306
+
307
+ /**
308
+ * The one place a refresh token is spent. Under the app's lock it
309
+ * re-reads the store, and refreshes only when `stillNeeded` says the
310
+ * stored pair still needs it: another process may have refreshed while
311
+ * this one waited, and spending the same refresh token twice makes One
312
+ * revoke the grant.
313
+ */
314
+ const refreshUnderLock = (
315
+ userId: string,
316
+ stillNeeded: (current: OneConnectTokens) => boolean,
317
+ ): Promise<OneConnectTokens> =>
318
+ locked(userId, async () => {
225
319
  const current = await tokenStore.loadTokens(userId);
226
- if (!current)
227
- throw new OneConnectError("not_connected", "This user is not connected.");
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())
327
+ return retire(userId, current);
328
+
329
+ let answer: TokenAnswer;
228
330
  try {
229
- const next = toTokens(
230
- await exchange(
231
- new URLSearchParams({
232
- grant_type: "refresh_token",
233
- refresh_token: current.refreshToken,
234
- }),
235
- ),
331
+ answer = await postToken(
332
+ new URLSearchParams({
333
+ grant_type: "refresh_token",
334
+ refresh_token: current.refreshToken,
335
+ }),
336
+ );
337
+ } catch {
338
+ throw new OneConnectError(
339
+ "request_failed",
340
+ "One could not be reached to refresh the connection. The tokens were kept; try again.",
236
341
  );
342
+ }
343
+
344
+ if (answer.ok) {
237
345
  // Both tokens: One rotates the pair on every refresh.
346
+ const next = toTokens(answer.body);
238
347
  await tokenStore.saveTokens(userId, next);
239
348
  return next;
240
- } catch (error) {
241
- // The family is dead: revoked, expired or reused. Keeping the
242
- // pair would only fail again; the user has to reconnect.
243
- await tokenStore.clearTokens(userId);
244
- const status = error instanceof OneConnectError ? error.status : undefined;
245
- throw new OneConnectError(
246
- "refresh_failed",
247
- "The connection to One has expired or was revoked. Ask the user to connect again.",
248
- status,
249
- );
250
- } finally {
251
- refreshing.delete(userId);
252
349
  }
253
- })();
254
- refreshing.set(userId, job);
255
- return job;
350
+ if (isDeadGrant(answer)) return retire(userId, current, answer.status);
351
+ // A server error, a rate limit, a misconfigured secret: nothing says
352
+ // the grant is gone, so keep the tokens and let the caller retry.
353
+ throw new OneConnectError(
354
+ "request_failed",
355
+ `One could not refresh the connection (HTTP ${answer.status}). The tokens were kept; try again.`,
356
+ answer.status,
357
+ );
358
+ });
359
+
360
+ /** One refresh per user in this process; concurrent callers share it. */
361
+ const singleFlight = (
362
+ userId: string,
363
+ job: () => Promise<OneConnectTokens>,
364
+ ): Promise<OneConnectTokens> => {
365
+ const inFlight = refreshing.get(userId);
366
+ if (inFlight) return inFlight;
367
+ const running = job().finally(() => refreshing.delete(userId));
368
+ refreshing.set(userId, running);
369
+ return running;
256
370
  };
257
371
 
258
- const getAccessToken = async (userId: string): Promise<string> => {
372
+ const refreshTokens = (userId: string): Promise<OneConnectTokens> =>
373
+ singleFlight(userId, async () => {
374
+ const before = await tokenStore.loadTokens(userId);
375
+ if (!before) throw notConnected();
376
+ // Rotate the pair seen now; a pair someone else rotated since is
377
+ // already fresh.
378
+ return refreshUnderLock(
379
+ userId,
380
+ (current) => current.refreshToken === before.refreshToken,
381
+ );
382
+ });
383
+
384
+ const refreshIfExpiring = async (
385
+ userId: string,
386
+ options: RefreshIfExpiringOptions = {},
387
+ ): Promise<OneConnectTokens> => {
388
+ const withinMs = options.withinMs ?? REFRESH_MARGIN_MS;
259
389
  const tokens = await tokenStore.loadTokens(userId);
260
- if (!tokens)
261
- throw new OneConnectError("not_connected", "This user is not connected.");
262
- if (Date.now() < tokens.expiresAt - REFRESH_MARGIN_MS) return tokens.accessToken;
263
- return (await refreshTokens(userId)).accessToken;
390
+ if (!tokens) throw notConnected();
391
+ if (!expiresWithin(tokens, withinMs)) return tokens;
392
+ return singleFlight(userId, () =>
393
+ refreshUnderLock(userId, (current) => expiresWithin(current, withinMs)),
394
+ );
264
395
  };
265
396
 
397
+ const getAccessToken = async (userId: string): Promise<string> =>
398
+ (await refreshIfExpiring(userId)).accessToken;
399
+
266
400
  const oneFetch = async (
267
401
  userId: string,
268
402
  path: string,
@@ -373,6 +507,7 @@ export function createOneConnect(config: OneConnectServerConfig): OneConnect {
373
507
  getAccessToken,
374
508
  getTokens: (userId) => tokenStore.loadTokens(userId),
375
509
  refreshTokens,
510
+ refreshIfExpiring,
376
511
  disconnect: (userId) => tokenStore.clearTokens(userId),
377
512
  listConnections,
378
513
  listActions,
@@ -85,6 +85,22 @@ export function tenancyHeaders(accessToken: string): Record<string, string> {
85
85
  }
86
86
  }
87
87
 
88
+ /** When a refresh token stops working, in epoch milliseconds, from its
89
+ * `exp` claim. Null when the token carries no readable expiry, in which
90
+ * case only One can say whether it still works. */
91
+ export function refreshTokenExpiresAt(refreshToken: string): number | null {
92
+ try {
93
+ const payload = JSON.parse(
94
+ Buffer.from(refreshToken.split(".")[1], "base64url").toString(),
95
+ ) as { exp?: unknown };
96
+ return typeof payload.exp === "number" && Number.isFinite(payload.exp)
97
+ ? payload.exp * 1000
98
+ : null;
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
88
104
  /** The scopes the token was granted, from its claims. Display only. */
89
105
  export function tokenScopes(accessToken: string): string[] {
90
106
  try {
@@ -13,13 +13,45 @@ export interface OneConnectTokens {
13
13
 
14
14
  /**
15
15
  * Where the app keeps each user's tokens: its database, a cache, an
16
- * encrypted cookie. The SDK never sees a token outside these three
17
- * calls. `userId` is the app's own id for its user.
16
+ * encrypted cookie. The SDK never sees a token outside these calls.
17
+ * `userId` is the app's own id for its user.
18
18
  */
19
19
  export interface OneConnectTokenStore {
20
20
  saveTokens: (userId: string, tokens: OneConnectTokens) => Promise<void>;
21
21
  loadTokens: (userId: string) => Promise<OneConnectTokens | null>;
22
- clearTokens: (userId: string) => Promise<void>;
22
+ /**
23
+ * Deletes the user's tokens.
24
+ *
25
+ * `failed` is set when the SDK clears because One declared that pair
26
+ * dead. Delete only when the stored refresh token is still
27
+ * `failed.refreshToken`: a newer pair saved in the meantime (a
28
+ * reconnect, another server's refresh) must survive. `failed` is
29
+ * undefined for `disconnect`, which always deletes.
30
+ */
31
+ clearTokens: (userId: string, failed?: OneConnectTokens) => Promise<void>;
32
+ /**
33
+ * Runs `run` while holding a lock on this user that every server and
34
+ * worker of the app shares: a Postgres advisory lock, a Redis lock, a
35
+ * row lock. The SDK loads, refreshes and saves the user's tokens
36
+ * inside it.
37
+ *
38
+ * Required when the app runs more than one process (serverless,
39
+ * several instances, a background worker). One rotates the refresh
40
+ * token on every use and treats a second use of the old one as theft,
41
+ * revoking the whole grant, so two processes refreshing the same user
42
+ * at once disconnect that user. Without a lock the SDK can only stop
43
+ * that inside a single process.
44
+ *
45
+ * Hold it for at least 60 seconds before any timeout: it spans one
46
+ * call to One's token endpoint.
47
+ */
48
+ withLock?: <T>(userId: string, run: () => Promise<T>) => Promise<T>;
49
+ }
50
+
51
+ export interface RefreshIfExpiringOptions {
52
+ /** Refresh when the access token or the refresh token expires within
53
+ * this many milliseconds. One minute when omitted. */
54
+ withinMs?: number;
23
55
  }
24
56
 
25
57
  export interface OneConnectServerConfig {
@@ -137,6 +169,13 @@ export interface RunActionResult {
137
169
  data: unknown;
138
170
  }
139
171
 
172
+ /**
173
+ * - `not_connected`: no tokens are stored for this user.
174
+ * - `refresh_failed`: One declared the grant dead (revoked, expired or
175
+ * reused). The tokens were cleared; ask the user to connect again.
176
+ * - `request_failed`: One answered with an error or could not be
177
+ * reached. During a refresh the tokens are kept, so retry later.
178
+ */
140
179
  export type OneConnectErrorCode =
141
180
  | "not_connected"
142
181
  | "refresh_failed"
package/src/types.ts CHANGED
@@ -42,8 +42,7 @@ export interface OneConnectReturn {
42
42
  * object to override either.
43
43
  */
44
44
  export type ConnectButtonPlatformInput =
45
- | string
46
- | { slug?: string; name?: string; imageUrl?: string };
45
+ string | { slug?: string; name?: string; imageUrl?: string };
47
46
 
48
47
  /** A normalized chip: what the button actually draws. */
49
48
  export interface ConnectButtonPlatform {
@@ -66,11 +65,9 @@ export interface ConnectButtonOptions {
66
65
  variant?: ConnectButtonVariant;
67
66
  /** Matches the host page, not One's page (that is connect.appTheme). */
68
67
  theme?: OneConnectTheme;
69
- /** Connector chips. The first three render; the rest fold into the
70
- * "+N" chip together with moreCount. */
68
+ /** Connector chips. The first three render; the rest fold into a "+N"
69
+ * chip, so that count only ever describes this list. */
71
70
  platforms?: ConnectButtonPlatformInput[];
72
- /** Extra count for the "+N" chip, e.g. 274 for "the whole catalog". */
73
- moreCount?: number;
74
71
  /** Sub-line on the block variant, shown while idle. */
75
72
  description?: string;
76
73
  /** Fill of the accent variant; One's lime when omitted. */
package/src/vue.ts CHANGED
@@ -44,7 +44,6 @@ export const ConnectButton = defineComponent({
44
44
  type: Array as PropType<ConnectButtonPlatformInput[]>,
45
45
  default: undefined,
46
46
  },
47
- moreCount: { type: Number, default: undefined },
48
47
  description: { type: String, default: undefined },
49
48
  accentColor: { type: String, default: undefined },
50
49
  connectedLabel: { type: String, default: undefined },
@@ -21,7 +21,6 @@ export interface ConnectButtonProps {
21
21
  theme?: OneConnectTheme;
22
22
  /** Connector slugs, or objects to override name or logo. */
23
23
  platforms?: ConnectButtonPlatformInput[];
24
- moreCount?: number;
25
24
  description?: string;
26
25
  accentColor?: string;
27
26
  connectedLabel?: string;
@@ -47,7 +46,6 @@ export function optionsFromProps(
47
46
  variant: props.variant,
48
47
  theme: props.theme,
49
48
  platforms: props.platforms,
50
- moreCount: props.moreCount,
51
49
  description: props.description,
52
50
  accentColor: props.accentColor,
53
51
  connectedLabel: props.connectedLabel,
@@ -63,7 +61,6 @@ export function propsIdentity(props: ConnectButtonProps): string {
63
61
  props.variant,
64
62
  props.theme,
65
63
  props.platforms ?? [],
66
- props.moreCount,
67
64
  props.description,
68
65
  props.accentColor,
69
66
  props.connectedLabel,