@withone/connect 0.8.3 → 0.9.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 +158 -370
- package/dist/button.d.ts +9 -10
- package/dist/constants.d.ts +8 -4
- package/dist/index.cjs.js +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.esm.js +1 -1
- package/dist/next.cjs.js +104 -0
- package/dist/next.d.ts +36 -0
- package/dist/next.esm.js +98 -0
- package/dist/node.cjs.js +69 -0
- package/dist/node.d.ts +33 -0
- package/dist/node.esm.js +67 -0
- package/dist/platforms.d.ts +21 -0
- package/dist/react.cjs.js +1 -1
- package/dist/react.d.ts +2 -19
- package/dist/react.esm.js +1 -1
- package/dist/return.d.ts +11 -0
- package/dist/server/index.cjs.js +349 -0
- package/dist/server/index.d.ts +32 -0
- package/dist/server/index.esm.js +344 -0
- package/dist/server/oauth.d.ts +24 -0
- package/dist/server/types.d.ts +136 -0
- package/dist/svelte.cjs.js +1 -1
- package/dist/svelte.d.ts +14 -4
- package/dist/svelte.esm.js +1 -1
- package/dist/types.d.ts +80 -0
- package/dist/useOneConnect.d.ts +13 -2
- package/dist/vue.cjs.js +1 -1
- package/dist/vue.d.ts +20 -18
- package/dist/vue.esm.js +1 -1
- package/dist/wrapper-options.d.ts +20 -15
- package/package.json +37 -15
- package/skills/one-connect/SKILL.md +194 -0
- package/src/button.ts +106 -157
- package/src/constants.ts +10 -4
- package/src/index.ts +17 -2
- package/src/next.ts +133 -0
- package/src/node.ts +107 -0
- package/src/platforms.ts +89 -0
- package/src/react.ts +23 -66
- package/src/return.ts +30 -0
- package/src/server/index.ts +382 -0
- package/src/server/oauth.ts +98 -0
- package/src/server/types.ts +155 -0
- package/src/svelte.ts +15 -15
- package/src/types.ts +87 -0
- package/src/useOneConnect.ts +36 -69
- package/src/vue.ts +23 -29
- package/src/wrapper-options.ts +46 -23
- package/dist/index.umd.js +0 -1
- package/dist/types/index.d.ts +0 -92
- package/src/react-types.d.ts +0 -23
- package/src/svelte-types.d.ts +0 -27
- package/src/types/index.d.ts +0 -92
- package/src/vue-types.d.ts +0 -16
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@withone/connect/server`: the half of One Connect that runs on the
|
|
3
|
+
* app's server. It starts the flow, completes it, keeps the tokens fresh
|
|
4
|
+
* and makes every call with the grant. The client secret never leaves
|
|
5
|
+
* here.
|
|
6
|
+
*
|
|
7
|
+
* const oneConnect = createOneConnect({ clientId, clientSecret,
|
|
8
|
+
* redirectUri, permissionSet, tokenStore });
|
|
9
|
+
*
|
|
10
|
+
* // in the authorize route
|
|
11
|
+
* const { redirectUrl, cookie } = oneConnect.startAuthorization({ loginHint });
|
|
12
|
+
* // in the callback route
|
|
13
|
+
* const result = await oneConnect.completeAuthorization({ userId, url, getCookie });
|
|
14
|
+
* // afterwards
|
|
15
|
+
* const rows = await oneConnect.listConnections(userId);
|
|
16
|
+
* const reply = await oneConnect.runAction(userId, { connectionKey, actionId, method, path });
|
|
17
|
+
*
|
|
18
|
+
* The Next.js and Node adapters turn the first two into route handlers.
|
|
19
|
+
*/
|
|
20
|
+
import { DEFAULT_ONE_API_URL, RETURN_MESSAGE_PARAM, RETURN_STATUS_PARAM } from "../constants";
|
|
21
|
+
import {
|
|
22
|
+
DEFAULT_SCOPES,
|
|
23
|
+
basicAuthorization,
|
|
24
|
+
createPkceVerifier,
|
|
25
|
+
createState,
|
|
26
|
+
pkceChallenge,
|
|
27
|
+
tenancyHeaders,
|
|
28
|
+
txCookie,
|
|
29
|
+
txCookieName,
|
|
30
|
+
} from "./oauth";
|
|
31
|
+
import {
|
|
32
|
+
OneConnectError,
|
|
33
|
+
type CompleteAuthorizationInput,
|
|
34
|
+
type CompleteAuthorizationResult,
|
|
35
|
+
type OneConnectServerConfig,
|
|
36
|
+
type OneConnectTokens,
|
|
37
|
+
type PlatformAction,
|
|
38
|
+
type ReachableConnection,
|
|
39
|
+
type RunActionInput,
|
|
40
|
+
type RunActionResult,
|
|
41
|
+
type StartAuthorizationInput,
|
|
42
|
+
type StartAuthorizationResult,
|
|
43
|
+
} from "./types";
|
|
44
|
+
|
|
45
|
+
export * from "./types";
|
|
46
|
+
export { tenancyHeaders, tokenScopes } from "./oauth";
|
|
47
|
+
|
|
48
|
+
/** Refresh this long before expiry, so a call never races the clock. */
|
|
49
|
+
const REFRESH_MARGIN_MS = 60_000;
|
|
50
|
+
const CATALOG_PAGE_SIZE = 100;
|
|
51
|
+
const CATALOG_MAX_PAGES = 20;
|
|
52
|
+
|
|
53
|
+
interface TokenResponse {
|
|
54
|
+
access_token: string;
|
|
55
|
+
refresh_token: string;
|
|
56
|
+
expires_in: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface OneConnect {
|
|
60
|
+
/** The authorize leg: where to send the browser and the cookie to set. */
|
|
61
|
+
startAuthorization: (
|
|
62
|
+
input?: StartAuthorizationInput,
|
|
63
|
+
) => StartAuthorizationResult;
|
|
64
|
+
/** The callback leg: verifies state, exchanges the code, stores the
|
|
65
|
+
* tokens, and says where to send the browser next. Never throws for
|
|
66
|
+
* a failed flow; read `outcome`. */
|
|
67
|
+
completeAuthorization: (
|
|
68
|
+
input: CompleteAuthorizationInput,
|
|
69
|
+
) => Promise<CompleteAuthorizationResult>;
|
|
70
|
+
/** Whether the user has tokens stored. */
|
|
71
|
+
isConnected: (userId: string) => Promise<boolean>;
|
|
72
|
+
/** A live access token, refreshed first when it is about to expire. */
|
|
73
|
+
getAccessToken: (userId: string) => Promise<string>;
|
|
74
|
+
/** The stored tokens, for display. Null when not connected. */
|
|
75
|
+
getTokens: (userId: string) => Promise<OneConnectTokens | null>;
|
|
76
|
+
/** Forces a refresh now. */
|
|
77
|
+
refreshTokens: (userId: string) => Promise<OneConnectTokens>;
|
|
78
|
+
/** Drops the app's copy of the tokens. The user revokes the grant
|
|
79
|
+
* itself from their One dashboard. */
|
|
80
|
+
disconnect: (userId: string) => Promise<void>;
|
|
81
|
+
/** The connections the grant reaches, each with its access. */
|
|
82
|
+
listConnections: (userId: string) => Promise<ReachableConnection[]>;
|
|
83
|
+
/** Every catalog action of a platform. What exists, not what is
|
|
84
|
+
* permitted; the grant decides that when the action runs. */
|
|
85
|
+
listActions: (userId: string, platform: string) => Promise<PlatformAction[]>;
|
|
86
|
+
/** Runs one action through One with the grant. */
|
|
87
|
+
runAction: (userId: string, input: RunActionInput) => Promise<RunActionResult>;
|
|
88
|
+
/** Any authenticated request to One's /v1 API, headers handled. */
|
|
89
|
+
fetch: (
|
|
90
|
+
userId: string,
|
|
91
|
+
path: string,
|
|
92
|
+
init?: RequestInit,
|
|
93
|
+
) => Promise<Response>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createOneConnect(config: OneConnectServerConfig): OneConnect {
|
|
97
|
+
const oneApiUrl = (config.oneApiUrl ?? DEFAULT_ONE_API_URL).replace(/\/+$/, "");
|
|
98
|
+
const authorizeUrl = `${oneApiUrl}/oauth/authorize`;
|
|
99
|
+
const tokenUrl = `${oneApiUrl}/oauth/token`;
|
|
100
|
+
const apiUrl = `${oneApiUrl}/v1`;
|
|
101
|
+
const returnTo = config.returnTo ?? "/";
|
|
102
|
+
const scopes = config.scopes ?? DEFAULT_SCOPES;
|
|
103
|
+
const { tokenStore } = config;
|
|
104
|
+
|
|
105
|
+
/** One refresh in flight per user: two concurrent refreshes with the
|
|
106
|
+
* same refresh token trip One's reuse detection. */
|
|
107
|
+
const refreshing = new Map<string, Promise<OneConnectTokens>>();
|
|
108
|
+
|
|
109
|
+
const returnUrl = (status: "success" | "error", message?: string): string => {
|
|
110
|
+
const url = new URL(returnTo, config.redirectUri);
|
|
111
|
+
url.searchParams.set(RETURN_STATUS_PARAM, status);
|
|
112
|
+
if (message) url.searchParams.set(RETURN_MESSAGE_PARAM, message);
|
|
113
|
+
return url.toString();
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const exchange = async (body: URLSearchParams): Promise<TokenResponse> => {
|
|
117
|
+
const response = await fetch(tokenUrl, {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: {
|
|
120
|
+
Authorization: basicAuthorization(config.clientId, config.clientSecret),
|
|
121
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
122
|
+
},
|
|
123
|
+
body,
|
|
124
|
+
});
|
|
125
|
+
if (!response.ok) {
|
|
126
|
+
throw new OneConnectError(
|
|
127
|
+
"request_failed",
|
|
128
|
+
`One refused the token request (HTTP ${response.status}).`,
|
|
129
|
+
response.status,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return (await response.json()) as TokenResponse;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const toTokens = (response: TokenResponse): OneConnectTokens => ({
|
|
136
|
+
accessToken: response.access_token,
|
|
137
|
+
refreshToken: response.refresh_token,
|
|
138
|
+
expiresAt: Date.now() + response.expires_in * 1000,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const startAuthorization = (
|
|
142
|
+
input: StartAuthorizationInput = {},
|
|
143
|
+
): StartAuthorizationResult => {
|
|
144
|
+
const state = createState();
|
|
145
|
+
const verifier = createPkceVerifier();
|
|
146
|
+
const url = new URL(authorizeUrl);
|
|
147
|
+
url.searchParams.set("client_id", config.clientId);
|
|
148
|
+
url.searchParams.set("redirect_uri", config.redirectUri);
|
|
149
|
+
url.searchParams.set("response_type", "code");
|
|
150
|
+
url.searchParams.set("scope", scopes.join(" "));
|
|
151
|
+
url.searchParams.set("state", state);
|
|
152
|
+
url.searchParams.set("code_challenge", pkceChallenge(verifier));
|
|
153
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
154
|
+
if (config.permissionSet)
|
|
155
|
+
url.searchParams.set("permission_set", config.permissionSet);
|
|
156
|
+
if (input.loginHint) url.searchParams.set("login_hint", input.loginHint);
|
|
157
|
+
return {
|
|
158
|
+
redirectUrl: url.toString(),
|
|
159
|
+
cookie: txCookie(state, verifier, config.redirectUri),
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const completeAuthorization = async (
|
|
164
|
+
input: CompleteAuthorizationInput,
|
|
165
|
+
): Promise<CompleteAuthorizationResult> => {
|
|
166
|
+
const params = new URL(input.url).searchParams;
|
|
167
|
+
const code = params.get("code");
|
|
168
|
+
const state = params.get("state");
|
|
169
|
+
const oauthError = params.get("error");
|
|
170
|
+
const cookieName = state ? txCookieName(state) : undefined;
|
|
171
|
+
const verifier = cookieName ? input.getCookie(cookieName) : undefined;
|
|
172
|
+
|
|
173
|
+
const fail = (
|
|
174
|
+
outcome: "declined" | "failed",
|
|
175
|
+
message: string,
|
|
176
|
+
): CompleteAuthorizationResult => ({
|
|
177
|
+
outcome,
|
|
178
|
+
message,
|
|
179
|
+
redirectUrl: returnUrl("error", message),
|
|
180
|
+
clearCookieName: cookieName,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
if (oauthError === "access_denied")
|
|
184
|
+
return fail("declined", "You cancelled the request.");
|
|
185
|
+
if (oauthError)
|
|
186
|
+
return fail("failed", `One reported an error: ${oauthError}.`);
|
|
187
|
+
// The returned state names its own cookie. No cookie means a forged
|
|
188
|
+
// or stale state; the code is never exchanged in that case.
|
|
189
|
+
if (!code || !state || !verifier)
|
|
190
|
+
return fail("failed", "The sign-in attempt expired or was tampered with.");
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const tokens = toTokens(
|
|
194
|
+
await exchange(
|
|
195
|
+
new URLSearchParams({
|
|
196
|
+
grant_type: "authorization_code",
|
|
197
|
+
code,
|
|
198
|
+
redirect_uri: config.redirectUri,
|
|
199
|
+
code_verifier: verifier,
|
|
200
|
+
}),
|
|
201
|
+
),
|
|
202
|
+
);
|
|
203
|
+
await tokenStore.saveTokens(input.userId, tokens);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
const status = error instanceof OneConnectError ? error.status : undefined;
|
|
206
|
+
return fail(
|
|
207
|
+
"failed",
|
|
208
|
+
status
|
|
209
|
+
? `One rejected the code exchange (HTTP ${status}).`
|
|
210
|
+
: "One could not be reached to complete the connection.",
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
outcome: "connected",
|
|
216
|
+
redirectUrl: returnUrl("success"),
|
|
217
|
+
clearCookieName: cookieName,
|
|
218
|
+
};
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const refreshTokens = (userId: string): Promise<OneConnectTokens> => {
|
|
222
|
+
const inFlight = refreshing.get(userId);
|
|
223
|
+
if (inFlight) return inFlight;
|
|
224
|
+
const job = (async () => {
|
|
225
|
+
const current = await tokenStore.loadTokens(userId);
|
|
226
|
+
if (!current)
|
|
227
|
+
throw new OneConnectError("not_connected", "This user is not connected.");
|
|
228
|
+
try {
|
|
229
|
+
const next = toTokens(
|
|
230
|
+
await exchange(
|
|
231
|
+
new URLSearchParams({
|
|
232
|
+
grant_type: "refresh_token",
|
|
233
|
+
refresh_token: current.refreshToken,
|
|
234
|
+
}),
|
|
235
|
+
),
|
|
236
|
+
);
|
|
237
|
+
// Both tokens: One rotates the pair on every refresh.
|
|
238
|
+
await tokenStore.saveTokens(userId, next);
|
|
239
|
+
return next;
|
|
240
|
+
} catch (error) {
|
|
241
|
+
// The family is dead: revoked, expired or reused. Keeping the
|
|
242
|
+
// pair would only fail again; the user has to reconnect.
|
|
243
|
+
await tokenStore.clearTokens(userId);
|
|
244
|
+
const status = error instanceof OneConnectError ? error.status : undefined;
|
|
245
|
+
throw new OneConnectError(
|
|
246
|
+
"refresh_failed",
|
|
247
|
+
"The connection to One has expired or was revoked. Ask the user to connect again.",
|
|
248
|
+
status,
|
|
249
|
+
);
|
|
250
|
+
} finally {
|
|
251
|
+
refreshing.delete(userId);
|
|
252
|
+
}
|
|
253
|
+
})();
|
|
254
|
+
refreshing.set(userId, job);
|
|
255
|
+
return job;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const getAccessToken = async (userId: string): Promise<string> => {
|
|
259
|
+
const tokens = await tokenStore.loadTokens(userId);
|
|
260
|
+
if (!tokens)
|
|
261
|
+
throw new OneConnectError("not_connected", "This user is not connected.");
|
|
262
|
+
if (Date.now() < tokens.expiresAt - REFRESH_MARGIN_MS) return tokens.accessToken;
|
|
263
|
+
return (await refreshTokens(userId)).accessToken;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const oneFetch = async (
|
|
267
|
+
userId: string,
|
|
268
|
+
path: string,
|
|
269
|
+
init: RequestInit = {},
|
|
270
|
+
): Promise<Response> => {
|
|
271
|
+
const accessToken = await getAccessToken(userId);
|
|
272
|
+
const headers = new Headers(init.headers);
|
|
273
|
+
headers.set("Authorization", `Bearer ${accessToken}`);
|
|
274
|
+
for (const [name, value] of Object.entries(tenancyHeaders(accessToken)))
|
|
275
|
+
headers.set(name, value);
|
|
276
|
+
return fetch(`${apiUrl}${path.startsWith("/") ? path : `/${path}`}`, {
|
|
277
|
+
...init,
|
|
278
|
+
headers,
|
|
279
|
+
});
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const listConnections = async (userId: string): Promise<ReachableConnection[]> => {
|
|
283
|
+
const response = await oneFetch(userId, "/connections/reachable");
|
|
284
|
+
if (!response.ok) {
|
|
285
|
+
throw new OneConnectError(
|
|
286
|
+
"request_failed",
|
|
287
|
+
`One refused the connections request (HTTP ${response.status}).`,
|
|
288
|
+
response.status,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
const body = (await response.json()) as { rows?: ReachableConnection[] };
|
|
292
|
+
return body.rows ?? [];
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const listActions = async (
|
|
296
|
+
userId: string,
|
|
297
|
+
platform: string,
|
|
298
|
+
): Promise<PlatformAction[]> => {
|
|
299
|
+
const pageUrl = (page: number) =>
|
|
300
|
+
`/knowledge?connectionPlatform=${encodeURIComponent(platform)}&limit=${CATALOG_PAGE_SIZE}&page=${page}`;
|
|
301
|
+
const slim = (rows: Record<string, unknown>[]): PlatformAction[] =>
|
|
302
|
+
rows.map((row) => ({
|
|
303
|
+
_id: String(row._id ?? ""),
|
|
304
|
+
title: String(row.title ?? ""),
|
|
305
|
+
method: String(row.method ?? ""),
|
|
306
|
+
path: String(row.path ?? ""),
|
|
307
|
+
}));
|
|
308
|
+
const first = await oneFetch(userId, pageUrl(1));
|
|
309
|
+
if (!first.ok) {
|
|
310
|
+
throw new OneConnectError(
|
|
311
|
+
"request_failed",
|
|
312
|
+
`One refused the catalog request (HTTP ${first.status}).`,
|
|
313
|
+
first.status,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
const page1 = (await first.json()) as {
|
|
317
|
+
rows?: Record<string, unknown>[];
|
|
318
|
+
pages?: number;
|
|
319
|
+
};
|
|
320
|
+
const pages = Math.min(Math.max(page1.pages ?? 1, 1), CATALOG_MAX_PAGES);
|
|
321
|
+
const rest = await Promise.all(
|
|
322
|
+
Array.from({ length: pages - 1 }, (_, index) =>
|
|
323
|
+
oneFetch(userId, pageUrl(index + 2))
|
|
324
|
+
.then((response) =>
|
|
325
|
+
response.ok
|
|
326
|
+
? (response.json() as Promise<{ rows?: Record<string, unknown>[] }>)
|
|
327
|
+
: null,
|
|
328
|
+
)
|
|
329
|
+
.catch(() => null),
|
|
330
|
+
),
|
|
331
|
+
);
|
|
332
|
+
return slim([page1, ...rest].flatMap((page) => page?.rows ?? []));
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const runAction = async (
|
|
336
|
+
userId: string,
|
|
337
|
+
input: RunActionInput,
|
|
338
|
+
): Promise<RunActionResult> => {
|
|
339
|
+
const method = input.method.toUpperCase();
|
|
340
|
+
const query = input.query ? `?${new URLSearchParams(input.query)}` : "";
|
|
341
|
+
const headers: Record<string, string> = {
|
|
342
|
+
"x-one-connection-key": input.connectionKey,
|
|
343
|
+
"x-one-action-id": input.actionId,
|
|
344
|
+
};
|
|
345
|
+
const hasBody = method !== "GET" && method !== "HEAD" && input.body !== undefined;
|
|
346
|
+
if (hasBody) headers["Content-Type"] = "application/json";
|
|
347
|
+
const response = await oneFetch(userId, `/passthrough${input.path}${query}`, {
|
|
348
|
+
method,
|
|
349
|
+
headers,
|
|
350
|
+
body: hasBody ? JSON.stringify(input.body) : undefined,
|
|
351
|
+
});
|
|
352
|
+
const text = await response.text();
|
|
353
|
+
let data: unknown = text;
|
|
354
|
+
try {
|
|
355
|
+
data = JSON.parse(text);
|
|
356
|
+
} catch {
|
|
357
|
+
/* the provider answered with something other than JSON */
|
|
358
|
+
}
|
|
359
|
+
// One's own refusals carry a correlationId; a provider's error does
|
|
360
|
+
// not. A One-shaped 403 is the grant working, not the provider.
|
|
361
|
+
const blockedByGrant =
|
|
362
|
+
!response.ok &&
|
|
363
|
+
typeof data === "object" &&
|
|
364
|
+
data !== null &&
|
|
365
|
+
"correlationId" in (data as Record<string, unknown>);
|
|
366
|
+
return { status: response.status, ok: response.ok, blockedByGrant, data };
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
startAuthorization,
|
|
371
|
+
completeAuthorization,
|
|
372
|
+
isConnected: async (userId) => (await tokenStore.loadTokens(userId)) !== null,
|
|
373
|
+
getAccessToken,
|
|
374
|
+
getTokens: (userId) => tokenStore.loadTokens(userId),
|
|
375
|
+
refreshTokens,
|
|
376
|
+
disconnect: (userId) => tokenStore.clearTokens(userId),
|
|
377
|
+
listConnections,
|
|
378
|
+
listActions,
|
|
379
|
+
runAction,
|
|
380
|
+
fetch: oneFetch,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type { OneConnectCookie } from "./types";
|
|
4
|
+
|
|
5
|
+
/** One cookie per flow, named by its state, so a retry or a second tab
|
|
6
|
+
* never overwrites the verifier of a flow still in progress. */
|
|
7
|
+
export const TX_COOKIE_PREFIX = "one_tx_";
|
|
8
|
+
|
|
9
|
+
/** One gives the consent 10 minutes and then the code 10 minutes, so a
|
|
10
|
+
* legitimate callback can arrive up to twenty minutes after the start.
|
|
11
|
+
* Thirty minutes leaves a margin. */
|
|
12
|
+
export const TX_COOKIE_MAX_AGE_SECONDS = 1800;
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_SCOPES = [
|
|
15
|
+
"user:connections:read",
|
|
16
|
+
"user:connections:write",
|
|
17
|
+
"org:connections:read",
|
|
18
|
+
"org:connections:write",
|
|
19
|
+
"project:connections:read",
|
|
20
|
+
"project:connections:write",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export function createState(): string {
|
|
24
|
+
return randomBytes(16).toString("hex");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createPkceVerifier(): string {
|
|
28
|
+
return randomBytes(32).toString("base64url");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function pkceChallenge(verifier: string): string {
|
|
32
|
+
return createHash("sha256").update(verifier).digest("base64url");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function txCookieName(state: string): string {
|
|
36
|
+
return `${TX_COOKIE_PREFIX}${state}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The cookie scoped to the routes that need it: the directory of the
|
|
40
|
+
* callback path, which is also where the authorize route lives. */
|
|
41
|
+
export function txCookie(
|
|
42
|
+
state: string,
|
|
43
|
+
verifier: string,
|
|
44
|
+
redirectUri: string,
|
|
45
|
+
): OneConnectCookie {
|
|
46
|
+
const url = new URL(redirectUri);
|
|
47
|
+
const path = url.pathname.replace(/\/[^/]*$/, "") || "/";
|
|
48
|
+
return {
|
|
49
|
+
name: txCookieName(state),
|
|
50
|
+
value: verifier,
|
|
51
|
+
options: {
|
|
52
|
+
httpOnly: true,
|
|
53
|
+
secure: url.protocol === "https:",
|
|
54
|
+
sameSite: "lax",
|
|
55
|
+
maxAge: TX_COOKIE_MAX_AGE_SECONDS,
|
|
56
|
+
path,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function basicAuthorization(
|
|
62
|
+
clientId: string,
|
|
63
|
+
clientSecret: string,
|
|
64
|
+
): string {
|
|
65
|
+
return `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The tenant headers a grant needs on every call. A grant made into an
|
|
69
|
+
* organization or project lives there; One resolves the tenant from
|
|
70
|
+
* these headers, and without them the call runs in the user's personal
|
|
71
|
+
* space. The access token's claims name the tenant, so echo them. */
|
|
72
|
+
export function tenancyHeaders(accessToken: string): Record<string, string> {
|
|
73
|
+
try {
|
|
74
|
+
const payload = JSON.parse(
|
|
75
|
+
Buffer.from(accessToken.split(".")[1], "base64url").toString(),
|
|
76
|
+
) as { organization_ids?: string[]; project_ids?: string[] };
|
|
77
|
+
const headers: Record<string, string> = {};
|
|
78
|
+
const organizationId = payload.organization_ids?.[0];
|
|
79
|
+
const projectId = payload.project_ids?.[0];
|
|
80
|
+
if (organizationId) headers["X-One-Organization-Id"] = organizationId;
|
|
81
|
+
if (projectId) headers["X-One-Project-Id"] = projectId;
|
|
82
|
+
return headers;
|
|
83
|
+
} catch {
|
|
84
|
+
return {};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The scopes the token was granted, from its claims. Display only. */
|
|
89
|
+
export function tokenScopes(accessToken: string): string[] {
|
|
90
|
+
try {
|
|
91
|
+
const payload = JSON.parse(
|
|
92
|
+
Buffer.from(accessToken.split(".")[1], "base64url").toString(),
|
|
93
|
+
) as { scope?: string };
|
|
94
|
+
return (payload.scope ?? "").split(" ").filter(Boolean);
|
|
95
|
+
} catch {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for `@withone/connect/server`: the half of the SDK that runs on
|
|
3
|
+
* the app's server and holds the client secret.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** What the app stores per user after the exchange. */
|
|
7
|
+
export interface OneConnectTokens {
|
|
8
|
+
accessToken: string;
|
|
9
|
+
refreshToken: string;
|
|
10
|
+
/** Epoch milliseconds when the access token expires. */
|
|
11
|
+
expiresAt: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Where the app keeps each user's tokens: its database, a cache, an
|
|
16
|
+
* encrypted cookie. The SDK never sees a token outside these three
|
|
17
|
+
* calls. `userId` is the app's own id for its user.
|
|
18
|
+
*/
|
|
19
|
+
export interface OneConnectTokenStore {
|
|
20
|
+
saveTokens: (userId: string, tokens: OneConnectTokens) => Promise<void>;
|
|
21
|
+
loadTokens: (userId: string) => Promise<OneConnectTokens | null>;
|
|
22
|
+
clearTokens: (userId: string) => Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface OneConnectServerConfig {
|
|
26
|
+
/** The app's client id from the dashboard. */
|
|
27
|
+
clientId: string;
|
|
28
|
+
/** The app's client secret. Server only. */
|
|
29
|
+
clientSecret: string;
|
|
30
|
+
/** Exactly the callback URL registered on the app, character for
|
|
31
|
+
* character, for example "https://yourapp.com/api/one/callback". */
|
|
32
|
+
redirectUri: string;
|
|
33
|
+
/** The ask (permission set) to open the consent card on. Omit to ask
|
|
34
|
+
* for everything the user has connected. */
|
|
35
|
+
permissionSet?: string;
|
|
36
|
+
/** One's API origin. Production when omitted. */
|
|
37
|
+
oneApiUrl?: string;
|
|
38
|
+
/** Where the callback sends the browser afterwards. "/" when omitted;
|
|
39
|
+
* the outcome is appended as `?one_connect=…`. */
|
|
40
|
+
returnTo?: string;
|
|
41
|
+
/** OAuth scopes. All three tenancy tiers when omitted, so the user may
|
|
42
|
+
* grant from any space. */
|
|
43
|
+
scopes?: string[];
|
|
44
|
+
tokenStore: OneConnectTokenStore;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The transaction cookie the authorize leg sets and the callback reads. */
|
|
48
|
+
export interface OneConnectCookie {
|
|
49
|
+
name: string;
|
|
50
|
+
value: string;
|
|
51
|
+
options: {
|
|
52
|
+
httpOnly: true;
|
|
53
|
+
secure: boolean;
|
|
54
|
+
sameSite: "lax";
|
|
55
|
+
maxAge: number;
|
|
56
|
+
path: string;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface StartAuthorizationInput {
|
|
61
|
+
/** Pre-fills the sign-in email on One's page. Never locks it. */
|
|
62
|
+
loginHint?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface StartAuthorizationResult {
|
|
66
|
+
/** Send the browser here with a 302. */
|
|
67
|
+
redirectUrl: string;
|
|
68
|
+
/** Set this cookie on the same response. */
|
|
69
|
+
cookie: OneConnectCookie;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface CompleteAuthorizationInput {
|
|
73
|
+
/** The app's user the grant belongs to. */
|
|
74
|
+
userId: string;
|
|
75
|
+
/** The full callback URL One redirected to, with its query string. */
|
|
76
|
+
url: string;
|
|
77
|
+
/** Reads a cookie by name from the incoming request. */
|
|
78
|
+
getCookie: (name: string) => string | undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type AuthorizationOutcome = "connected" | "declined" | "failed";
|
|
82
|
+
|
|
83
|
+
export interface CompleteAuthorizationResult {
|
|
84
|
+
outcome: AuthorizationOutcome;
|
|
85
|
+
/** Safe to show to the user when the outcome is not "connected". */
|
|
86
|
+
message?: string;
|
|
87
|
+
/** Send the browser here with a 302; it carries `?one_connect=…`. */
|
|
88
|
+
redirectUrl: string;
|
|
89
|
+
/** Delete this cookie on the same response, when one was involved. */
|
|
90
|
+
clearCookieName?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** One connection the grant reaches, with the access it confers. */
|
|
94
|
+
export interface ReachableConnection {
|
|
95
|
+
key: string;
|
|
96
|
+
platform: string;
|
|
97
|
+
name?: string;
|
|
98
|
+
title?: string;
|
|
99
|
+
image?: string;
|
|
100
|
+
access: ConnectionAccess;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type ConnectionAccess =
|
|
104
|
+
| { policy: "full" }
|
|
105
|
+
| { policy: "methods"; methods: string[] }
|
|
106
|
+
| {
|
|
107
|
+
policy: "actions";
|
|
108
|
+
actions: { actionId: string; title: string; method: string }[];
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** One catalog action for a platform. */
|
|
112
|
+
export interface PlatformAction {
|
|
113
|
+
_id: string;
|
|
114
|
+
title: string;
|
|
115
|
+
method: string;
|
|
116
|
+
path: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface RunActionInput {
|
|
120
|
+
/** From `listConnections`. */
|
|
121
|
+
connectionKey: string;
|
|
122
|
+
/** From `listActions`. */
|
|
123
|
+
actionId: string;
|
|
124
|
+
method: string;
|
|
125
|
+
/** The action's path, appended to /v1/passthrough. */
|
|
126
|
+
path: string;
|
|
127
|
+
body?: unknown;
|
|
128
|
+
query?: Record<string, string>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface RunActionResult {
|
|
132
|
+
status: number;
|
|
133
|
+
ok: boolean;
|
|
134
|
+
/** True when One refused the call because it is outside the grant.
|
|
135
|
+
* The provider was never called. Do not retry. */
|
|
136
|
+
blockedByGrant: boolean;
|
|
137
|
+
data: unknown;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export type OneConnectErrorCode =
|
|
141
|
+
| "not_connected"
|
|
142
|
+
| "refresh_failed"
|
|
143
|
+
| "request_failed";
|
|
144
|
+
|
|
145
|
+
export class OneConnectError extends Error {
|
|
146
|
+
readonly code: OneConnectErrorCode;
|
|
147
|
+
readonly status?: number;
|
|
148
|
+
|
|
149
|
+
constructor(code: OneConnectErrorCode, message: string, status?: number) {
|
|
150
|
+
super(message);
|
|
151
|
+
this.name = "OneConnectError";
|
|
152
|
+
this.code = code;
|
|
153
|
+
this.status = status;
|
|
154
|
+
}
|
|
155
|
+
}
|
package/src/svelte.ts
CHANGED
|
@@ -1,37 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Svelte
|
|
2
|
+
* Svelte: `import { connectButton } from "@withone/connect/svelte"`.
|
|
3
3
|
*
|
|
4
|
-
* A Svelte
|
|
5
|
-
*
|
|
4
|
+
* A Svelte action, the idiomatic shape for DOM-mounting libraries, so no
|
|
5
|
+
* Svelte compiler or dependency is involved:
|
|
6
6
|
*
|
|
7
7
|
* <div use:connectButton={{ authorizeUrl: "/api/one/authorize",
|
|
8
|
-
* platforms: [
|
|
8
|
+
* platforms: ["stripe", "notion"],
|
|
9
9
|
* onSuccess: () => { ... } }} />
|
|
10
10
|
*/
|
|
11
|
-
import { mountConnectButton } from "./button";
|
|
12
11
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
mountConnectButton,
|
|
13
|
+
optionsFromProps,
|
|
14
|
+
type ConnectButtonHandle,
|
|
15
|
+
type ConnectButtonProps,
|
|
16
|
+
} from "@withone/connect";
|
|
17
17
|
|
|
18
|
-
export type {
|
|
18
|
+
export type { ConnectButtonProps };
|
|
19
19
|
|
|
20
20
|
export function connectButton(
|
|
21
21
|
node: HTMLElement,
|
|
22
|
-
props:
|
|
22
|
+
props: ConnectButtonProps,
|
|
23
23
|
): {
|
|
24
|
-
update: (next:
|
|
24
|
+
update: (next: ConnectButtonProps) => void;
|
|
25
25
|
destroy: () => void;
|
|
26
26
|
} {
|
|
27
27
|
let handle: ConnectButtonHandle = mountConnectButton(
|
|
28
28
|
node,
|
|
29
|
-
|
|
29
|
+
optionsFromProps(props),
|
|
30
30
|
);
|
|
31
31
|
return {
|
|
32
|
-
update(next:
|
|
32
|
+
update(next: ConnectButtonProps) {
|
|
33
33
|
handle.destroy();
|
|
34
|
-
handle = mountConnectButton(node,
|
|
34
|
+
handle = mountConnectButton(node, optionsFromProps(next));
|
|
35
35
|
},
|
|
36
36
|
destroy() {
|
|
37
37
|
handle.destroy();
|