@withone/connect 0.10.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.
package/README.md CHANGED
@@ -166,6 +166,8 @@ export const oneConnect = createOneConnect({
166
166
  saveTokens: (userId, tokens) => db.oneTokens.upsert(userId, tokens),
167
167
  loadTokens: (userId) => db.oneTokens.find(userId),
168
168
  clearTokens: (userId) => db.oneTokens.delete(userId),
169
+ // Required when you run more than one server or a worker. See "Tokens" below.
170
+ withLock: (userId, run) => db.withUserLock(userId, run),
169
171
  },
170
172
  });
171
173
  ```
@@ -207,7 +209,7 @@ What the routes do for you: mint `state` and a PKCE verifier, keep them in a per
207
209
 
208
210
  ## 4 · Using the grant
209
211
 
210
- Everything runs on your server through the same client. Tokens are refreshed for you before they expire; a refresh One refuses clears the stored tokens and throws `OneConnectError` with code `refresh_failed`, which means "ask the user to connect again".
212
+ Everything runs on your server through the same client. Every call refreshes the tokens first when they are about to expire (see [Tokens](#5--tokens-storing-refreshing-keeping-alive)).
211
213
 
212
214
  ```ts
213
215
  // What the grant reaches, each connection with its access
@@ -231,7 +233,88 @@ const reply = await oneConnect.runAction(userId, {
231
233
 
232
234
  `blockedByGrant` is true when One refused the call because it is outside what the user granted. The provider was never called. Do not retry; the user chose that. Anything else on One's `/v1` API: `oneConnect.fetch(userId, "/connections", init)` adds the bearer and the tenancy headers for you.
233
235
 
234
- Other calls on the client: `isConnected`, `getAccessToken`, `getTokens`, `refreshTokens`, `disconnect`.
236
+ Other calls on the client: `isConnected`, `getAccessToken`, `getTokens`, `refreshTokens`, `refreshIfExpiring`, `disconnect`.
237
+
238
+ ## 5 · Tokens: storing, refreshing, keeping alive
239
+
240
+ Your app owns the tokens. The SDK never stores anything itself: it calls your `tokenStore`, and it refreshes through it.
241
+
242
+ **What One issues**
243
+
244
+ | | Lifetime | On refresh |
245
+ |---|---|---|
246
+ | Access token | The lifetime set on your app: 1 hour by default, or 7, 30, 90 or 365 days | Replaced |
247
+ | Refresh token | 30 days | Replaced, with a fresh 30 days |
248
+
249
+ Every refresh returns a **new pair** and retires the old refresh token. If the old refresh token is ever used again, One treats it as stolen and **revokes the whole grant**. The user then has to connect again.
250
+
251
+ **Store them in your database, encrypted, keyed by your user id**
252
+
253
+ ```sql
254
+ create table one_tokens (
255
+ user_id text primary key,
256
+ access_token text not null, -- encrypted
257
+ refresh_token text not null, -- encrypted
258
+ expires_at timestamptz not null -- tokens.expiresAt
259
+ );
260
+ ```
261
+
262
+ **Give the store a lock when more than one process can refresh**
263
+
264
+ Serverless functions, several instances, a background worker: any two of them can decide to refresh the same user at the same moment.
265
+
266
+ ```
267
+ without a lock with withLock
268
+ web ──refresh(R1)──► One: here is R2 web ──lock──refresh(R1)──► R2 ──save──unlock
269
+ worker ─refresh(R1)─► One: R1 reused! worker ──wait─────────────────────────────┐
270
+ revoke everything ✗ reload: R2 is fresh, use it ✓
271
+ ```
272
+
273
+ The SDK takes the lock around every refresh and around the save in the callback. It re-reads the store inside the lock, so the process that waited uses the pair the first one saved instead of spending the old refresh token again. The SDK never takes the lock twice for the same call. Postgres, with a transaction-scoped advisory lock:
274
+
275
+ ```ts
276
+ withLock: async (userId, run) => {
277
+ const client = await pool.connect();
278
+ try {
279
+ await client.query("begin");
280
+ await client.query("select pg_advisory_xact_lock(hashtextextended($1, 0))", [`one-connect:${userId}`]);
281
+ return await run();
282
+ } finally {
283
+ await client.query("commit").catch(() => {});
284
+ client.release();
285
+ }
286
+ },
287
+ ```
288
+
289
+ A single long-running process can leave `withLock` out; the SDK already runs one refresh per user at a time inside a process. If your lock has a timeout, make it at least 60 seconds, because it spans one call to One.
290
+
291
+ **Refreshing ahead of time**
292
+
293
+ `getAccessToken`, `runAction`, `listConnections` and `fetch` refresh on their own when the access token has less than a minute left. For work that runs in the background, refresh ahead with `refreshIfExpiring`. It refreshes only when the access token **or** the refresh token expires within the window, and otherwise returns the stored pair without calling One.
294
+
295
+ ```ts
296
+ // Every 10 minutes: users whose access token expires in the next 15 minutes
297
+ // (your table knows expires_at).
298
+ await oneConnect.refreshIfExpiring(userId, { withinMs: 15 * 60_000 });
299
+
300
+ // Once a day, for every connected user: renews refresh tokens before their
301
+ // 30 days run out, so a user who has not been active stays connected.
302
+ await oneConnect.refreshIfExpiring(userId, { withinMs: 7 * 24 * 3_600_000 });
303
+ ```
304
+
305
+ **When a refresh fails**
306
+
307
+ | `OneConnectError.code` | What happened | Tokens | What to do |
308
+ |---|---|---|---|
309
+ | `refresh_failed` | One declared the grant dead: the user revoked it, the refresh token expired, or it was reused | Cleared | Show "Reconnect", which runs Connect again |
310
+ | `request_failed` | One could not be reached, answered with a server error, or refused your client credentials | **Kept** | Retry later. Check `status` for a 401, which means your client secret is wrong. |
311
+ | `not_connected` | No tokens are stored for this user | — | Show "Connect" |
312
+
313
+ The SDK clears tokens only when One says the grant is dead (`invalid_grant`), or when the refresh token's own expiry has passed. It calls `clearTokens(userId, failed)` with the pair that failed. Just before that, it re-reads the store: if a newer pair has landed (the user reconnected while an old refresh was failing), it keeps and returns the newer pair. To make that check atomic, delete only when the stored refresh token is still `failed.refreshToken`. `disconnect(userId)` calls `clearTokens(userId)` without `failed`, and always deletes.
314
+
315
+ **Asking for more tools later**
316
+
317
+ Edit the app's permission set in the dashboard (Developers → Connect). The next time a user presses the button, One shows them the new tools as "{your app} needs one more connection", with what they already granted pre-selected. Authorizing replaces the user's grant and old tokens at once, and the callback saves the new pair.
235
318
 
236
319
  ## What your users see
237
320
 
@@ -243,7 +326,7 @@ In *your* dashboard the app lists every user who said yes, what each one granted
243
326
 
244
327
  - The client secret is used on your server only, for the code exchange and refresh, over HTTP Basic.
245
328
  - The authorization code is single use and expires ten minutes after consent.
246
- - Refresh tokens rotate on every use. Reusing an old one revokes the whole family, which is why the client serialises refreshes per user.
329
+ - Refresh tokens rotate on every use. Reusing an old one revokes the whole family, which is why the client refreshes one user at a time: inside a process on its own, and across processes through `tokenStore.withLock`.
247
330
  - The browser half of this package never sees a token. It navigates and reads one query parameter.
248
331
 
249
332
  ## Development
@@ -72,6 +72,18 @@ function tenancyHeaders(accessToken) {
72
72
  }
73
73
  }
74
74
 
75
+ /** When a refresh token stops working, in epoch milliseconds, from its
76
+ * `exp` claim. Null when the token carries no readable expiry, in which
77
+ * case only One can say whether it still works. */
78
+ function refreshTokenExpiresAt(refreshToken) {
79
+ try {
80
+ const payload = JSON.parse(Buffer.from(refreshToken.split(".")[1], "base64url").toString());
81
+ return typeof payload.exp === "number" && Number.isFinite(payload.exp) ? payload.exp * 1000 : null;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
75
87
  /** The scopes the token was granted, from its claims. Display only. */
76
88
  function tokenScopes(accessToken) {
77
89
  try {
@@ -91,8 +103,8 @@ function tokenScopes(accessToken) {
91
103
 
92
104
  /**
93
105
  * 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.
106
+ * encrypted cookie. The SDK never sees a token outside these calls.
107
+ * `userId` is the app's own id for its user.
96
108
  */
97
109
 
98
110
  /** The transaction cookie the authorize leg sets and the callback reads. */
@@ -101,6 +113,14 @@ function tokenScopes(accessToken) {
101
113
 
102
114
  /** One catalog action for a platform. */
103
115
 
116
+ /**
117
+ * - `not_connected`: no tokens are stored for this user.
118
+ * - `refresh_failed`: One declared the grant dead (revoked, expired or
119
+ * reused). The tokens were cleared; ask the user to connect again.
120
+ * - `request_failed`: One answered with an error or could not be
121
+ * reached. During a refresh the tokens are kept, so retry later.
122
+ */
123
+
104
124
  class OneConnectError extends Error {
105
125
  constructor(code, message, status) {
106
126
  super(message);
@@ -128,12 +148,26 @@ class OneConnectError extends Error {
128
148
  * const reply = await oneConnect.runAction(userId, { connectionKey, actionId, method, path });
129
149
  *
130
150
  * The Next.js and Node adapters turn the first two into route handlers.
151
+ *
152
+ * Refresh. One's access token lives an hour by default, its refresh token 30
153
+ * days; every refresh rotates both, and One treats a second use of a
154
+ * rotated refresh token as theft and revokes the whole grant. So the
155
+ * client refreshes one user at a time (in this process always, across
156
+ * processes through `tokenStore.withLock`), re-reads the store before
157
+ * spending a refresh token, clears tokens only when One declares the
158
+ * grant dead, and never lets a failing old pair delete a newer one.
131
159
  */
132
160
 
133
161
  /** Refresh this long before expiry, so a call never races the clock. */
134
162
  const REFRESH_MARGIN_MS = 60_000;
135
163
  const CATALOG_PAGE_SIZE = 100;
136
164
  const CATALOG_MAX_PAGES = 20;
165
+
166
+ /** What One's token endpoint answered. A network failure throws instead. */
167
+
168
+ /** The one refusal that means the grant is gone for good: revoked by the
169
+ * user, expired, or burned by a reused refresh token (RFC 6749 §5.2). */
170
+ const isDeadGrant = answer => !answer.ok && answer.status === 400 && answer.error === "invalid_grant";
137
171
  function createOneConnect(config) {
138
172
  const oneApiUrl = (config.oneApiUrl ?? DEFAULT_ONE_API_URL).replace(/\/+$/, "");
139
173
  const authorizeUrl = `${oneApiUrl}/oauth/authorize`;
@@ -154,7 +188,7 @@ function createOneConnect(config) {
154
188
  if (message) url.searchParams.set(RETURN_MESSAGE_PARAM, message);
155
189
  return url.toString();
156
190
  };
157
- const exchange = async body => {
191
+ const postToken = async body => {
158
192
  const response = await fetch(tokenUrl, {
159
193
  method: "POST",
160
194
  headers: {
@@ -163,10 +197,40 @@ function createOneConnect(config) {
163
197
  },
164
198
  body
165
199
  });
166
- if (!response.ok) {
167
- throw new OneConnectError("request_failed", `One refused the token request (HTTP ${response.status}).`, response.status);
200
+ if (response.ok) return {
201
+ ok: true,
202
+ body: await response.json()
203
+ };
204
+ let error;
205
+ try {
206
+ const refusal = await response.json();
207
+ if (typeof refusal.error === "string") error = refusal.error;
208
+ } catch {
209
+ /* not an OAuth error body: a proxy page or a server error */
210
+ }
211
+ return {
212
+ ok: false,
213
+ status: response.status,
214
+ error
215
+ };
216
+ };
217
+ const exchange = async body => {
218
+ const answer = await postToken(body);
219
+ if (!answer.ok) {
220
+ throw new OneConnectError("request_failed", `One refused the token request (HTTP ${answer.status}).`, answer.status);
168
221
  }
169
- return await response.json();
222
+ return answer.body;
223
+ };
224
+
225
+ /** Runs `run` under the app's cross-process lock for this user, when
226
+ * the store has one. */
227
+ const locked = (userId, run) => tokenStore.withLock ? tokenStore.withLock(userId, run) : run();
228
+
229
+ /** Whether either token of the pair stops working within `withinMs`. */
230
+ const expiresWithin = (tokens, withinMs) => {
231
+ const horizon = Date.now() + withinMs;
232
+ const refreshExpiresAt = refreshTokenExpiresAt(tokens.refreshToken);
233
+ return tokens.expiresAt <= horizon || refreshExpiresAt !== null && refreshExpiresAt <= horizon;
170
234
  };
171
235
  const toTokens = response => ({
172
236
  accessToken: response.access_token,
@@ -216,7 +280,9 @@ function createOneConnect(config) {
216
280
  redirect_uri: config.redirectUri,
217
281
  code_verifier: verifier
218
282
  })));
219
- await tokenStore.saveTokens(input.userId, tokens);
283
+ // Under the lock, so a refresh in flight on another server cannot
284
+ // interleave with this save.
285
+ await locked(input.userId, () => tokenStore.saveTokens(input.userId, tokens));
220
286
  } catch (error) {
221
287
  const status = error instanceof OneConnectError ? error.status : undefined;
222
288
  return fail("failed", status ? `One rejected the code exchange (HTTP ${status}).` : "One could not be reached to complete the connection.");
@@ -227,39 +293,81 @@ function createOneConnect(config) {
227
293
  clearCookieName: cookieName
228
294
  };
229
295
  };
230
- const refreshTokens = userId => {
296
+ const notConnected = () => new OneConnectError("not_connected", "This user is not connected.");
297
+
298
+ /**
299
+ * The grant behind `failed` is dead. Clears it, unless a newer pair
300
+ * landed while it was failing (a reconnect's callback, another
301
+ * server's refresh): that pair is returned instead, because the user
302
+ * did nothing wrong and deleting it would disconnect them.
303
+ */
304
+ const retire = async (userId, failed, status) => {
305
+ const latest = await tokenStore.loadTokens(userId);
306
+ if (latest && latest.refreshToken !== failed.refreshToken) return latest;
307
+ await tokenStore.clearTokens(userId, failed);
308
+ throw new OneConnectError("refresh_failed", "The connection to One has expired or was revoked. Ask the user to connect again.", status);
309
+ };
310
+
311
+ /**
312
+ * The one place a refresh token is spent. Under the app's lock it
313
+ * re-reads the store, and refreshes only when `stillNeeded` says the
314
+ * stored pair still needs it: another process may have refreshed while
315
+ * this one waited, and spending the same refresh token twice makes One
316
+ * revoke the grant.
317
+ */
318
+ const refreshUnderLock = (userId, stillNeeded) => locked(userId, async () => {
319
+ const current = await tokenStore.loadTokens(userId);
320
+ if (!current) throw notConnected();
321
+ if (!stillNeeded(current)) return current;
322
+
323
+ // An expired refresh token cannot work, and One answers one with a
324
+ // server error rather than invalid_grant, so settle it here.
325
+ const refreshExpiresAt = refreshTokenExpiresAt(current.refreshToken);
326
+ if (refreshExpiresAt !== null && refreshExpiresAt <= Date.now()) return retire(userId, current);
327
+ let answer;
328
+ try {
329
+ answer = await postToken(new URLSearchParams({
330
+ grant_type: "refresh_token",
331
+ refresh_token: current.refreshToken
332
+ }));
333
+ } catch {
334
+ throw new OneConnectError("request_failed", "One could not be reached to refresh the connection. The tokens were kept; try again.");
335
+ }
336
+ if (answer.ok) {
337
+ // Both tokens: One rotates the pair on every refresh.
338
+ const next = toTokens(answer.body);
339
+ await tokenStore.saveTokens(userId, next);
340
+ return next;
341
+ }
342
+ if (isDeadGrant(answer)) return retire(userId, current, answer.status);
343
+ // A server error, a rate limit, a misconfigured secret: nothing says
344
+ // the grant is gone, so keep the tokens and let the caller retry.
345
+ throw new OneConnectError("request_failed", `One could not refresh the connection (HTTP ${answer.status}). The tokens were kept; try again.`, answer.status);
346
+ });
347
+
348
+ /** One refresh per user in this process; concurrent callers share it. */
349
+ const singleFlight = (userId, job) => {
231
350
  const inFlight = refreshing.get(userId);
232
351
  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;
352
+ const running = job().finally(() => refreshing.delete(userId));
353
+ refreshing.set(userId, running);
354
+ return running;
256
355
  };
257
- const getAccessToken = async userId => {
356
+ const refreshTokens = userId => singleFlight(userId, async () => {
357
+ const before = await tokenStore.loadTokens(userId);
358
+ if (!before) throw notConnected();
359
+ // Rotate the pair seen now; a pair someone else rotated since is
360
+ // already fresh.
361
+ return refreshUnderLock(userId, current => current.refreshToken === before.refreshToken);
362
+ });
363
+ const refreshIfExpiring = async (userId, options = {}) => {
364
+ const withinMs = options.withinMs ?? REFRESH_MARGIN_MS;
258
365
  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;
366
+ if (!tokens) throw notConnected();
367
+ if (!expiresWithin(tokens, withinMs)) return tokens;
368
+ return singleFlight(userId, () => refreshUnderLock(userId, current => expiresWithin(current, withinMs)));
262
369
  };
370
+ const getAccessToken = async userId => (await refreshIfExpiring(userId)).accessToken;
263
371
  const oneFetch = async (userId, path, init = {}) => {
264
372
  const accessToken = await getAccessToken(userId);
265
373
  const headers = new Headers(init.headers);
@@ -335,6 +443,7 @@ function createOneConnect(config) {
335
443
  getAccessToken,
336
444
  getTokens: userId => tokenStore.loadTokens(userId),
337
445
  refreshTokens,
446
+ refreshIfExpiring,
338
447
  disconnect: userId => tokenStore.clearTokens(userId),
339
448
  listConnections,
340
449
  listActions,
@@ -345,5 +454,6 @@ function createOneConnect(config) {
345
454
 
346
455
  exports.OneConnectError = OneConnectError;
347
456
  exports.createOneConnect = createOneConnect;
457
+ exports.refreshTokenExpiresAt = refreshTokenExpiresAt;
348
458
  exports.tenancyHeaders = tenancyHeaders;
349
459
  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>;
@@ -70,6 +70,18 @@ function tenancyHeaders(accessToken) {
70
70
  }
71
71
  }
72
72
 
73
+ /** When a refresh token stops working, in epoch milliseconds, from its
74
+ * `exp` claim. Null when the token carries no readable expiry, in which
75
+ * case only One can say whether it still works. */
76
+ function refreshTokenExpiresAt(refreshToken) {
77
+ try {
78
+ const payload = JSON.parse(Buffer.from(refreshToken.split(".")[1], "base64url").toString());
79
+ return typeof payload.exp === "number" && Number.isFinite(payload.exp) ? payload.exp * 1000 : null;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
73
85
  /** The scopes the token was granted, from its claims. Display only. */
74
86
  function tokenScopes(accessToken) {
75
87
  try {
@@ -89,8 +101,8 @@ function tokenScopes(accessToken) {
89
101
 
90
102
  /**
91
103
  * Where the app keeps each user's tokens: its database, a cache, an
92
- * encrypted cookie. The SDK never sees a token outside these three
93
- * calls. `userId` is the app's own id for its user.
104
+ * encrypted cookie. The SDK never sees a token outside these calls.
105
+ * `userId` is the app's own id for its user.
94
106
  */
95
107
 
96
108
  /** The transaction cookie the authorize leg sets and the callback reads. */
@@ -99,6 +111,14 @@ function tokenScopes(accessToken) {
99
111
 
100
112
  /** One catalog action for a platform. */
101
113
 
114
+ /**
115
+ * - `not_connected`: no tokens are stored for this user.
116
+ * - `refresh_failed`: One declared the grant dead (revoked, expired or
117
+ * reused). The tokens were cleared; ask the user to connect again.
118
+ * - `request_failed`: One answered with an error or could not be
119
+ * reached. During a refresh the tokens are kept, so retry later.
120
+ */
121
+
102
122
  class OneConnectError extends Error {
103
123
  constructor(code, message, status) {
104
124
  super(message);
@@ -126,12 +146,26 @@ class OneConnectError extends Error {
126
146
  * const reply = await oneConnect.runAction(userId, { connectionKey, actionId, method, path });
127
147
  *
128
148
  * The Next.js and Node adapters turn the first two into route handlers.
149
+ *
150
+ * Refresh. One's access token lives an hour by default, its refresh token 30
151
+ * days; every refresh rotates both, and One treats a second use of a
152
+ * rotated refresh token as theft and revokes the whole grant. So the
153
+ * client refreshes one user at a time (in this process always, across
154
+ * processes through `tokenStore.withLock`), re-reads the store before
155
+ * spending a refresh token, clears tokens only when One declares the
156
+ * grant dead, and never lets a failing old pair delete a newer one.
129
157
  */
130
158
 
131
159
  /** Refresh this long before expiry, so a call never races the clock. */
132
160
  const REFRESH_MARGIN_MS = 60_000;
133
161
  const CATALOG_PAGE_SIZE = 100;
134
162
  const CATALOG_MAX_PAGES = 20;
163
+
164
+ /** What One's token endpoint answered. A network failure throws instead. */
165
+
166
+ /** The one refusal that means the grant is gone for good: revoked by the
167
+ * user, expired, or burned by a reused refresh token (RFC 6749 §5.2). */
168
+ const isDeadGrant = answer => !answer.ok && answer.status === 400 && answer.error === "invalid_grant";
135
169
  function createOneConnect(config) {
136
170
  const oneApiUrl = (config.oneApiUrl ?? DEFAULT_ONE_API_URL).replace(/\/+$/, "");
137
171
  const authorizeUrl = `${oneApiUrl}/oauth/authorize`;
@@ -152,7 +186,7 @@ function createOneConnect(config) {
152
186
  if (message) url.searchParams.set(RETURN_MESSAGE_PARAM, message);
153
187
  return url.toString();
154
188
  };
155
- const exchange = async body => {
189
+ const postToken = async body => {
156
190
  const response = await fetch(tokenUrl, {
157
191
  method: "POST",
158
192
  headers: {
@@ -161,10 +195,40 @@ function createOneConnect(config) {
161
195
  },
162
196
  body
163
197
  });
164
- if (!response.ok) {
165
- throw new OneConnectError("request_failed", `One refused the token request (HTTP ${response.status}).`, response.status);
198
+ if (response.ok) return {
199
+ ok: true,
200
+ body: await response.json()
201
+ };
202
+ let error;
203
+ try {
204
+ const refusal = await response.json();
205
+ if (typeof refusal.error === "string") error = refusal.error;
206
+ } catch {
207
+ /* not an OAuth error body: a proxy page or a server error */
208
+ }
209
+ return {
210
+ ok: false,
211
+ status: response.status,
212
+ error
213
+ };
214
+ };
215
+ const exchange = async body => {
216
+ const answer = await postToken(body);
217
+ if (!answer.ok) {
218
+ throw new OneConnectError("request_failed", `One refused the token request (HTTP ${answer.status}).`, answer.status);
166
219
  }
167
- return await response.json();
220
+ return answer.body;
221
+ };
222
+
223
+ /** Runs `run` under the app's cross-process lock for this user, when
224
+ * the store has one. */
225
+ const locked = (userId, run) => tokenStore.withLock ? tokenStore.withLock(userId, run) : run();
226
+
227
+ /** Whether either token of the pair stops working within `withinMs`. */
228
+ const expiresWithin = (tokens, withinMs) => {
229
+ const horizon = Date.now() + withinMs;
230
+ const refreshExpiresAt = refreshTokenExpiresAt(tokens.refreshToken);
231
+ return tokens.expiresAt <= horizon || refreshExpiresAt !== null && refreshExpiresAt <= horizon;
168
232
  };
169
233
  const toTokens = response => ({
170
234
  accessToken: response.access_token,
@@ -214,7 +278,9 @@ function createOneConnect(config) {
214
278
  redirect_uri: config.redirectUri,
215
279
  code_verifier: verifier
216
280
  })));
217
- await tokenStore.saveTokens(input.userId, tokens);
281
+ // Under the lock, so a refresh in flight on another server cannot
282
+ // interleave with this save.
283
+ await locked(input.userId, () => tokenStore.saveTokens(input.userId, tokens));
218
284
  } catch (error) {
219
285
  const status = error instanceof OneConnectError ? error.status : undefined;
220
286
  return fail("failed", status ? `One rejected the code exchange (HTTP ${status}).` : "One could not be reached to complete the connection.");
@@ -225,39 +291,81 @@ function createOneConnect(config) {
225
291
  clearCookieName: cookieName
226
292
  };
227
293
  };
228
- const refreshTokens = userId => {
294
+ const notConnected = () => new OneConnectError("not_connected", "This user is not connected.");
295
+
296
+ /**
297
+ * The grant behind `failed` is dead. Clears it, unless a newer pair
298
+ * landed while it was failing (a reconnect's callback, another
299
+ * server's refresh): that pair is returned instead, because the user
300
+ * did nothing wrong and deleting it would disconnect them.
301
+ */
302
+ const retire = async (userId, failed, status) => {
303
+ const latest = await tokenStore.loadTokens(userId);
304
+ if (latest && latest.refreshToken !== failed.refreshToken) return latest;
305
+ await tokenStore.clearTokens(userId, failed);
306
+ throw new OneConnectError("refresh_failed", "The connection to One has expired or was revoked. Ask the user to connect again.", status);
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 = (userId, stillNeeded) => locked(userId, async () => {
317
+ const current = await tokenStore.loadTokens(userId);
318
+ if (!current) throw notConnected();
319
+ if (!stillNeeded(current)) return current;
320
+
321
+ // An expired refresh token cannot work, and One answers one with a
322
+ // server error rather than invalid_grant, so settle it here.
323
+ const refreshExpiresAt = refreshTokenExpiresAt(current.refreshToken);
324
+ if (refreshExpiresAt !== null && refreshExpiresAt <= Date.now()) return retire(userId, current);
325
+ let answer;
326
+ try {
327
+ answer = await postToken(new URLSearchParams({
328
+ grant_type: "refresh_token",
329
+ refresh_token: current.refreshToken
330
+ }));
331
+ } catch {
332
+ throw new OneConnectError("request_failed", "One could not be reached to refresh the connection. The tokens were kept; try again.");
333
+ }
334
+ if (answer.ok) {
335
+ // Both tokens: One rotates the pair on every refresh.
336
+ const next = toTokens(answer.body);
337
+ await tokenStore.saveTokens(userId, next);
338
+ return next;
339
+ }
340
+ if (isDeadGrant(answer)) return retire(userId, current, answer.status);
341
+ // A server error, a rate limit, a misconfigured secret: nothing says
342
+ // the grant is gone, so keep the tokens and let the caller retry.
343
+ throw new OneConnectError("request_failed", `One could not refresh the connection (HTTP ${answer.status}). The tokens were kept; try again.`, answer.status);
344
+ });
345
+
346
+ /** One refresh per user in this process; concurrent callers share it. */
347
+ const singleFlight = (userId, job) => {
229
348
  const inFlight = refreshing.get(userId);
230
349
  if (inFlight) return inFlight;
231
- const job = (async () => {
232
- const current = await tokenStore.loadTokens(userId);
233
- if (!current) throw new OneConnectError("not_connected", "This user is not connected.");
234
- try {
235
- const next = toTokens(await exchange(new URLSearchParams({
236
- grant_type: "refresh_token",
237
- refresh_token: current.refreshToken
238
- })));
239
- // Both tokens: One rotates the pair on every refresh.
240
- await tokenStore.saveTokens(userId, next);
241
- return next;
242
- } catch (error) {
243
- // The family is dead: revoked, expired or reused. Keeping the
244
- // pair would only fail again; the user has to reconnect.
245
- await tokenStore.clearTokens(userId);
246
- const status = error instanceof OneConnectError ? error.status : undefined;
247
- throw new OneConnectError("refresh_failed", "The connection to One has expired or was revoked. Ask the user to connect again.", status);
248
- } finally {
249
- refreshing.delete(userId);
250
- }
251
- })();
252
- refreshing.set(userId, job);
253
- return job;
350
+ const running = job().finally(() => refreshing.delete(userId));
351
+ refreshing.set(userId, running);
352
+ return running;
254
353
  };
255
- const getAccessToken = async userId => {
354
+ const refreshTokens = userId => singleFlight(userId, async () => {
355
+ const before = await tokenStore.loadTokens(userId);
356
+ if (!before) throw notConnected();
357
+ // Rotate the pair seen now; a pair someone else rotated since is
358
+ // already fresh.
359
+ return refreshUnderLock(userId, current => current.refreshToken === before.refreshToken);
360
+ });
361
+ const refreshIfExpiring = async (userId, options = {}) => {
362
+ const withinMs = options.withinMs ?? REFRESH_MARGIN_MS;
256
363
  const tokens = await tokenStore.loadTokens(userId);
257
- if (!tokens) throw new OneConnectError("not_connected", "This user is not connected.");
258
- if (Date.now() < tokens.expiresAt - REFRESH_MARGIN_MS) return tokens.accessToken;
259
- return (await refreshTokens(userId)).accessToken;
364
+ if (!tokens) throw notConnected();
365
+ if (!expiresWithin(tokens, withinMs)) return tokens;
366
+ return singleFlight(userId, () => refreshUnderLock(userId, current => expiresWithin(current, withinMs)));
260
367
  };
368
+ const getAccessToken = async userId => (await refreshIfExpiring(userId)).accessToken;
261
369
  const oneFetch = async (userId, path, init = {}) => {
262
370
  const accessToken = await getAccessToken(userId);
263
371
  const headers = new Headers(init.headers);
@@ -333,6 +441,7 @@ function createOneConnect(config) {
333
441
  getAccessToken,
334
442
  getTokens: userId => tokenStore.loadTokens(userId),
335
443
  refreshTokens,
444
+ refreshIfExpiring,
336
445
  disconnect: userId => tokenStore.clearTokens(userId),
337
446
  listConnections,
338
447
  listActions,
@@ -341,4 +450,4 @@ function createOneConnect(config) {
341
450
  };
342
451
  }
343
452
 
344
- export { OneConnectError, createOneConnect, tenancyHeaders, tokenScopes };
453
+ export { OneConnectError, createOneConnect, refreshTokenExpiresAt, tenancyHeaders, tokenScopes };
@@ -20,5 +20,9 @@ export declare function basicAuthorization(clientId: string, clientSecret: strin
20
20
  * these headers, and without them the call runs in the user's personal
21
21
  * space. The access token's claims name the tenant, so echo them. */
22
22
  export declare function tenancyHeaders(accessToken: string): Record<string, string>;
23
+ /** When a refresh token stops working, in epoch milliseconds, from its
24
+ * `exp` claim. Null when the token carries no readable expiry, in which
25
+ * case only One can say whether it still works. */
26
+ export declare function refreshTokenExpiresAt(refreshToken: string): number | null;
23
27
  /** The scopes the token was granted, from its claims. Display only. */
24
28
  export declare function tokenScopes(accessToken: string): string[];
@@ -11,13 +11,44 @@ export interface OneConnectTokens {
11
11
  }
12
12
  /**
13
13
  * Where the app keeps each user's tokens: its database, a cache, an
14
- * encrypted cookie. The SDK never sees a token outside these three
15
- * calls. `userId` is the app's own id for its user.
14
+ * encrypted cookie. The SDK never sees a token outside these calls.
15
+ * `userId` is the app's own id for its user.
16
16
  */
17
17
  export interface OneConnectTokenStore {
18
18
  saveTokens: (userId: string, tokens: OneConnectTokens) => Promise<void>;
19
19
  loadTokens: (userId: string) => Promise<OneConnectTokens | null>;
20
- clearTokens: (userId: string) => Promise<void>;
20
+ /**
21
+ * Deletes the user's tokens.
22
+ *
23
+ * `failed` is set when the SDK clears because One declared that pair
24
+ * dead. Delete only when the stored refresh token is still
25
+ * `failed.refreshToken`: a newer pair saved in the meantime (a
26
+ * reconnect, another server's refresh) must survive. `failed` is
27
+ * undefined for `disconnect`, which always deletes.
28
+ */
29
+ clearTokens: (userId: string, failed?: OneConnectTokens) => Promise<void>;
30
+ /**
31
+ * Runs `run` while holding a lock on this user that every server and
32
+ * worker of the app shares: a Postgres advisory lock, a Redis lock, a
33
+ * row lock. The SDK loads, refreshes and saves the user's tokens
34
+ * inside it.
35
+ *
36
+ * Required when the app runs more than one process (serverless,
37
+ * several instances, a background worker). One rotates the refresh
38
+ * token on every use and treats a second use of the old one as theft,
39
+ * revoking the whole grant, so two processes refreshing the same user
40
+ * at once disconnect that user. Without a lock the SDK can only stop
41
+ * that inside a single process.
42
+ *
43
+ * Hold it for at least 60 seconds before any timeout: it spans one
44
+ * call to One's token endpoint.
45
+ */
46
+ withLock?: <T>(userId: string, run: () => Promise<T>) => Promise<T>;
47
+ }
48
+ export interface RefreshIfExpiringOptions {
49
+ /** Refresh when the access token or the refresh token expires within
50
+ * this many milliseconds. One minute when omitted. */
51
+ withinMs?: number;
21
52
  }
22
53
  export interface OneConnectServerConfig {
23
54
  /** The app's client id from the dashboard. */
@@ -128,6 +159,13 @@ export interface RunActionResult {
128
159
  blockedByGrant: boolean;
129
160
  data: unknown;
130
161
  }
162
+ /**
163
+ * - `not_connected`: no tokens are stored for this user.
164
+ * - `refresh_failed`: One declared the grant dead (revoked, expired or
165
+ * reused). The tokens were cleared; ask the user to connect again.
166
+ * - `request_failed`: One answered with an error or could not be
167
+ * reached. During a refresh the tokens are kept, so retry later.
168
+ */
131
169
  export type OneConnectErrorCode = "not_connected" | "refresh_failed" | "request_failed";
132
170
  export declare class OneConnectError extends Error {
133
171
  readonly code: OneConnectErrorCode;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@withone/connect",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "One Connect for your app: the button your users press, the two backend routes as one import, and a server client that calls One with the grant. Users keep their connections in One; your app holds only what they granted.",
5
5
  "files": [
6
6
  "dist",
@@ -72,7 +72,9 @@ export const oneConnect = createOneConnect({
72
72
  tokenStore: {
73
73
  saveTokens: (userId, tokens) => /* write to this app's database, encrypted, keyed by its user */,
74
74
  loadTokens: (userId) => /* read; null when the user never connected */,
75
- clearTokens: (userId) => /* delete */,
75
+ clearTokens: (userId, failed) => /* delete; when `failed` is set, only if the stored refreshToken is still failed.refreshToken */,
76
+ // Required when the app runs more than one process (serverless, several instances, a worker):
77
+ withLock: (userId, run) => /* run() while holding a per-user lock all processes share, e.g. pg_advisory_xact_lock */,
76
78
  },
77
79
  });
78
80
  ```
@@ -81,6 +83,14 @@ export const oneConnect = createOneConnect({
81
83
  id as the key. Implement the store on whatever the app already uses; do not
82
84
  add a database for it.
83
85
 
86
+ One rotates the refresh token on every refresh and revokes the whole grant if
87
+ an old one is used again. So when two processes could refresh the same user,
88
+ `withLock` is not optional: without it, a web request and a worker refreshing
89
+ together disconnect the user. The README's "Tokens" section has a Postgres
90
+ version. For background work, call `refreshIfExpiring(userId, { withinMs })`:
91
+ every few minutes with a window of minutes to keep access tokens warm, and
92
+ once a day with a window of days so idle users' 30-day refresh tokens renew.
93
+
84
94
  ## 4 - The two routes
85
95
 
86
96
  Next.js App Router (also Remix, SvelteKit, Hono, Bun: anything with the web `Request`):
@@ -170,14 +180,22 @@ grant; the provider was never called. Do not retry. Any other `/v1` call:
170
180
  `oneConnect.fetch(userId, "/connections", init)`.
171
181
 
172
182
  `OneConnectError` codes: `not_connected` (no tokens stored), `refresh_failed`
173
- (One refused the refresh; the tokens were cleared; ask the user to connect
174
- again), `request_failed` (One answered with an error; `status` carries it).
183
+ (One declared the grant dead: revoked, expired or reused; the tokens were
184
+ cleared; ask the user to connect again), `request_failed` (One answered with
185
+ an error or could not be reached; `status` carries it; during a refresh the
186
+ tokens are KEPT, so retry later rather than asking the user to reconnect).
187
+
188
+ To ask users for more tools later, edit the app's permission set in the
189
+ dashboard. The next Connect shows only the new tools as "needs one more
190
+ connection" and the callback stores the new pair; no code change.
175
191
 
176
192
  ## 7 - Rules
177
193
 
178
194
  - Never put the secret in a client bundle, a log line or an error report.
179
195
  - Store tokens encrypted, keyed by the app's user. Delete them when the user
180
- is deleted. The client deletes them itself when a refresh fails.
196
+ is deleted. The client deletes them itself only when One declares the
197
+ grant dead, and never deletes a newer pair saved in the meantime.
198
+ - Give the store `withLock` whenever more than one process can refresh.
181
199
  - The registered redirect URI and `ONE_REDIRECT_URI` must be the same string.
182
200
  - Do not build a completion page. The callback redirect is the completion.
183
201
  - Do not write the OAuth steps by hand when the package exposes them.
@@ -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"