@withone/connect 0.12.2 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -10
- package/dist/next.d.ts +2 -2
- package/dist/node.d.ts +2 -2
- package/dist/server/credential.d.ts +45 -0
- package/dist/server/index.cjs.js +441 -135
- package/dist/server/index.d.ts +42 -22
- package/dist/server/index.esm.js +440 -136
- package/dist/server/key.d.ts +32 -0
- package/dist/server/token.d.ts +33 -0
- package/dist/server/types.d.ts +76 -8
- package/package.json +1 -1
- package/skills/one-connect/SKILL.md +129 -23
- package/src/next.ts +2 -2
- package/src/node.ts +2 -2
- package/src/server/credential.ts +44 -0
- package/src/server/index.ts +197 -221
- package/src/server/key.ts +142 -0
- package/src/server/token.ts +235 -0
- package/src/server/types.ts +82 -7
package/dist/server/index.esm.js
CHANGED
|
@@ -98,6 +98,27 @@ function tokenScopes(accessToken) {
|
|
|
98
98
|
* the app's server and holds the client secret.
|
|
99
99
|
*/
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* How the app holds a user's grant.
|
|
103
|
+
*
|
|
104
|
+
* - `"key"`: the app's connect key plus a permanent id per user. Nothing
|
|
105
|
+
* expires, so there is nothing to refresh. The default.
|
|
106
|
+
* - `"token"`: an access token and a refresh token per user, which the
|
|
107
|
+
* SDK keeps fresh.
|
|
108
|
+
*/
|
|
109
|
+
|
|
110
|
+
/** Key mode: who a stored user is to One, and where their grant lives. */
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Key mode: where the app keeps the one value the SDK gives it per user.
|
|
114
|
+
* A single string, written when the user connects and the same until
|
|
115
|
+
* they connect again, so one column on the app's user row is enough.
|
|
116
|
+
* `userId` is the app's own id for its user.
|
|
117
|
+
*
|
|
118
|
+
* It is an identifier, not a credential: it does nothing without the
|
|
119
|
+
* app's connect key. No lock is needed, because nothing rotates.
|
|
120
|
+
*/
|
|
121
|
+
|
|
101
122
|
/** What the app stores per user after the exchange. */
|
|
102
123
|
|
|
103
124
|
/**
|
|
@@ -106,6 +127,24 @@ function tokenScopes(accessToken) {
|
|
|
106
127
|
* `userId` is the app's own id for its user.
|
|
107
128
|
*/
|
|
108
129
|
|
|
130
|
+
/** What every app configures, whichever mode it uses. */
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Key mode, the default: pass the app's connect key and a place to keep
|
|
134
|
+
* one value per user.
|
|
135
|
+
*/
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Token mode: pass a token store and the SDK keeps each user's tokens
|
|
139
|
+
* fresh.
|
|
140
|
+
*/
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The mode is whichever credential is configured: a `connectKey` is key
|
|
144
|
+
* mode, a `tokenStore` alone is token mode. Set `mode` to say so
|
|
145
|
+
* explicitly.
|
|
146
|
+
*/
|
|
147
|
+
|
|
109
148
|
/** The transaction cookie the authorize leg sets and the callback reads. */
|
|
110
149
|
|
|
111
150
|
/** One connection the grant reaches, with the access it confers. */
|
|
@@ -113,11 +152,15 @@ function tokenScopes(accessToken) {
|
|
|
113
152
|
/** One catalog action for a platform. */
|
|
114
153
|
|
|
115
154
|
/**
|
|
116
|
-
* - `not_connected`:
|
|
117
|
-
* - `
|
|
118
|
-
*
|
|
155
|
+
* - `not_connected`: nothing is stored for this user.
|
|
156
|
+
* - `reconnect_required` (key mode): One will not act for this user.
|
|
157
|
+
* Their consent was revoked, or the app is deactivated. What the app
|
|
158
|
+
* stored is kept; ask the user to connect again.
|
|
159
|
+
* - `refresh_failed` (token mode): One declared the grant dead (revoked,
|
|
160
|
+
* expired or reused). The tokens were cleared; ask the user to connect
|
|
161
|
+
* again.
|
|
119
162
|
* - `request_failed`: One answered with an error or could not be
|
|
120
|
-
* reached.
|
|
163
|
+
* reached. Nothing stored was changed, so retry later.
|
|
121
164
|
*/
|
|
122
165
|
|
|
123
166
|
class OneConnectError extends Error {
|
|
@@ -129,12 +172,293 @@ class OneConnectError extends Error {
|
|
|
129
172
|
}
|
|
130
173
|
}
|
|
131
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Key mode: the app holds one connect key, and one permanent id per
|
|
177
|
+
* user. Both go on every call; One reads the user's consent live each
|
|
178
|
+
* time. Nothing expires, so there is nothing to refresh, rotate or lock.
|
|
179
|
+
*
|
|
180
|
+
* X-One-Secret: the app's connect key
|
|
181
|
+
* X-One-Connect-User-Id: cu_… for the user being acted for
|
|
182
|
+
*
|
|
183
|
+
* The id comes from the one code exchange the callback makes. It is the
|
|
184
|
+
* same for a user for the life of the app, through revocation and
|
|
185
|
+
* re-consent, so it is saved once and never deleted by the SDK.
|
|
186
|
+
*/
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
/** `cu_` and a hex HMAC-SHA256, as One derives it. */
|
|
190
|
+
const CONNECT_USER_ID = /^cu_[0-9a-f]{64}$/;
|
|
191
|
+
const ORGANIZATION = "org=";
|
|
192
|
+
const PROJECT = "project=";
|
|
193
|
+
const SEPARATOR = ";";
|
|
194
|
+
const ORGANIZATION_HEADER = "X-One-Organization-Id";
|
|
195
|
+
const PROJECT_HEADER = "X-One-Project-Id";
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The one value an app stores per user, as a string for one column:
|
|
199
|
+
* `cu_…`, then the space the user granted from when it is not their
|
|
200
|
+
* personal one (`cu_…;org=<id>;project=<id>`).
|
|
201
|
+
*
|
|
202
|
+
* The space travels with the id because One resolves a call's tenant
|
|
203
|
+
* from headers: a grant made from an organization lives there, and a
|
|
204
|
+
* call that names none runs in the user's personal space and reaches
|
|
205
|
+
* nothing. In token mode the access token names the space on every
|
|
206
|
+
* call; here there is no token after the exchange, so it is kept.
|
|
207
|
+
*/
|
|
208
|
+
function encodeUserReference(reference) {
|
|
209
|
+
const parts = [reference.connectUserId];
|
|
210
|
+
if (reference.organizationId) parts.push(`${ORGANIZATION}${reference.organizationId}`);
|
|
211
|
+
if (reference.projectId) parts.push(`${PROJECT}${reference.projectId}`);
|
|
212
|
+
return parts.join(SEPARATOR);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Reads a stored value back. Null when it is not one this SDK wrote. */
|
|
216
|
+
function parseUserReference(value) {
|
|
217
|
+
const [connectUserId, ...rest] = value.trim().split(SEPARATOR);
|
|
218
|
+
if (!CONNECT_USER_ID.test(connectUserId)) return null;
|
|
219
|
+
const reference = {
|
|
220
|
+
connectUserId
|
|
221
|
+
};
|
|
222
|
+
for (const part of rest) {
|
|
223
|
+
if (part.startsWith(ORGANIZATION)) reference.organizationId = part.slice(ORGANIZATION.length);else if (part.startsWith(PROJECT)) reference.projectId = part.slice(PROJECT.length);
|
|
224
|
+
}
|
|
225
|
+
return reference;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* One answers a credential it will not accept with the bare status line
|
|
230
|
+
* and nothing else. Anything richer came from the route or from the
|
|
231
|
+
* provider behind a passthrough, and is not a verdict on the credential.
|
|
232
|
+
*/
|
|
233
|
+
const isBareRefusal = (status, body, reason) => body.trim() === `${status} ${reason}`;
|
|
234
|
+
function createKeyCredential(connectKey, userStore) {
|
|
235
|
+
const load = async userId => {
|
|
236
|
+
const stored = await userStore.loadUser(userId);
|
|
237
|
+
return stored ? parseUserReference(stored) : null;
|
|
238
|
+
};
|
|
239
|
+
return {
|
|
240
|
+
connected: async (userId, response) => {
|
|
241
|
+
const connectUserId = response.connect_user_id;
|
|
242
|
+
if (!connectUserId || !CONNECT_USER_ID.test(connectUserId)) {
|
|
243
|
+
throw new OneConnectError("request_failed", "One did not return a connect user id for this user, so key mode cannot act for them.");
|
|
244
|
+
}
|
|
245
|
+
// The space the user granted from, read once from the token that
|
|
246
|
+
// named it. The token itself is not kept.
|
|
247
|
+
const space = tenancyHeaders(response.access_token);
|
|
248
|
+
await userStore.saveUser(userId, encodeUserReference({
|
|
249
|
+
connectUserId,
|
|
250
|
+
organizationId: space[ORGANIZATION_HEADER],
|
|
251
|
+
projectId: space[PROJECT_HEADER]
|
|
252
|
+
}));
|
|
253
|
+
},
|
|
254
|
+
headers: async userId => {
|
|
255
|
+
const reference = await load(userId);
|
|
256
|
+
if (!reference) throw new OneConnectError("not_connected", "This user is not connected.");
|
|
257
|
+
const headers = {
|
|
258
|
+
"X-One-Secret": connectKey,
|
|
259
|
+
"X-One-Connect-User-Id": reference.connectUserId
|
|
260
|
+
};
|
|
261
|
+
if (reference.organizationId) headers[ORGANIZATION_HEADER] = reference.organizationId;
|
|
262
|
+
if (reference.projectId) headers[PROJECT_HEADER] = reference.projectId;
|
|
263
|
+
return headers;
|
|
264
|
+
},
|
|
265
|
+
isConnected: async userId => (await load(userId)) !== null,
|
|
266
|
+
disconnect: userId => userStore.clearUser(userId),
|
|
267
|
+
refusal: (status, body) => {
|
|
268
|
+
// One does not say which: the consent was revoked, was never given
|
|
269
|
+
// in this key's environment, or the app is deactivated. The saved id
|
|
270
|
+
// is kept either way, because it is the same id after a reconnect.
|
|
271
|
+
if (status === 403 && isBareRefusal(status, body, "Forbidden")) {
|
|
272
|
+
return new OneConnectError("reconnect_required", "One will not act for this user: their consent was revoked, or the app is deactivated. Ask the user to connect again. If every user fails, check that the connect key is for this environment and that the app is active.", status);
|
|
273
|
+
}
|
|
274
|
+
if (status === 401 && isBareRefusal(status, body, "Unauthorized")) {
|
|
275
|
+
return new OneConnectError("request_failed", "One did not accept the connect key. Check that it is this app's connect key, minted on the app's page in the One dashboard.", status);
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
},
|
|
279
|
+
getConnectUserId: async userId => (await load(userId))?.connectUserId ?? null
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Token mode: the app holds an access token and a refresh token per
|
|
285
|
+
* user, and the SDK refreshes them with One.
|
|
286
|
+
*
|
|
287
|
+
* Lifetimes. The access token lives as long as the app's Token lifetime
|
|
288
|
+
* setting says (dashboard, Advanced, when creating or editing the app: 7
|
|
289
|
+
* days, 30 days, 90 days or 1 year; 30 days unless changed). An app that
|
|
290
|
+
* never had the setting gets an hour. The refresh token always lives 30
|
|
291
|
+
* days, whatever the access token's lifetime, and only a live refresh
|
|
292
|
+
* token can buy a new pair.
|
|
293
|
+
*
|
|
294
|
+
* Who refreshes. Before each call the client refreshes a pair that is
|
|
295
|
+
* within a minute of expiring. Refreshing earlier than that is the app's
|
|
296
|
+
* job: it runs `refreshIfExpiring` on a schedule, so the refresh token
|
|
297
|
+
* never runs out. When it has run out, the access token is used until it
|
|
298
|
+
* expires too, and only then is the user asked to connect again.
|
|
299
|
+
*
|
|
300
|
+
* Rotation. Every refresh rotates both tokens, and One treats a second
|
|
301
|
+
* use of a rotated refresh token as theft and revokes the whole grant.
|
|
302
|
+
* So the client refreshes one user at a time (in this process always,
|
|
303
|
+
* across processes through `tokenStore.withLock`), re-reads the store
|
|
304
|
+
* before spending a refresh token, clears tokens only when the grant is
|
|
305
|
+
* dead, and never lets a failing old pair delete a newer one.
|
|
306
|
+
*/
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
/** Refresh this long before expiry, so a call never races the clock. */
|
|
310
|
+
const REFRESH_MARGIN_MS = 60_000;
|
|
311
|
+
|
|
312
|
+
/** The one refusal that means the grant is gone for good: revoked by the
|
|
313
|
+
* user, expired, or burned by a reused refresh token (RFC 6749 §5.2). */
|
|
314
|
+
const isDeadGrant = answer => !answer.ok && answer.status === 400 && answer.error === "invalid_grant";
|
|
315
|
+
function createTokenCredential(tokenStore, postToken) {
|
|
316
|
+
/** One refresh in flight per user: two concurrent refreshes with the
|
|
317
|
+
* same refresh token trip One's reuse detection. */
|
|
318
|
+
const refreshing = new Map();
|
|
319
|
+
|
|
320
|
+
/** Runs `run` under the app's cross-process lock for this user, when
|
|
321
|
+
* the store has one. */
|
|
322
|
+
const locked = (userId, run) => tokenStore.withLock ? tokenStore.withLock(userId, run) : run();
|
|
323
|
+
|
|
324
|
+
/** Whether either token of the pair stops working within `withinMs`. */
|
|
325
|
+
const expiresWithin = (tokens, withinMs) => {
|
|
326
|
+
const horizon = Date.now() + withinMs;
|
|
327
|
+
const refreshExpiresAt = refreshTokenExpiresAt(tokens.refreshToken);
|
|
328
|
+
return tokens.expiresAt <= horizon || refreshExpiresAt !== null && refreshExpiresAt <= horizon;
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
/** Whether the access token is too close to expiry to make a call with. */
|
|
332
|
+
const accessEnding = tokens => tokens.expiresAt <= Date.now() + REFRESH_MARGIN_MS;
|
|
333
|
+
|
|
334
|
+
/** Whether the refresh token has run out. One with no readable expiry
|
|
335
|
+
* counts as live: only One can say otherwise. */
|
|
336
|
+
const refreshSpent = tokens => {
|
|
337
|
+
const refreshExpiresAt = refreshTokenExpiresAt(tokens.refreshToken);
|
|
338
|
+
return refreshExpiresAt !== null && refreshExpiresAt <= Date.now();
|
|
339
|
+
};
|
|
340
|
+
const toTokens = response => ({
|
|
341
|
+
accessToken: response.access_token,
|
|
342
|
+
refreshToken: response.refresh_token,
|
|
343
|
+
expiresAt: Date.now() + response.expires_in * 1000
|
|
344
|
+
});
|
|
345
|
+
const notConnected = () => new OneConnectError("not_connected", "This user is not connected.");
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The grant behind `failed` is dead. Clears it, unless a newer pair
|
|
349
|
+
* landed while it was failing (a reconnect's callback, another
|
|
350
|
+
* server's refresh): that pair is returned instead, because the user
|
|
351
|
+
* did nothing wrong and deleting it would disconnect them.
|
|
352
|
+
*/
|
|
353
|
+
const retire = async (userId, failed, status) => {
|
|
354
|
+
const latest = await tokenStore.loadTokens(userId);
|
|
355
|
+
if (latest && latest.refreshToken !== failed.refreshToken) return latest;
|
|
356
|
+
await tokenStore.clearTokens(userId, failed);
|
|
357
|
+
throw new OneConnectError("refresh_failed", "The connection to One has expired or was revoked. Ask the user to connect again.", status);
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The one place a refresh token is spent. Under the app's lock it
|
|
362
|
+
* re-reads the store, and refreshes only when `stillNeeded` says the
|
|
363
|
+
* stored pair still needs it: another process may have refreshed while
|
|
364
|
+
* this one waited, and spending the same refresh token twice makes One
|
|
365
|
+
* revoke the grant.
|
|
366
|
+
*/
|
|
367
|
+
const refreshUnderLock = (userId, stillNeeded) => locked(userId, async () => {
|
|
368
|
+
const current = await tokenStore.loadTokens(userId);
|
|
369
|
+
if (!current) throw notConnected();
|
|
370
|
+
if (!stillNeeded(current)) return current;
|
|
371
|
+
|
|
372
|
+
// An expired refresh token cannot work, and One answers one with a
|
|
373
|
+
// server error rather than invalid_grant, so settle it here. The
|
|
374
|
+
// access token may outlive it (a 90-day or 1-year lifetime): that
|
|
375
|
+
// one still works, so the pair is kept until it ends too.
|
|
376
|
+
if (refreshSpent(current)) return accessEnding(current) ? retire(userId, current) : current;
|
|
377
|
+
let answer;
|
|
378
|
+
try {
|
|
379
|
+
answer = await postToken(new URLSearchParams({
|
|
380
|
+
grant_type: "refresh_token",
|
|
381
|
+
refresh_token: current.refreshToken
|
|
382
|
+
}));
|
|
383
|
+
} catch {
|
|
384
|
+
throw new OneConnectError("request_failed", "One could not be reached to refresh the connection. The tokens were kept; try again.");
|
|
385
|
+
}
|
|
386
|
+
if (answer.ok) {
|
|
387
|
+
// Both tokens: One rotates the pair on every refresh.
|
|
388
|
+
const next = toTokens(answer.body);
|
|
389
|
+
await tokenStore.saveTokens(userId, next);
|
|
390
|
+
return next;
|
|
391
|
+
}
|
|
392
|
+
if (isDeadGrant(answer)) return retire(userId, current, answer.status);
|
|
393
|
+
// A server error, a rate limit, a misconfigured secret: nothing says
|
|
394
|
+
// the grant is gone, so keep the tokens and let the caller retry.
|
|
395
|
+
throw new OneConnectError("request_failed", `One could not refresh the connection (HTTP ${answer.status}). The tokens were kept; try again.`, answer.status);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
/** One refresh per user in this process; concurrent callers share it. */
|
|
399
|
+
const singleFlight = (userId, job) => {
|
|
400
|
+
const inFlight = refreshing.get(userId);
|
|
401
|
+
if (inFlight) return inFlight;
|
|
402
|
+
const running = job().finally(() => refreshing.delete(userId));
|
|
403
|
+
refreshing.set(userId, running);
|
|
404
|
+
return running;
|
|
405
|
+
};
|
|
406
|
+
const refreshTokens = userId => singleFlight(userId, async () => {
|
|
407
|
+
const before = await tokenStore.loadTokens(userId);
|
|
408
|
+
if (!before) throw notConnected();
|
|
409
|
+
// Rotate the pair seen now; a pair someone else rotated since is
|
|
410
|
+
// already fresh.
|
|
411
|
+
return refreshUnderLock(userId, current => current.refreshToken === before.refreshToken);
|
|
412
|
+
});
|
|
413
|
+
const refreshIfExpiring = async (userId, options = {}) => {
|
|
414
|
+
const withinMs = options.withinMs ?? REFRESH_MARGIN_MS;
|
|
415
|
+
const tokens = await tokenStore.loadTokens(userId);
|
|
416
|
+
if (!tokens) throw notConnected();
|
|
417
|
+
if (!expiresWithin(tokens, withinMs)) return tokens;
|
|
418
|
+
// Nothing left to refresh with, and the access token still works.
|
|
419
|
+
if (refreshSpent(tokens) && !accessEnding(tokens)) return tokens;
|
|
420
|
+
return singleFlight(userId, () => refreshUnderLock(userId, current => expiresWithin(current, withinMs)));
|
|
421
|
+
};
|
|
422
|
+
const getAccessToken = async userId => (await refreshIfExpiring(userId)).accessToken;
|
|
423
|
+
return {
|
|
424
|
+
// Under the lock, so a refresh in flight on another server cannot
|
|
425
|
+
// interleave with this save.
|
|
426
|
+
connected: (userId, response) => {
|
|
427
|
+
const tokens = toTokens(response);
|
|
428
|
+
return locked(userId, () => tokenStore.saveTokens(userId, tokens));
|
|
429
|
+
},
|
|
430
|
+
headers: async userId => {
|
|
431
|
+
const accessToken = await getAccessToken(userId);
|
|
432
|
+
return {
|
|
433
|
+
Authorization: `Bearer ${accessToken}`,
|
|
434
|
+
...tenancyHeaders(accessToken)
|
|
435
|
+
};
|
|
436
|
+
},
|
|
437
|
+
isConnected: async userId => (await tokenStore.loadTokens(userId)) !== null,
|
|
438
|
+
disconnect: userId => tokenStore.clearTokens(userId),
|
|
439
|
+
// A dead grant surfaces at the refresh, before any call is made.
|
|
440
|
+
refusal: () => null,
|
|
441
|
+
getAccessToken,
|
|
442
|
+
getTokens: userId => tokenStore.loadTokens(userId),
|
|
443
|
+
refreshTokens,
|
|
444
|
+
refreshIfExpiring
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
132
448
|
/**
|
|
133
449
|
* `@withone/connect/server`: the half of One Connect that runs on the
|
|
134
|
-
* app's server. It starts the flow, completes it,
|
|
135
|
-
*
|
|
136
|
-
* here.
|
|
450
|
+
* app's server. It starts the flow, completes it, and makes every call
|
|
451
|
+
* with the grant. The app's secrets never leave here.
|
|
137
452
|
*
|
|
453
|
+
* An app holds a user's grant in one of two ways, and picks by what it
|
|
454
|
+
* configures. Everything else is the same in both.
|
|
455
|
+
*
|
|
456
|
+
* // key mode (default): one connect key for the app, one permanent id
|
|
457
|
+
* // per user, nothing to refresh
|
|
458
|
+
* const oneConnect = createOneConnect({ clientId, clientSecret,
|
|
459
|
+
* redirectUri, permissionSet, connectKey, userStore });
|
|
460
|
+
*
|
|
461
|
+
* // token mode: an access and a refresh token per user, kept fresh
|
|
138
462
|
* const oneConnect = createOneConnect({ clientId, clientSecret,
|
|
139
463
|
* redirectUri, permissionSet, tokenStore });
|
|
140
464
|
*
|
|
@@ -147,40 +471,52 @@ class OneConnectError extends Error {
|
|
|
147
471
|
* const reply = await oneConnect.runAction(userId, { connectionKey, actionId, method, path });
|
|
148
472
|
*
|
|
149
473
|
* The Next.js and Node adapters turn the first two into route handlers.
|
|
150
|
-
*
|
|
151
|
-
* Refresh. One's access token lives an hour by default, its refresh token 30
|
|
152
|
-
* days; every refresh rotates both, and One treats a second use of a
|
|
153
|
-
* rotated refresh token as theft and revokes the whole grant. So the
|
|
154
|
-
* client refreshes one user at a time (in this process always, across
|
|
155
|
-
* processes through `tokenStore.withLock`), re-reads the store before
|
|
156
|
-
* spending a refresh token, clears tokens only when One declares the
|
|
157
|
-
* grant dead, and never lets a failing old pair delete a newer one.
|
|
474
|
+
* See `./key` and `./token` for what each mode stores and sends.
|
|
158
475
|
*/
|
|
159
|
-
|
|
160
|
-
/** Refresh this long before expiry, so a call never races the clock. */
|
|
161
|
-
const REFRESH_MARGIN_MS = 60_000;
|
|
162
476
|
const CATALOG_PAGE_SIZE = 100;
|
|
163
477
|
const CATALOG_MAX_PAGES = 20;
|
|
164
478
|
|
|
165
|
-
/** What
|
|
479
|
+
/** What an app does with One in either mode. */
|
|
480
|
+
|
|
481
|
+
/** The client key mode returns. */
|
|
482
|
+
|
|
483
|
+
/** The client token mode returns. */
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Which mode a config asks for. A stated `mode` wins; otherwise a
|
|
487
|
+
* connect key means key mode and a token store alone means token mode,
|
|
488
|
+
* so an app written before key mode existed keeps working untouched.
|
|
489
|
+
*/
|
|
490
|
+
function resolveMode(config) {
|
|
491
|
+
const given = config;
|
|
492
|
+
const mode = given.mode ?? (given.connectKey ? "key" : given.tokenStore ? "token" : undefined);
|
|
493
|
+
if (mode === undefined) {
|
|
494
|
+
throw new TypeError("createOneConnect needs a credential: pass `connectKey` and `userStore` (key mode, recommended), or `tokenStore` (token mode).");
|
|
495
|
+
}
|
|
496
|
+
if (mode !== "key" && mode !== "token") {
|
|
497
|
+
throw new TypeError('createOneConnect: `mode` is "key" or "token".');
|
|
498
|
+
}
|
|
499
|
+
if (mode === "key" && !(given.connectKey && given.userStore)) {
|
|
500
|
+
throw new TypeError("createOneConnect: key mode needs `connectKey` (mint one on the app's page in the One dashboard) and `userStore`.");
|
|
501
|
+
}
|
|
502
|
+
if (mode === "token" && !given.tokenStore) {
|
|
503
|
+
throw new TypeError("createOneConnect: token mode needs `tokenStore`.");
|
|
504
|
+
}
|
|
505
|
+
return mode;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Key mode: a connect key for the app and one permanent id per user. */
|
|
509
|
+
|
|
510
|
+
/** Token mode: an access and a refresh token per user, kept fresh. */
|
|
166
511
|
|
|
167
|
-
/** The one refusal that means the grant is gone for good: revoked by the
|
|
168
|
-
* user, expired, or burned by a reused refresh token (RFC 6749 §5.2). */
|
|
169
|
-
const isDeadGrant = answer => !answer.ok && answer.status === 400 && answer.error === "invalid_grant";
|
|
170
512
|
function createOneConnect(config) {
|
|
513
|
+
const mode = resolveMode(config);
|
|
171
514
|
const oneApiUrl = (config.oneApiUrl ?? DEFAULT_ONE_API_URL).replace(/\/+$/, "");
|
|
172
515
|
const authorizeUrl = `${oneApiUrl}/oauth/authorize`;
|
|
173
516
|
const tokenUrl = `${oneApiUrl}/oauth/token`;
|
|
174
517
|
const apiUrl = `${oneApiUrl}/v1`;
|
|
175
518
|
const returnTo = config.returnTo ?? "/";
|
|
176
519
|
const scopes = config.scopes ?? DEFAULT_SCOPES;
|
|
177
|
-
const {
|
|
178
|
-
tokenStore
|
|
179
|
-
} = config;
|
|
180
|
-
|
|
181
|
-
/** One refresh in flight per user: two concurrent refreshes with the
|
|
182
|
-
* same refresh token trip One's reuse detection. */
|
|
183
|
-
const refreshing = new Map();
|
|
184
520
|
const returnUrl = (status, code) => {
|
|
185
521
|
const url = new URL(returnTo, config.redirectUri);
|
|
186
522
|
url.searchParams.set(RETURN_STATUS_PARAM, status);
|
|
@@ -221,21 +557,11 @@ function createOneConnect(config) {
|
|
|
221
557
|
return answer.body;
|
|
222
558
|
};
|
|
223
559
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const expiresWithin = (tokens, withinMs) => {
|
|
230
|
-
const horizon = Date.now() + withinMs;
|
|
231
|
-
const refreshExpiresAt = refreshTokenExpiresAt(tokens.refreshToken);
|
|
232
|
-
return tokens.expiresAt <= horizon || refreshExpiresAt !== null && refreshExpiresAt <= horizon;
|
|
233
|
-
};
|
|
234
|
-
const toTokens = response => ({
|
|
235
|
-
accessToken: response.access_token,
|
|
236
|
-
refreshToken: response.refresh_token,
|
|
237
|
-
expiresAt: Date.now() + response.expires_in * 1000
|
|
238
|
-
});
|
|
560
|
+
// The one thing the modes differ in: what is kept per user, and what
|
|
561
|
+
// is sent on a call. `resolveMode` already checked each has its parts.
|
|
562
|
+
const keyCredential = mode === "key" ? createKeyCredential(config.connectKey, config.userStore) : null;
|
|
563
|
+
const tokenCredential = mode === "token" ? createTokenCredential(config.tokenStore, postToken) : null;
|
|
564
|
+
const credential = keyCredential ?? tokenCredential;
|
|
239
565
|
const startAuthorization = (input = {}) => {
|
|
240
566
|
const state = createState();
|
|
241
567
|
const verifier = createPkceVerifier();
|
|
@@ -274,18 +600,17 @@ function createOneConnect(config) {
|
|
|
274
600
|
// foreign or forged state; the code is never exchanged in that case.
|
|
275
601
|
if (!code || !state || !verifier) return fail("expired", "The attempt expired, or its state cookie was missing.");
|
|
276
602
|
try {
|
|
277
|
-
const
|
|
603
|
+
const response = await exchange(new URLSearchParams({
|
|
278
604
|
grant_type: "authorization_code",
|
|
279
605
|
code,
|
|
280
606
|
redirect_uri: config.redirectUri,
|
|
281
607
|
code_verifier: verifier
|
|
282
|
-
}))
|
|
283
|
-
//
|
|
284
|
-
|
|
285
|
-
await locked(input.userId, () => tokenStore.saveTokens(input.userId, tokens));
|
|
608
|
+
}));
|
|
609
|
+
// Tokens in token mode, the user's permanent id in key mode.
|
|
610
|
+
await credential.connected(input.userId, response);
|
|
286
611
|
} catch (error) {
|
|
287
612
|
const status = error instanceof OneConnectError ? error.status : undefined;
|
|
288
|
-
return fail("failed", status ? `One rejected the code exchange (HTTP ${status}).` : "One could not be reached to complete the connection.");
|
|
613
|
+
return fail("failed", status ? `One rejected the code exchange (HTTP ${status}).` : error instanceof OneConnectError ? error.message : "One could not be reached to complete the connection.");
|
|
289
614
|
}
|
|
290
615
|
return {
|
|
291
616
|
outcome: "connected",
|
|
@@ -293,94 +618,32 @@ function createOneConnect(config) {
|
|
|
293
618
|
clearCookieName: cookieName
|
|
294
619
|
};
|
|
295
620
|
};
|
|
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) => {
|
|
350
|
-
const inFlight = refreshing.get(userId);
|
|
351
|
-
if (inFlight) return inFlight;
|
|
352
|
-
const running = job().finally(() => refreshing.delete(userId));
|
|
353
|
-
refreshing.set(userId, running);
|
|
354
|
-
return running;
|
|
355
|
-
};
|
|
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;
|
|
365
|
-
const tokens = await tokenStore.loadTokens(userId);
|
|
366
|
-
if (!tokens) throw notConnected();
|
|
367
|
-
if (!expiresWithin(tokens, withinMs)) return tokens;
|
|
368
|
-
return singleFlight(userId, () => refreshUnderLock(userId, current => expiresWithin(current, withinMs)));
|
|
369
|
-
};
|
|
370
|
-
const getAccessToken = async userId => (await refreshIfExpiring(userId)).accessToken;
|
|
371
621
|
const oneFetch = async (userId, path, init = {}) => {
|
|
372
|
-
const accessToken = await getAccessToken(userId);
|
|
373
622
|
const headers = new Headers(init.headers);
|
|
374
|
-
headers.set(
|
|
375
|
-
for (const [name, value] of Object.entries(tenancyHeaders(accessToken))) headers.set(name, value);
|
|
623
|
+
for (const [name, value] of Object.entries(await credential.headers(userId))) headers.set(name, value);
|
|
376
624
|
return fetch(`${apiUrl}${path.startsWith("/") ? path : `/${path}`}`, {
|
|
377
625
|
...init,
|
|
378
626
|
headers
|
|
379
627
|
});
|
|
380
628
|
};
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Whether One still acts for this user at all, asked of the one route
|
|
632
|
+
* that needs nothing but the credential. Throws when the question
|
|
633
|
+
* itself cannot be answered, rather than guessing.
|
|
634
|
+
*/
|
|
635
|
+
const consentStands = async (userId, refused) => {
|
|
636
|
+
const probe = await oneFetch(userId, "/connections/reachable?limit=1");
|
|
637
|
+
if (probe.ok) return true;
|
|
638
|
+
const verdict = credential.refusal(probe.status, await probe.text());
|
|
639
|
+
if (verdict?.code === refused.code) return false;
|
|
640
|
+
throw verdict ?? new OneConnectError("request_failed", `One could not confirm the user's consent (HTTP ${probe.status}). Nothing stored was changed; try again.`, probe.status);
|
|
641
|
+
};
|
|
381
642
|
const listConnections = async userId => {
|
|
382
643
|
const response = await oneFetch(userId, "/connections/reachable");
|
|
383
644
|
if (!response.ok) {
|
|
645
|
+
const refused = credential.refusal(response.status, await response.text());
|
|
646
|
+
if (refused) throw refused;
|
|
384
647
|
throw new OneConnectError("request_failed", `One refused the connections request (HTTP ${response.status}).`, response.status);
|
|
385
648
|
}
|
|
386
649
|
const body = await response.json();
|
|
@@ -396,6 +659,17 @@ function createOneConnect(config) {
|
|
|
396
659
|
}));
|
|
397
660
|
const first = await oneFetch(userId, pageUrl(1));
|
|
398
661
|
if (!first.ok) {
|
|
662
|
+
const refused = credential.refusal(first.status, await first.text());
|
|
663
|
+
if (refused?.code === "reconnect_required") {
|
|
664
|
+
// The same bare 403 is what a One API answers when its catalog
|
|
665
|
+
// does not take the connect key. If the consent stands, that is
|
|
666
|
+
// what happened, and asking the user to reconnect would not help.
|
|
667
|
+
if (await consentStands(userId, refused)) {
|
|
668
|
+
throw new OneConnectError("request_failed", "One's action catalog refused the connect key (HTTP 403) although the user's consent stands: this One API does not accept the connect key on the catalog.", first.status);
|
|
669
|
+
}
|
|
670
|
+
throw refused;
|
|
671
|
+
}
|
|
672
|
+
if (refused) throw refused;
|
|
399
673
|
throw new OneConnectError("request_failed", `One refused the catalog request (HTTP ${first.status}).`, first.status);
|
|
400
674
|
}
|
|
401
675
|
const page1 = await first.json();
|
|
@@ -420,6 +694,24 @@ function createOneConnect(config) {
|
|
|
420
694
|
body: hasBody ? JSON.stringify(input.body) : undefined
|
|
421
695
|
});
|
|
422
696
|
const text = await response.text();
|
|
697
|
+
if (!response.ok) {
|
|
698
|
+
const refused = credential.refusal(response.status, text);
|
|
699
|
+
if (refused?.code === "reconnect_required") {
|
|
700
|
+
// One answers a call outside the grant with the same bare 403 it
|
|
701
|
+
// gives a consent that is gone. Asking what the grant reaches
|
|
702
|
+
// tells them apart: if that still answers, the consent stands
|
|
703
|
+
// and it was this call One refused.
|
|
704
|
+
if (await consentStands(userId, refused)) return {
|
|
705
|
+
status: response.status,
|
|
706
|
+
ok: false,
|
|
707
|
+
blockedByGrant: true,
|
|
708
|
+
data: text
|
|
709
|
+
};
|
|
710
|
+
throw refused;
|
|
711
|
+
}
|
|
712
|
+
// One refusing the credential is not an answer to the action.
|
|
713
|
+
if (refused) throw refused;
|
|
714
|
+
}
|
|
423
715
|
let data = text;
|
|
424
716
|
try {
|
|
425
717
|
data = JSON.parse(text);
|
|
@@ -436,20 +728,32 @@ function createOneConnect(config) {
|
|
|
436
728
|
data
|
|
437
729
|
};
|
|
438
730
|
};
|
|
439
|
-
|
|
731
|
+
const shared = {
|
|
440
732
|
startAuthorization,
|
|
441
733
|
completeAuthorization,
|
|
442
|
-
isConnected:
|
|
443
|
-
|
|
444
|
-
getTokens: userId => tokenStore.loadTokens(userId),
|
|
445
|
-
refreshTokens,
|
|
446
|
-
refreshIfExpiring,
|
|
447
|
-
disconnect: userId => tokenStore.clearTokens(userId),
|
|
734
|
+
isConnected: credential.isConnected,
|
|
735
|
+
disconnect: credential.disconnect,
|
|
448
736
|
listConnections,
|
|
449
737
|
listActions,
|
|
450
738
|
runAction,
|
|
451
739
|
fetch: oneFetch
|
|
452
740
|
};
|
|
741
|
+
if (keyCredential) {
|
|
742
|
+
return {
|
|
743
|
+
mode: "key",
|
|
744
|
+
...shared,
|
|
745
|
+
getConnectUserId: keyCredential.getConnectUserId
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
const tokens = tokenCredential;
|
|
749
|
+
return {
|
|
750
|
+
mode: "token",
|
|
751
|
+
...shared,
|
|
752
|
+
getAccessToken: tokens.getAccessToken,
|
|
753
|
+
getTokens: tokens.getTokens,
|
|
754
|
+
refreshTokens: tokens.refreshTokens,
|
|
755
|
+
refreshIfExpiring: tokens.refreshIfExpiring
|
|
756
|
+
};
|
|
453
757
|
}
|
|
454
758
|
|
|
455
|
-
export { OneConnectError, createOneConnect, refreshTokenExpiresAt, tenancyHeaders, tokenScopes };
|
|
759
|
+
export { OneConnectError, createOneConnect, encodeUserReference, parseUserReference, refreshTokenExpiresAt, tenancyHeaders, tokenScopes };
|