@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/src/server/index.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `@withone/connect/server`: the half of One Connect that runs on the
|
|
3
|
-
* app's server. It starts the flow, completes it,
|
|
4
|
-
*
|
|
5
|
-
* here.
|
|
3
|
+
* app's server. It starts the flow, completes it, and makes every call
|
|
4
|
+
* with the grant. The app's secrets never leave here.
|
|
6
5
|
*
|
|
6
|
+
* An app holds a user's grant in one of two ways, and picks by what it
|
|
7
|
+
* configures. Everything else is the same in both.
|
|
8
|
+
*
|
|
9
|
+
* // key mode (default): one connect key for the app, one permanent id
|
|
10
|
+
* // per user, nothing to refresh
|
|
11
|
+
* const oneConnect = createOneConnect({ clientId, clientSecret,
|
|
12
|
+
* redirectUri, permissionSet, connectKey, userStore });
|
|
13
|
+
*
|
|
14
|
+
* // token mode: an access and a refresh token per user, kept fresh
|
|
7
15
|
* const oneConnect = createOneConnect({ clientId, clientSecret,
|
|
8
16
|
* redirectUri, permissionSet, tokenStore });
|
|
9
17
|
*
|
|
@@ -16,33 +24,30 @@
|
|
|
16
24
|
* const reply = await oneConnect.runAction(userId, { connectionKey, actionId, method, path });
|
|
17
25
|
*
|
|
18
26
|
* 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.
|
|
27
|
+
* See `./key` and `./token` for what each mode stores and sends.
|
|
27
28
|
*/
|
|
28
29
|
import { DEFAULT_ONE_API_URL, RETURN_ERROR_PARAM, RETURN_STATUS_PARAM } from "../constants";
|
|
30
|
+
import type { Credential, PostToken, TokenResponse } from "./credential";
|
|
31
|
+
import { createKeyCredential } from "./key";
|
|
29
32
|
import {
|
|
30
33
|
DEFAULT_SCOPES,
|
|
31
34
|
basicAuthorization,
|
|
32
35
|
createPkceVerifier,
|
|
33
36
|
createState,
|
|
34
37
|
pkceChallenge,
|
|
35
|
-
refreshTokenExpiresAt,
|
|
36
|
-
tenancyHeaders,
|
|
37
38
|
txCookie,
|
|
38
39
|
txCookieName,
|
|
39
40
|
} from "./oauth";
|
|
41
|
+
import { createTokenCredential } from "./token";
|
|
40
42
|
import {
|
|
41
43
|
OneConnectError,
|
|
42
44
|
type CompleteAuthorizationInput,
|
|
43
45
|
type CompleteAuthorizationResult,
|
|
44
46
|
type ConnectFailureCode,
|
|
47
|
+
type OneConnectKeyConfig,
|
|
48
|
+
type OneConnectMode,
|
|
45
49
|
type OneConnectServerConfig,
|
|
50
|
+
type OneConnectTokenConfig,
|
|
46
51
|
type OneConnectTokens,
|
|
47
52
|
type PlatformAction,
|
|
48
53
|
type ReachableConnection,
|
|
@@ -54,60 +59,31 @@ import {
|
|
|
54
59
|
} from "./types";
|
|
55
60
|
|
|
56
61
|
export * from "./types";
|
|
62
|
+
export { encodeUserReference, parseUserReference } from "./key";
|
|
57
63
|
export { refreshTokenExpiresAt, tenancyHeaders, tokenScopes } from "./oauth";
|
|
58
64
|
|
|
59
|
-
/** Refresh this long before expiry, so a call never races the clock. */
|
|
60
|
-
const REFRESH_MARGIN_MS = 60_000;
|
|
61
65
|
const CATALOG_PAGE_SIZE = 100;
|
|
62
66
|
const CATALOG_MAX_PAGES = 20;
|
|
63
67
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** What One's token endpoint answered. A network failure throws instead. */
|
|
71
|
-
type TokenAnswer =
|
|
72
|
-
| { ok: true; body: TokenResponse }
|
|
73
|
-
| { ok: false; status: number; error?: string };
|
|
74
|
-
|
|
75
|
-
/** The one refusal that means the grant is gone for good: revoked by the
|
|
76
|
-
* user, expired, or burned by a reused refresh token (RFC 6749 §5.2). */
|
|
77
|
-
const isDeadGrant = (answer: TokenAnswer): boolean =>
|
|
78
|
-
!answer.ok && answer.status === 400 && answer.error === "invalid_grant";
|
|
79
|
-
|
|
80
|
-
export interface OneConnect {
|
|
68
|
+
/** What an app does with One in either mode. */
|
|
69
|
+
export interface OneConnectClient {
|
|
70
|
+
/** How this client holds each user's grant. */
|
|
71
|
+
readonly mode: OneConnectMode;
|
|
81
72
|
/** The authorize leg: where to send the browser and the cookie to set. */
|
|
82
73
|
startAuthorization: (
|
|
83
74
|
input?: StartAuthorizationInput,
|
|
84
75
|
) => StartAuthorizationResult;
|
|
85
|
-
/** The callback leg: verifies state, exchanges the code, stores
|
|
86
|
-
*
|
|
87
|
-
* a failed flow; read `outcome`. */
|
|
76
|
+
/** The callback leg: verifies state, exchanges the code, stores what
|
|
77
|
+
* the mode keeps, and says where to send the browser next. Never
|
|
78
|
+
* throws for a failed flow; read `outcome`. */
|
|
88
79
|
completeAuthorization: (
|
|
89
80
|
input: CompleteAuthorizationInput,
|
|
90
81
|
) => Promise<CompleteAuthorizationResult>;
|
|
91
|
-
/** Whether the
|
|
82
|
+
/** Whether the app has something stored for the user. In key mode One
|
|
83
|
+
* confirms the consent on each call, so a user who revoked still
|
|
84
|
+
* reads as connected here until a call throws `reconnect_required`. */
|
|
92
85
|
isConnected: (userId: string) => Promise<boolean>;
|
|
93
|
-
/**
|
|
94
|
-
getAccessToken: (userId: string) => Promise<string>;
|
|
95
|
-
/** The stored tokens, for display. Null when not connected. */
|
|
96
|
-
getTokens: (userId: string) => Promise<OneConnectTokens | null>;
|
|
97
|
-
/** Refreshes now and returns the new pair. When another caller rotated
|
|
98
|
-
* the pair a moment earlier, returns that pair instead of rotating it
|
|
99
|
-
* a second time. */
|
|
100
|
-
refreshTokens: (userId: string) => Promise<OneConnectTokens>;
|
|
101
|
-
/** Refreshes only when the access token or the refresh token expires
|
|
102
|
-
* within `withinMs`, and returns the pair that is current afterwards.
|
|
103
|
-
* For background jobs: a frequent run keeps access tokens warm, and a
|
|
104
|
-
* daily run with a window of days keeps idle users' 30-day refresh
|
|
105
|
-
* tokens from running out. */
|
|
106
|
-
refreshIfExpiring: (
|
|
107
|
-
userId: string,
|
|
108
|
-
options?: RefreshIfExpiringOptions,
|
|
109
|
-
) => Promise<OneConnectTokens>;
|
|
110
|
-
/** Drops the app's copy of the tokens. The user revokes the grant
|
|
86
|
+
/** Drops the app's copy of what it stored. The user revokes the grant
|
|
111
87
|
* itself from their One dashboard. */
|
|
112
88
|
disconnect: (userId: string) => Promise<void>;
|
|
113
89
|
/** The connections the grant reaches, each with its access. */
|
|
@@ -125,18 +101,80 @@ export interface OneConnect {
|
|
|
125
101
|
) => Promise<Response>;
|
|
126
102
|
}
|
|
127
103
|
|
|
128
|
-
|
|
104
|
+
/** The client key mode returns. */
|
|
105
|
+
export interface OneConnectKeyClient extends OneConnectClient {
|
|
106
|
+
readonly mode: "key";
|
|
107
|
+
/** One's permanent id for this user (`cu_…`), or null when they have
|
|
108
|
+
* never connected. */
|
|
109
|
+
getConnectUserId: (userId: string) => Promise<string | null>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The client token mode returns. */
|
|
113
|
+
export interface OneConnect extends OneConnectClient {
|
|
114
|
+
readonly mode: "token";
|
|
115
|
+
/** A live access token, refreshed first when it is about to expire. */
|
|
116
|
+
getAccessToken: (userId: string) => Promise<string>;
|
|
117
|
+
/** The stored tokens, for display. Null when not connected. */
|
|
118
|
+
getTokens: (userId: string) => Promise<OneConnectTokens | null>;
|
|
119
|
+
/** Refreshes now and returns the new pair. When another caller rotated
|
|
120
|
+
* the pair a moment earlier, returns that pair instead of rotating it
|
|
121
|
+
* a second time. */
|
|
122
|
+
refreshTokens: (userId: string) => Promise<OneConnectTokens>;
|
|
123
|
+
/** Refreshes only when the access token or the refresh token expires
|
|
124
|
+
* within `withinMs`, and returns the pair that is current afterwards.
|
|
125
|
+
* For the app's scheduled job: a daily run with a window of days
|
|
126
|
+
* renews each user's pair before the 30-day refresh token runs out. */
|
|
127
|
+
refreshIfExpiring: (
|
|
128
|
+
userId: string,
|
|
129
|
+
options?: RefreshIfExpiringOptions,
|
|
130
|
+
) => Promise<OneConnectTokens>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Which mode a config asks for. A stated `mode` wins; otherwise a
|
|
135
|
+
* connect key means key mode and a token store alone means token mode,
|
|
136
|
+
* so an app written before key mode existed keeps working untouched.
|
|
137
|
+
*/
|
|
138
|
+
function resolveMode(config: OneConnectServerConfig): OneConnectMode {
|
|
139
|
+
const given = config as Partial<OneConnectKeyConfig> & Partial<OneConnectTokenConfig>;
|
|
140
|
+
const mode: OneConnectMode | undefined =
|
|
141
|
+
given.mode ?? (given.connectKey ? "key" : given.tokenStore ? "token" : undefined);
|
|
142
|
+
if (mode === undefined) {
|
|
143
|
+
throw new TypeError(
|
|
144
|
+
"createOneConnect needs a credential: pass `connectKey` and `userStore` (key mode, recommended), or `tokenStore` (token mode).",
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (mode !== "key" && mode !== "token") {
|
|
148
|
+
throw new TypeError('createOneConnect: `mode` is "key" or "token".');
|
|
149
|
+
}
|
|
150
|
+
if (mode === "key" && !(given.connectKey && given.userStore)) {
|
|
151
|
+
throw new TypeError(
|
|
152
|
+
"createOneConnect: key mode needs `connectKey` (mint one on the app's page in the One dashboard) and `userStore`.",
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
if (mode === "token" && !given.tokenStore) {
|
|
156
|
+
throw new TypeError("createOneConnect: token mode needs `tokenStore`.");
|
|
157
|
+
}
|
|
158
|
+
return mode;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Key mode: a connect key for the app and one permanent id per user. */
|
|
162
|
+
export function createOneConnect(config: OneConnectKeyConfig): OneConnectKeyClient;
|
|
163
|
+
/** Token mode: an access and a refresh token per user, kept fresh. */
|
|
164
|
+
export function createOneConnect(config: OneConnectTokenConfig): OneConnect;
|
|
165
|
+
export function createOneConnect(
|
|
166
|
+
config: OneConnectServerConfig,
|
|
167
|
+
): OneConnectKeyClient | OneConnect;
|
|
168
|
+
export function createOneConnect(
|
|
169
|
+
config: OneConnectServerConfig,
|
|
170
|
+
): OneConnectKeyClient | OneConnect {
|
|
171
|
+
const mode = resolveMode(config);
|
|
129
172
|
const oneApiUrl = (config.oneApiUrl ?? DEFAULT_ONE_API_URL).replace(/\/+$/, "");
|
|
130
173
|
const authorizeUrl = `${oneApiUrl}/oauth/authorize`;
|
|
131
174
|
const tokenUrl = `${oneApiUrl}/oauth/token`;
|
|
132
175
|
const apiUrl = `${oneApiUrl}/v1`;
|
|
133
176
|
const returnTo = config.returnTo ?? "/";
|
|
134
177
|
const scopes = config.scopes ?? DEFAULT_SCOPES;
|
|
135
|
-
const { tokenStore } = config;
|
|
136
|
-
|
|
137
|
-
/** One refresh in flight per user: two concurrent refreshes with the
|
|
138
|
-
* same refresh token trip One's reuse detection. */
|
|
139
|
-
const refreshing = new Map<string, Promise<OneConnectTokens>>();
|
|
140
178
|
|
|
141
179
|
const returnUrl = (status: "success" | "error", code?: ConnectFailureCode): string => {
|
|
142
180
|
const url = new URL(returnTo, config.redirectUri);
|
|
@@ -145,7 +183,7 @@ export function createOneConnect(config: OneConnectServerConfig): OneConnect {
|
|
|
145
183
|
return url.toString();
|
|
146
184
|
};
|
|
147
185
|
|
|
148
|
-
const postToken = async (body
|
|
186
|
+
const postToken: PostToken = async (body) => {
|
|
149
187
|
const response = await fetch(tokenUrl, {
|
|
150
188
|
method: "POST",
|
|
151
189
|
headers: {
|
|
@@ -178,26 +216,20 @@ export function createOneConnect(config: OneConnectServerConfig): OneConnect {
|
|
|
178
216
|
return answer.body;
|
|
179
217
|
};
|
|
180
218
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
(
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const toTokens = (response: TokenResponse): OneConnectTokens => ({
|
|
197
|
-
accessToken: response.access_token,
|
|
198
|
-
refreshToken: response.refresh_token,
|
|
199
|
-
expiresAt: Date.now() + response.expires_in * 1000,
|
|
200
|
-
});
|
|
219
|
+
// The one thing the modes differ in: what is kept per user, and what
|
|
220
|
+
// is sent on a call. `resolveMode` already checked each has its parts.
|
|
221
|
+
const keyCredential =
|
|
222
|
+
mode === "key"
|
|
223
|
+
? createKeyCredential(
|
|
224
|
+
(config as OneConnectKeyConfig).connectKey,
|
|
225
|
+
(config as OneConnectKeyConfig).userStore,
|
|
226
|
+
)
|
|
227
|
+
: null;
|
|
228
|
+
const tokenCredential =
|
|
229
|
+
mode === "token"
|
|
230
|
+
? createTokenCredential((config as OneConnectTokenConfig).tokenStore, postToken)
|
|
231
|
+
: null;
|
|
232
|
+
const credential = (keyCredential ?? tokenCredential) as Credential;
|
|
201
233
|
|
|
202
234
|
const startAuthorization = (
|
|
203
235
|
input: StartAuthorizationInput = {},
|
|
@@ -252,26 +284,25 @@ export function createOneConnect(config: OneConnectServerConfig): OneConnect {
|
|
|
252
284
|
return fail("expired", "The attempt expired, or its state cookie was missing.");
|
|
253
285
|
|
|
254
286
|
try {
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
}),
|
|
263
|
-
),
|
|
287
|
+
const response = await exchange(
|
|
288
|
+
new URLSearchParams({
|
|
289
|
+
grant_type: "authorization_code",
|
|
290
|
+
code,
|
|
291
|
+
redirect_uri: config.redirectUri,
|
|
292
|
+
code_verifier: verifier,
|
|
293
|
+
}),
|
|
264
294
|
);
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
-
await locked(input.userId, () => tokenStore.saveTokens(input.userId, tokens));
|
|
295
|
+
// Tokens in token mode, the user's permanent id in key mode.
|
|
296
|
+
await credential.connected(input.userId, response);
|
|
268
297
|
} catch (error) {
|
|
269
298
|
const status = error instanceof OneConnectError ? error.status : undefined;
|
|
270
299
|
return fail(
|
|
271
300
|
"failed",
|
|
272
301
|
status
|
|
273
302
|
? `One rejected the code exchange (HTTP ${status}).`
|
|
274
|
-
:
|
|
303
|
+
: error instanceof OneConnectError
|
|
304
|
+
? error.message
|
|
305
|
+
: "One could not be reached to complete the connection.",
|
|
275
306
|
);
|
|
276
307
|
}
|
|
277
308
|
|
|
@@ -282,132 +313,13 @@ export function createOneConnect(config: OneConnectServerConfig): OneConnect {
|
|
|
282
313
|
};
|
|
283
314
|
};
|
|
284
315
|
|
|
285
|
-
const notConnected = () =>
|
|
286
|
-
new OneConnectError("not_connected", "This user is not connected.");
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* The grant behind `failed` is dead. Clears it, unless a newer pair
|
|
290
|
-
* landed while it was failing (a reconnect's callback, another
|
|
291
|
-
* server's refresh): that pair is returned instead, because the user
|
|
292
|
-
* did nothing wrong and deleting it would disconnect them.
|
|
293
|
-
*/
|
|
294
|
-
const retire = async (
|
|
295
|
-
userId: string,
|
|
296
|
-
failed: OneConnectTokens,
|
|
297
|
-
status?: number,
|
|
298
|
-
): Promise<OneConnectTokens> => {
|
|
299
|
-
const latest = await tokenStore.loadTokens(userId);
|
|
300
|
-
if (latest && latest.refreshToken !== failed.refreshToken) return latest;
|
|
301
|
-
await tokenStore.clearTokens(userId, failed);
|
|
302
|
-
throw new OneConnectError(
|
|
303
|
-
"refresh_failed",
|
|
304
|
-
"The connection to One has expired or was revoked. Ask the user to connect again.",
|
|
305
|
-
status,
|
|
306
|
-
);
|
|
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 = (
|
|
317
|
-
userId: string,
|
|
318
|
-
stillNeeded: (current: OneConnectTokens) => boolean,
|
|
319
|
-
): Promise<OneConnectTokens> =>
|
|
320
|
-
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())
|
|
329
|
-
return retire(userId, current);
|
|
330
|
-
|
|
331
|
-
let answer: TokenAnswer;
|
|
332
|
-
try {
|
|
333
|
-
answer = await postToken(
|
|
334
|
-
new URLSearchParams({
|
|
335
|
-
grant_type: "refresh_token",
|
|
336
|
-
refresh_token: current.refreshToken,
|
|
337
|
-
}),
|
|
338
|
-
);
|
|
339
|
-
} catch {
|
|
340
|
-
throw new OneConnectError(
|
|
341
|
-
"request_failed",
|
|
342
|
-
"One could not be reached to refresh the connection. The tokens were kept; try again.",
|
|
343
|
-
);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
if (answer.ok) {
|
|
347
|
-
// Both tokens: One rotates the pair on every refresh.
|
|
348
|
-
const next = toTokens(answer.body);
|
|
349
|
-
await tokenStore.saveTokens(userId, next);
|
|
350
|
-
return next;
|
|
351
|
-
}
|
|
352
|
-
if (isDeadGrant(answer)) return retire(userId, current, answer.status);
|
|
353
|
-
// A server error, a rate limit, a misconfigured secret: nothing says
|
|
354
|
-
// the grant is gone, so keep the tokens and let the caller retry.
|
|
355
|
-
throw new OneConnectError(
|
|
356
|
-
"request_failed",
|
|
357
|
-
`One could not refresh the connection (HTTP ${answer.status}). The tokens were kept; try again.`,
|
|
358
|
-
answer.status,
|
|
359
|
-
);
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
/** One refresh per user in this process; concurrent callers share it. */
|
|
363
|
-
const singleFlight = (
|
|
364
|
-
userId: string,
|
|
365
|
-
job: () => Promise<OneConnectTokens>,
|
|
366
|
-
): Promise<OneConnectTokens> => {
|
|
367
|
-
const inFlight = refreshing.get(userId);
|
|
368
|
-
if (inFlight) return inFlight;
|
|
369
|
-
const running = job().finally(() => refreshing.delete(userId));
|
|
370
|
-
refreshing.set(userId, running);
|
|
371
|
-
return running;
|
|
372
|
-
};
|
|
373
|
-
|
|
374
|
-
const refreshTokens = (userId: string): Promise<OneConnectTokens> =>
|
|
375
|
-
singleFlight(userId, async () => {
|
|
376
|
-
const before = await tokenStore.loadTokens(userId);
|
|
377
|
-
if (!before) throw notConnected();
|
|
378
|
-
// Rotate the pair seen now; a pair someone else rotated since is
|
|
379
|
-
// already fresh.
|
|
380
|
-
return refreshUnderLock(
|
|
381
|
-
userId,
|
|
382
|
-
(current) => current.refreshToken === before.refreshToken,
|
|
383
|
-
);
|
|
384
|
-
});
|
|
385
|
-
|
|
386
|
-
const refreshIfExpiring = async (
|
|
387
|
-
userId: string,
|
|
388
|
-
options: RefreshIfExpiringOptions = {},
|
|
389
|
-
): Promise<OneConnectTokens> => {
|
|
390
|
-
const withinMs = options.withinMs ?? REFRESH_MARGIN_MS;
|
|
391
|
-
const tokens = await tokenStore.loadTokens(userId);
|
|
392
|
-
if (!tokens) throw notConnected();
|
|
393
|
-
if (!expiresWithin(tokens, withinMs)) return tokens;
|
|
394
|
-
return singleFlight(userId, () =>
|
|
395
|
-
refreshUnderLock(userId, (current) => expiresWithin(current, withinMs)),
|
|
396
|
-
);
|
|
397
|
-
};
|
|
398
|
-
|
|
399
|
-
const getAccessToken = async (userId: string): Promise<string> =>
|
|
400
|
-
(await refreshIfExpiring(userId)).accessToken;
|
|
401
|
-
|
|
402
316
|
const oneFetch = async (
|
|
403
317
|
userId: string,
|
|
404
318
|
path: string,
|
|
405
319
|
init: RequestInit = {},
|
|
406
320
|
): Promise<Response> => {
|
|
407
|
-
const accessToken = await getAccessToken(userId);
|
|
408
321
|
const headers = new Headers(init.headers);
|
|
409
|
-
|
|
410
|
-
for (const [name, value] of Object.entries(tenancyHeaders(accessToken)))
|
|
322
|
+
for (const [name, value] of Object.entries(await credential.headers(userId)))
|
|
411
323
|
headers.set(name, value);
|
|
412
324
|
return fetch(`${apiUrl}${path.startsWith("/") ? path : `/${path}`}`, {
|
|
413
325
|
...init,
|
|
@@ -415,9 +327,31 @@ export function createOneConnect(config: OneConnectServerConfig): OneConnect {
|
|
|
415
327
|
});
|
|
416
328
|
};
|
|
417
329
|
|
|
330
|
+
/**
|
|
331
|
+
* Whether One still acts for this user at all, asked of the one route
|
|
332
|
+
* that needs nothing but the credential. Throws when the question
|
|
333
|
+
* itself cannot be answered, rather than guessing.
|
|
334
|
+
*/
|
|
335
|
+
const consentStands = async (userId: string, refused: OneConnectError): Promise<boolean> => {
|
|
336
|
+
const probe = await oneFetch(userId, "/connections/reachable?limit=1");
|
|
337
|
+
if (probe.ok) return true;
|
|
338
|
+
const verdict = credential.refusal(probe.status, await probe.text());
|
|
339
|
+
if (verdict?.code === refused.code) return false;
|
|
340
|
+
throw (
|
|
341
|
+
verdict ??
|
|
342
|
+
new OneConnectError(
|
|
343
|
+
"request_failed",
|
|
344
|
+
`One could not confirm the user's consent (HTTP ${probe.status}). Nothing stored was changed; try again.`,
|
|
345
|
+
probe.status,
|
|
346
|
+
)
|
|
347
|
+
);
|
|
348
|
+
};
|
|
349
|
+
|
|
418
350
|
const listConnections = async (userId: string): Promise<ReachableConnection[]> => {
|
|
419
351
|
const response = await oneFetch(userId, "/connections/reachable");
|
|
420
352
|
if (!response.ok) {
|
|
353
|
+
const refused = credential.refusal(response.status, await response.text());
|
|
354
|
+
if (refused) throw refused;
|
|
421
355
|
throw new OneConnectError(
|
|
422
356
|
"request_failed",
|
|
423
357
|
`One refused the connections request (HTTP ${response.status}).`,
|
|
@@ -443,6 +377,21 @@ export function createOneConnect(config: OneConnectServerConfig): OneConnect {
|
|
|
443
377
|
}));
|
|
444
378
|
const first = await oneFetch(userId, pageUrl(1));
|
|
445
379
|
if (!first.ok) {
|
|
380
|
+
const refused = credential.refusal(first.status, await first.text());
|
|
381
|
+
if (refused?.code === "reconnect_required") {
|
|
382
|
+
// The same bare 403 is what a One API answers when its catalog
|
|
383
|
+
// does not take the connect key. If the consent stands, that is
|
|
384
|
+
// what happened, and asking the user to reconnect would not help.
|
|
385
|
+
if (await consentStands(userId, refused)) {
|
|
386
|
+
throw new OneConnectError(
|
|
387
|
+
"request_failed",
|
|
388
|
+
"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.",
|
|
389
|
+
first.status,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
throw refused;
|
|
393
|
+
}
|
|
394
|
+
if (refused) throw refused;
|
|
446
395
|
throw new OneConnectError(
|
|
447
396
|
"request_failed",
|
|
448
397
|
`One refused the catalog request (HTTP ${first.status}).`,
|
|
@@ -486,6 +435,20 @@ export function createOneConnect(config: OneConnectServerConfig): OneConnect {
|
|
|
486
435
|
body: hasBody ? JSON.stringify(input.body) : undefined,
|
|
487
436
|
});
|
|
488
437
|
const text = await response.text();
|
|
438
|
+
if (!response.ok) {
|
|
439
|
+
const refused = credential.refusal(response.status, text);
|
|
440
|
+
if (refused?.code === "reconnect_required") {
|
|
441
|
+
// One answers a call outside the grant with the same bare 403 it
|
|
442
|
+
// gives a consent that is gone. Asking what the grant reaches
|
|
443
|
+
// tells them apart: if that still answers, the consent stands
|
|
444
|
+
// and it was this call One refused.
|
|
445
|
+
if (await consentStands(userId, refused))
|
|
446
|
+
return { status: response.status, ok: false, blockedByGrant: true, data: text };
|
|
447
|
+
throw refused;
|
|
448
|
+
}
|
|
449
|
+
// One refusing the credential is not an answer to the action.
|
|
450
|
+
if (refused) throw refused;
|
|
451
|
+
}
|
|
489
452
|
let data: unknown = text;
|
|
490
453
|
try {
|
|
491
454
|
data = JSON.parse(text);
|
|
@@ -502,18 +465,31 @@ export function createOneConnect(config: OneConnectServerConfig): OneConnect {
|
|
|
502
465
|
return { status: response.status, ok: response.ok, blockedByGrant, data };
|
|
503
466
|
};
|
|
504
467
|
|
|
505
|
-
|
|
468
|
+
const shared = {
|
|
506
469
|
startAuthorization,
|
|
507
470
|
completeAuthorization,
|
|
508
|
-
isConnected:
|
|
509
|
-
|
|
510
|
-
getTokens: (userId) => tokenStore.loadTokens(userId),
|
|
511
|
-
refreshTokens,
|
|
512
|
-
refreshIfExpiring,
|
|
513
|
-
disconnect: (userId) => tokenStore.clearTokens(userId),
|
|
471
|
+
isConnected: credential.isConnected,
|
|
472
|
+
disconnect: credential.disconnect,
|
|
514
473
|
listConnections,
|
|
515
474
|
listActions,
|
|
516
475
|
runAction,
|
|
517
476
|
fetch: oneFetch,
|
|
518
477
|
};
|
|
478
|
+
|
|
479
|
+
if (keyCredential) {
|
|
480
|
+
return {
|
|
481
|
+
mode: "key",
|
|
482
|
+
...shared,
|
|
483
|
+
getConnectUserId: keyCredential.getConnectUserId,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
const tokens = tokenCredential!;
|
|
487
|
+
return {
|
|
488
|
+
mode: "token",
|
|
489
|
+
...shared,
|
|
490
|
+
getAccessToken: tokens.getAccessToken,
|
|
491
|
+
getTokens: tokens.getTokens,
|
|
492
|
+
refreshTokens: tokens.refreshTokens,
|
|
493
|
+
refreshIfExpiring: tokens.refreshIfExpiring,
|
|
494
|
+
};
|
|
519
495
|
}
|