@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 +86 -3
- package/dist/server/index.cjs.js +145 -35
- package/dist/server/index.d.ts +11 -3
- package/dist/server/index.esm.js +145 -36
- package/dist/server/oauth.d.ts +4 -0
- package/dist/server/types.d.ts +41 -3
- package/package.json +1 -1
- package/skills/one-connect/SKILL.md +22 -4
- package/src/server/index.ts +176 -41
- package/src/server/oauth.ts +16 -0
- package/src/server/types.ts +42 -3
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.
|
|
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
|
|
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
|
package/dist/server/index.cjs.js
CHANGED
|
@@ -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
|
|
95
|
-
*
|
|
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
|
|
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 (
|
|
167
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
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
|
|
260
|
-
if (
|
|
261
|
-
return (
|
|
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;
|
package/dist/server/index.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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>;
|
package/dist/server/index.esm.js
CHANGED
|
@@ -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
|
|
93
|
-
*
|
|
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
|
|
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 (
|
|
165
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
232
|
-
|
|
233
|
-
|
|
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
|
|
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
|
|
258
|
-
if (
|
|
259
|
-
return (
|
|
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 };
|
package/dist/server/oauth.d.ts
CHANGED
|
@@ -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[];
|
package/dist/server/types.d.ts
CHANGED
|
@@ -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
|
|
15
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
|
174
|
-
again), `request_failed` (One answered with
|
|
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
|
|
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.
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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 (
|
|
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 ${
|
|
129
|
-
|
|
173
|
+
`One refused the token request (HTTP ${answer.status}).`,
|
|
174
|
+
answer.status,
|
|
130
175
|
);
|
|
131
176
|
}
|
|
132
|
-
return
|
|
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
|
-
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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,
|
package/src/server/oauth.ts
CHANGED
|
@@ -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 {
|
package/src/server/types.ts
CHANGED
|
@@ -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
|
|
17
|
-
*
|
|
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
|
-
|
|
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"
|