@withone/connect 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/react.d.ts CHANGED
@@ -1,7 +1,26 @@
1
1
  import type { CSSProperties, ReactElement } from "react";
2
- import { type ConnectButtonProps as ConnectButtonCoreProps } from "@withone/connect";
2
+ import { type ConnectButtonProps as ConnectButtonCoreProps, type ConnectFailureCode, type OneConnectFlowOptions } from "@withone/connect";
3
3
  export interface ConnectButtonProps extends ConnectButtonCoreProps {
4
+ /** On the wrapper element around the button. */
4
5
  className?: string;
5
6
  style?: CSSProperties;
6
7
  }
7
8
  export declare function ConnectButton(props: ConnectButtonProps): ReactElement;
9
+ export type OneConnectStatus = "idle" | "connecting" | "connected" | "error";
10
+ export interface UseOneConnectResult {
11
+ /** Sends the tab to One's hosted connect flow. */
12
+ open: () => void;
13
+ /** "connected" and "error" reflect how this page load ended a flow. */
14
+ status: OneConnectStatus;
15
+ error: {
16
+ message: string;
17
+ code: ConnectFailureCode;
18
+ } | null;
19
+ }
20
+ /**
21
+ * The flow for your own button:
22
+ *
23
+ * const { open, status, error } = useOneConnect({ authorizeUrl: "/api/one/authorize" });
24
+ * <button onClick={open} disabled={status === "connecting"}>Connect</button>
25
+ */
26
+ export declare function useOneConnect(options: OneConnectFlowOptions): UseOneConnectResult;
package/dist/react.esm.js CHANGED
@@ -1 +1,2 @@
1
- import{useRef as r,useEffect as n,createElement as o}from"react";import{propsIdentity as c,mountConnectButton as e,optionsFromProps as u}from"@withone/connect";function t(t){const s=r(null),l=r(null),i=r({onSuccess:t.onSuccess,onError:t.onError});i.current={onSuccess:t.onSuccess,onError:t.onError};const a=c(t);return n(()=>{if(s.current)return l.current=e(s.current,u(t,{onSuccess:()=>{var r,n;return null===(r=(n=i.current).onSuccess)||void 0===r?void 0:r.call(n)},onError:r=>{var n,o;return null===(n=(o=i.current).onError)||void 0===n?void 0:n.call(o,r)}})),()=>{var r;null===(r=l.current)||void 0===r||r.destroy(),l.current=null}},[a]),o("div",{ref:s,className:t.className,style:t.style})}export{t as ConnectButton};
1
+ "use client";
2
+ import{useRef as r,useEffect as n,createElement as e,useState as t,useCallback as c}from"react";import{mountConnectButton as l,createConnectFlow as o,readConnectReturn as u}from"@withone/connect";const s=r=>{var n,e;return JSON.stringify([r.authorizeUrl,null!==(n=r.platforms)&&void 0!==n?n:[],r.connected,r.disabled,r.variant,r.size,r.fullWidth,r.theme,null!==(e=r.connectTheme)&&void 0!==e?e:r.appTheme,r.accentColor,r.label,r.connectedLabel,r.description])};function a(t){const c=r(null),o=r(null),u=r(""),a=r(t);a.current=t;const i=()=>({...a.current,onSuccess:()=>{var r,n;return null===(r=(n=a.current).onSuccess)||void 0===r?void 0:r.call(n)},onError:(r,n)=>{var e,t;return null===(e=(t=a.current).onError)||void 0===e?void 0:e.call(t,r,n)},onCancel:()=>{var r,n;return null===(r=(n=a.current).onCancel)||void 0===r?void 0:r.call(n)}});n(()=>{if(c.current)return o.current=l(c.current,i()),u.current=s(a.current),()=>{var r;null===(r=o.current)||void 0===r||r.destroy(),o.current=null}},[]);const d=s(t);return n(()=>{o.current&&d!==u.current&&(u.current=d,o.current.update(i()))},[d]),e("div",{ref:c,className:t.className,style:t.style})}function i(e){const[l,s]=t("idle"),[a,i]=t(null),d=r(e);d.current=e;const v=r(null),f=()=>({...d.current,onSuccess:()=>{var r,n;return null===(r=(n=d.current).onSuccess)||void 0===r?void 0:r.call(n)},onError:(r,n)=>{var e,t;return null===(e=(t=d.current).onError)||void 0===e?void 0:e.call(t,r,n)},onCancel:()=>{var r,n;s("idle"),null===(r=(n=d.current).onCancel)||void 0===r||r.call(n)}});n(()=>{const r=o(f());v.current=r;const n=u();if("success"===(null==n?void 0:n.status))s("connected");else if("error"===(null==n?void 0:n.status)){var e,t;s("error"),i({message:null!==(e=n.message)&&void 0!==e?e:"",code:null!==(t=n.code)&&void 0!==t?t:"failed"})}return()=>{r.destroy(),v.current=null}},[]),n(()=>{var r;null===(r=v.current)||void 0===r||r.update(f())});return{open:c(()=>{v.current&&(i(null),s("connecting"),v.current.open())},[]),status:l,error:a}}export{a as ConnectButton,i as useOneConnect};
package/dist/return.d.ts CHANGED
@@ -1,5 +1,8 @@
1
- import type { OneConnectReturn } from "./types";
2
- /** Reads the outcome the callback route put on the return URL. */
1
+ import type { ConnectFailureCode, OneConnectReturn } from "./types";
2
+ /** The only text the SDK ever shows for a failed flow. */
3
+ export declare const ERROR_MESSAGES: Record<ConnectFailureCode, string>;
4
+ /** Reads the outcome the callback route put on the return URL. The
5
+ * message always comes from ERROR_MESSAGES, never from the URL. */
3
6
  export declare function parseReturn(search: string): OneConnectReturn | null;
4
7
  /** The same URL without the return params, so a refresh does not
5
8
  * re-fire the callbacks. */
@@ -6,7 +6,8 @@ var node_crypto = require('node:crypto');
6
6
  * The SDK reads them off the page URL when the tab comes home, so
7
7
  * there is no completion page to build. */
8
8
  const RETURN_STATUS_PARAM = "one_connect";
9
- const RETURN_MESSAGE_PARAM = "one_connect_message";
9
+ /** Why the flow failed, as a code (see ConnectFailureCode). */
10
+ const RETURN_ERROR_PARAM = "one_connect_error";
10
11
 
11
12
  /** One's production API. Point `oneApiUrl` elsewhere for development. */
12
13
  const DEFAULT_ONE_API_URL = "https://api.withone.ai";
@@ -72,6 +73,18 @@ function tenancyHeaders(accessToken) {
72
73
  }
73
74
  }
74
75
 
76
+ /** When a refresh token stops working, in epoch milliseconds, from its
77
+ * `exp` claim. Null when the token carries no readable expiry, in which
78
+ * case only One can say whether it still works. */
79
+ function refreshTokenExpiresAt(refreshToken) {
80
+ try {
81
+ const payload = JSON.parse(Buffer.from(refreshToken.split(".")[1], "base64url").toString());
82
+ return typeof payload.exp === "number" && Number.isFinite(payload.exp) ? payload.exp * 1000 : null;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
75
88
  /** The scopes the token was granted, from its claims. Display only. */
76
89
  function tokenScopes(accessToken) {
77
90
  try {
@@ -91,8 +104,8 @@ function tokenScopes(accessToken) {
91
104
 
92
105
  /**
93
106
  * Where the app keeps each user's tokens: its database, a cache, an
94
- * encrypted cookie. The SDK never sees a token outside these three
95
- * calls. `userId` is the app's own id for its user.
107
+ * encrypted cookie. The SDK never sees a token outside these calls.
108
+ * `userId` is the app's own id for its user.
96
109
  */
97
110
 
98
111
  /** The transaction cookie the authorize leg sets and the callback reads. */
@@ -101,6 +114,14 @@ function tokenScopes(accessToken) {
101
114
 
102
115
  /** One catalog action for a platform. */
103
116
 
117
+ /**
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.
121
+ * - `request_failed`: One answered with an error or could not be
122
+ * reached. During a refresh the tokens are kept, so retry later.
123
+ */
124
+
104
125
  class OneConnectError extends Error {
105
126
  constructor(code, message, status) {
106
127
  super(message);
@@ -128,12 +149,26 @@ class OneConnectError extends Error {
128
149
  * const reply = await oneConnect.runAction(userId, { connectionKey, actionId, method, path });
129
150
  *
130
151
  * 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.
131
160
  */
132
161
 
133
162
  /** Refresh this long before expiry, so a call never races the clock. */
134
163
  const REFRESH_MARGIN_MS = 60_000;
135
164
  const CATALOG_PAGE_SIZE = 100;
136
165
  const CATALOG_MAX_PAGES = 20;
166
+
167
+ /** What One's token endpoint answered. A network failure throws instead. */
168
+
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";
137
172
  function createOneConnect(config) {
138
173
  const oneApiUrl = (config.oneApiUrl ?? DEFAULT_ONE_API_URL).replace(/\/+$/, "");
139
174
  const authorizeUrl = `${oneApiUrl}/oauth/authorize`;
@@ -148,13 +183,13 @@ function createOneConnect(config) {
148
183
  /** One refresh in flight per user: two concurrent refreshes with the
149
184
  * same refresh token trip One's reuse detection. */
150
185
  const refreshing = new Map();
151
- const returnUrl = (status, message) => {
186
+ const returnUrl = (status, code) => {
152
187
  const url = new URL(returnTo, config.redirectUri);
153
188
  url.searchParams.set(RETURN_STATUS_PARAM, status);
154
- if (message) url.searchParams.set(RETURN_MESSAGE_PARAM, message);
189
+ if (code) url.searchParams.set(RETURN_ERROR_PARAM, code);
155
190
  return url.toString();
156
191
  };
157
- const exchange = async body => {
192
+ const postToken = async body => {
158
193
  const response = await fetch(tokenUrl, {
159
194
  method: "POST",
160
195
  headers: {
@@ -163,10 +198,40 @@ function createOneConnect(config) {
163
198
  },
164
199
  body
165
200
  });
166
- if (!response.ok) {
167
- throw new OneConnectError("request_failed", `One refused the token request (HTTP ${response.status}).`, response.status);
201
+ if (response.ok) return {
202
+ ok: true,
203
+ body: await response.json()
204
+ };
205
+ let error;
206
+ try {
207
+ const refusal = await response.json();
208
+ if (typeof refusal.error === "string") error = refusal.error;
209
+ } catch {
210
+ /* not an OAuth error body: a proxy page or a server error */
211
+ }
212
+ return {
213
+ ok: false,
214
+ status: response.status,
215
+ error
216
+ };
217
+ };
218
+ const exchange = async body => {
219
+ const answer = await postToken(body);
220
+ if (!answer.ok) {
221
+ throw new OneConnectError("request_failed", `One refused the token request (HTTP ${answer.status}).`, answer.status);
168
222
  }
169
- return await response.json();
223
+ return answer.body;
224
+ };
225
+
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;
170
235
  };
171
236
  const toTokens = response => ({
172
237
  accessToken: response.access_token,
@@ -198,17 +263,18 @@ function createOneConnect(config) {
198
263
  const oauthError = params.get("error");
199
264
  const cookieName = state ? txCookieName(state) : undefined;
200
265
  const verifier = cookieName ? input.getCookie(cookieName) : undefined;
201
- const fail = (outcome, message) => ({
202
- outcome,
266
+ const fail = (failure, message) => ({
267
+ outcome: failure === "declined" ? "declined" : "failed",
268
+ code: failure,
203
269
  message,
204
- redirectUrl: returnUrl("error", message),
270
+ redirectUrl: returnUrl("error", failure),
205
271
  clearCookieName: cookieName
206
272
  });
207
- if (oauthError === "access_denied") return fail("declined", "You cancelled the request.");
273
+ if (oauthError === "access_denied") return fail("declined", "The user cancelled on One's page.");
208
274
  if (oauthError) return fail("failed", `One reported an error: ${oauthError}.`);
209
- // The returned state names its own cookie. No cookie means a forged
210
- // or stale state; the code is never exchanged in that case.
211
- if (!code || !state || !verifier) return fail("failed", "The sign-in attempt expired or was tampered with.");
275
+ // The returned state names its own cookie. No cookie means a stale,
276
+ // foreign or forged state; the code is never exchanged in that case.
277
+ if (!code || !state || !verifier) return fail("expired", "The attempt expired, or its state cookie was missing.");
212
278
  try {
213
279
  const tokens = toTokens(await exchange(new URLSearchParams({
214
280
  grant_type: "authorization_code",
@@ -216,7 +282,9 @@ function createOneConnect(config) {
216
282
  redirect_uri: config.redirectUri,
217
283
  code_verifier: verifier
218
284
  })));
219
- await tokenStore.saveTokens(input.userId, tokens);
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));
220
288
  } catch (error) {
221
289
  const status = error instanceof OneConnectError ? error.status : undefined;
222
290
  return fail("failed", status ? `One rejected the code exchange (HTTP ${status}).` : "One could not be reached to complete the connection.");
@@ -227,39 +295,81 @@ function createOneConnect(config) {
227
295
  clearCookieName: cookieName
228
296
  };
229
297
  };
230
- const refreshTokens = userId => {
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) => {
231
352
  const inFlight = refreshing.get(userId);
232
353
  if (inFlight) return inFlight;
233
- const job = (async () => {
234
- const current = await tokenStore.loadTokens(userId);
235
- if (!current) throw new OneConnectError("not_connected", "This user is not connected.");
236
- try {
237
- const next = toTokens(await exchange(new URLSearchParams({
238
- grant_type: "refresh_token",
239
- refresh_token: current.refreshToken
240
- })));
241
- // Both tokens: One rotates the pair on every refresh.
242
- await tokenStore.saveTokens(userId, next);
243
- return next;
244
- } catch (error) {
245
- // The family is dead: revoked, expired or reused. Keeping the
246
- // pair would only fail again; the user has to reconnect.
247
- await tokenStore.clearTokens(userId);
248
- const status = error instanceof OneConnectError ? error.status : undefined;
249
- throw new OneConnectError("refresh_failed", "The connection to One has expired or was revoked. Ask the user to connect again.", status);
250
- } finally {
251
- refreshing.delete(userId);
252
- }
253
- })();
254
- refreshing.set(userId, job);
255
- return job;
354
+ const running = job().finally(() => refreshing.delete(userId));
355
+ refreshing.set(userId, running);
356
+ return running;
256
357
  };
257
- const getAccessToken = async userId => {
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;
258
367
  const tokens = await tokenStore.loadTokens(userId);
259
- if (!tokens) throw new OneConnectError("not_connected", "This user is not connected.");
260
- if (Date.now() < tokens.expiresAt - REFRESH_MARGIN_MS) return tokens.accessToken;
261
- return (await refreshTokens(userId)).accessToken;
368
+ if (!tokens) throw notConnected();
369
+ if (!expiresWithin(tokens, withinMs)) return tokens;
370
+ return singleFlight(userId, () => refreshUnderLock(userId, current => expiresWithin(current, withinMs)));
262
371
  };
372
+ const getAccessToken = async userId => (await refreshIfExpiring(userId)).accessToken;
263
373
  const oneFetch = async (userId, path, init = {}) => {
264
374
  const accessToken = await getAccessToken(userId);
265
375
  const headers = new Headers(init.headers);
@@ -335,6 +445,7 @@ function createOneConnect(config) {
335
445
  getAccessToken,
336
446
  getTokens: userId => tokenStore.loadTokens(userId),
337
447
  refreshTokens,
448
+ refreshIfExpiring,
338
449
  disconnect: userId => tokenStore.clearTokens(userId),
339
450
  listConnections,
340
451
  listActions,
@@ -345,5 +456,6 @@ function createOneConnect(config) {
345
456
 
346
457
  exports.OneConnectError = OneConnectError;
347
458
  exports.createOneConnect = createOneConnect;
459
+ exports.refreshTokenExpiresAt = refreshTokenExpiresAt;
348
460
  exports.tenancyHeaders = tenancyHeaders;
349
461
  exports.tokenScopes = tokenScopes;
@@ -1,6 +1,6 @@
1
- import { type CompleteAuthorizationInput, type CompleteAuthorizationResult, type OneConnectServerConfig, type OneConnectTokens, type PlatformAction, type ReachableConnection, type RunActionInput, type RunActionResult, type StartAuthorizationInput, type StartAuthorizationResult } from "./types";
1
+ import { type CompleteAuthorizationInput, type CompleteAuthorizationResult, type OneConnectServerConfig, type OneConnectTokens, type PlatformAction, type ReachableConnection, type RefreshIfExpiringOptions, type RunActionInput, type RunActionResult, type StartAuthorizationInput, type StartAuthorizationResult } from "./types";
2
2
  export * from "./types";
3
- export { tenancyHeaders, tokenScopes } from "./oauth";
3
+ export { refreshTokenExpiresAt, tenancyHeaders, tokenScopes } from "./oauth";
4
4
  export interface OneConnect {
5
5
  /** The authorize leg: where to send the browser and the cookie to set. */
6
6
  startAuthorization: (input?: StartAuthorizationInput) => StartAuthorizationResult;
@@ -14,8 +14,16 @@ export interface OneConnect {
14
14
  getAccessToken: (userId: string) => Promise<string>;
15
15
  /** The stored tokens, for display. Null when not connected. */
16
16
  getTokens: (userId: string) => Promise<OneConnectTokens | null>;
17
- /** Forces a refresh now. */
17
+ /** Refreshes now and returns the new pair. When another caller rotated
18
+ * the pair a moment earlier, returns that pair instead of rotating it
19
+ * a second time. */
18
20
  refreshTokens: (userId: string) => Promise<OneConnectTokens>;
21
+ /** Refreshes only when the access token or the refresh token expires
22
+ * within `withinMs`, and returns the pair that is current afterwards.
23
+ * For background jobs: a frequent run keeps access tokens warm, and a
24
+ * daily run with a window of days keeps idle users' 30-day refresh
25
+ * tokens from running out. */
26
+ refreshIfExpiring: (userId: string, options?: RefreshIfExpiringOptions) => Promise<OneConnectTokens>;
19
27
  /** Drops the app's copy of the tokens. The user revokes the grant
20
28
  * itself from their One dashboard. */
21
29
  disconnect: (userId: string) => Promise<void>;