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