@withone/connect 0.10.0 → 0.12.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 +144 -26
- package/dist/button.d.ts +20 -14
- package/dist/constants.d.ts +5 -1
- package/dist/flow.d.ts +18 -0
- package/dist/index.cjs.js +1 -1
- package/dist/index.d.ts +7 -4
- package/dist/index.esm.js +1 -1
- package/dist/platforms.d.ts +1 -1
- package/dist/react.cjs.js +2 -1
- package/dist/react.d.ts +20 -1
- package/dist/react.esm.js +2 -1
- package/dist/return.d.ts +5 -2
- package/dist/server/index.cjs.js +157 -45
- package/dist/server/index.d.ts +11 -3
- package/dist/server/index.esm.js +157 -46
- package/dist/server/oauth.d.ts +4 -0
- package/dist/server/types.d.ts +47 -4
- package/dist/svelte.cjs.js +1 -1
- package/dist/svelte.d.ts +1 -1
- package/dist/svelte.esm.js +1 -1
- package/dist/types.d.ts +71 -29
- package/dist/vue.cjs.js +1 -1
- package/dist/vue.d.ts +71 -22
- package/dist/vue.esm.js +1 -1
- package/package.json +4 -1
- package/skills/one-connect/SKILL.md +55 -19
- package/src/button.ts +450 -295
- package/src/constants.ts +5 -1
- package/src/flow.ts +171 -0
- package/src/index.ts +17 -19
- package/src/platforms.ts +41 -6
- package/src/react.ts +119 -21
- package/src/return.ts +31 -9
- package/src/server/index.ts +188 -51
- package/src/server/oauth.ts +16 -0
- package/src/server/types.ts +49 -4
- package/src/svelte.ts +5 -13
- package/src/types.ts +73 -29
- package/src/vue.ts +42 -26
- package/dist/useOneConnect.d.ts +0 -13
- package/dist/wrapper-options.d.ts +0 -28
- package/src/useOneConnect.ts +0 -72
- package/src/wrapper-options.ts +0 -68
package/src/server/index.ts
CHANGED
|
@@ -16,14 +16,23 @@
|
|
|
16
16
|
* const reply = await oneConnect.runAction(userId, { connectionKey, actionId, method, path });
|
|
17
17
|
*
|
|
18
18
|
* The Next.js and Node adapters turn the first two into route handlers.
|
|
19
|
+
*
|
|
20
|
+
* Refresh. One's access token lives an hour by default, its refresh token 30
|
|
21
|
+
* days; every refresh rotates both, and One treats a second use of a
|
|
22
|
+
* rotated refresh token as theft and revokes the whole grant. So the
|
|
23
|
+
* client refreshes one user at a time (in this process always, across
|
|
24
|
+
* processes through `tokenStore.withLock`), re-reads the store before
|
|
25
|
+
* spending a refresh token, clears tokens only when One declares the
|
|
26
|
+
* grant dead, and never lets a failing old pair delete a newer one.
|
|
19
27
|
*/
|
|
20
|
-
import { DEFAULT_ONE_API_URL,
|
|
28
|
+
import { DEFAULT_ONE_API_URL, RETURN_ERROR_PARAM, RETURN_STATUS_PARAM } from "../constants";
|
|
21
29
|
import {
|
|
22
30
|
DEFAULT_SCOPES,
|
|
23
31
|
basicAuthorization,
|
|
24
32
|
createPkceVerifier,
|
|
25
33
|
createState,
|
|
26
34
|
pkceChallenge,
|
|
35
|
+
refreshTokenExpiresAt,
|
|
27
36
|
tenancyHeaders,
|
|
28
37
|
txCookie,
|
|
29
38
|
txCookieName,
|
|
@@ -32,10 +41,12 @@ import {
|
|
|
32
41
|
OneConnectError,
|
|
33
42
|
type CompleteAuthorizationInput,
|
|
34
43
|
type CompleteAuthorizationResult,
|
|
44
|
+
type ConnectFailureCode,
|
|
35
45
|
type OneConnectServerConfig,
|
|
36
46
|
type OneConnectTokens,
|
|
37
47
|
type PlatformAction,
|
|
38
48
|
type ReachableConnection,
|
|
49
|
+
type RefreshIfExpiringOptions,
|
|
39
50
|
type RunActionInput,
|
|
40
51
|
type RunActionResult,
|
|
41
52
|
type StartAuthorizationInput,
|
|
@@ -43,7 +54,7 @@ import {
|
|
|
43
54
|
} from "./types";
|
|
44
55
|
|
|
45
56
|
export * from "./types";
|
|
46
|
-
export { tenancyHeaders, tokenScopes } from "./oauth";
|
|
57
|
+
export { refreshTokenExpiresAt, tenancyHeaders, tokenScopes } from "./oauth";
|
|
47
58
|
|
|
48
59
|
/** Refresh this long before expiry, so a call never races the clock. */
|
|
49
60
|
const REFRESH_MARGIN_MS = 60_000;
|
|
@@ -56,6 +67,16 @@ interface TokenResponse {
|
|
|
56
67
|
expires_in: number;
|
|
57
68
|
}
|
|
58
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
|
+
|
|
59
80
|
export interface OneConnect {
|
|
60
81
|
/** The authorize leg: where to send the browser and the cookie to set. */
|
|
61
82
|
startAuthorization: (
|
|
@@ -73,8 +94,19 @@ export interface OneConnect {
|
|
|
73
94
|
getAccessToken: (userId: string) => Promise<string>;
|
|
74
95
|
/** The stored tokens, for display. Null when not connected. */
|
|
75
96
|
getTokens: (userId: string) => Promise<OneConnectTokens | null>;
|
|
76
|
-
/**
|
|
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. */
|
|
77
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>;
|
|
78
110
|
/** Drops the app's copy of the tokens. The user revokes the grant
|
|
79
111
|
* itself from their One dashboard. */
|
|
80
112
|
disconnect: (userId: string) => Promise<void>;
|
|
@@ -106,14 +138,14 @@ export function createOneConnect(config: OneConnectServerConfig): OneConnect {
|
|
|
106
138
|
* same refresh token trip One's reuse detection. */
|
|
107
139
|
const refreshing = new Map<string, Promise<OneConnectTokens>>();
|
|
108
140
|
|
|
109
|
-
const returnUrl = (status: "success" | "error",
|
|
141
|
+
const returnUrl = (status: "success" | "error", code?: ConnectFailureCode): string => {
|
|
110
142
|
const url = new URL(returnTo, config.redirectUri);
|
|
111
143
|
url.searchParams.set(RETURN_STATUS_PARAM, status);
|
|
112
|
-
if (
|
|
144
|
+
if (code) url.searchParams.set(RETURN_ERROR_PARAM, code);
|
|
113
145
|
return url.toString();
|
|
114
146
|
};
|
|
115
147
|
|
|
116
|
-
const
|
|
148
|
+
const postToken = async (body: URLSearchParams): Promise<TokenAnswer> => {
|
|
117
149
|
const response = await fetch(tokenUrl, {
|
|
118
150
|
method: "POST",
|
|
119
151
|
headers: {
|
|
@@ -122,14 +154,43 @@ export function createOneConnect(config: OneConnectServerConfig): OneConnect {
|
|
|
122
154
|
},
|
|
123
155
|
body,
|
|
124
156
|
});
|
|
125
|
-
if (
|
|
157
|
+
if (response.ok)
|
|
158
|
+
return { ok: true, body: (await response.json()) as TokenResponse };
|
|
159
|
+
let error: string | undefined;
|
|
160
|
+
try {
|
|
161
|
+
const refusal = (await response.json()) as { error?: unknown };
|
|
162
|
+
if (typeof refusal.error === "string") error = refusal.error;
|
|
163
|
+
} catch {
|
|
164
|
+
/* not an OAuth error body: a proxy page or a server error */
|
|
165
|
+
}
|
|
166
|
+
return { ok: false, status: response.status, error };
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const exchange = async (body: URLSearchParams): Promise<TokenResponse> => {
|
|
170
|
+
const answer = await postToken(body);
|
|
171
|
+
if (!answer.ok) {
|
|
126
172
|
throw new OneConnectError(
|
|
127
173
|
"request_failed",
|
|
128
|
-
`One refused the token request (HTTP ${
|
|
129
|
-
|
|
174
|
+
`One refused the token request (HTTP ${answer.status}).`,
|
|
175
|
+
answer.status,
|
|
130
176
|
);
|
|
131
177
|
}
|
|
132
|
-
return
|
|
178
|
+
return answer.body;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/** Runs `run` under the app's cross-process lock for this user, when
|
|
182
|
+
* the store has one. */
|
|
183
|
+
const locked = <T>(userId: string, run: () => Promise<T>): Promise<T> =>
|
|
184
|
+
tokenStore.withLock ? tokenStore.withLock(userId, run) : run();
|
|
185
|
+
|
|
186
|
+
/** Whether either token of the pair stops working within `withinMs`. */
|
|
187
|
+
const expiresWithin = (tokens: OneConnectTokens, withinMs: number): boolean => {
|
|
188
|
+
const horizon = Date.now() + withinMs;
|
|
189
|
+
const refreshExpiresAt = refreshTokenExpiresAt(tokens.refreshToken);
|
|
190
|
+
return (
|
|
191
|
+
tokens.expiresAt <= horizon ||
|
|
192
|
+
(refreshExpiresAt !== null && refreshExpiresAt <= horizon)
|
|
193
|
+
);
|
|
133
194
|
};
|
|
134
195
|
|
|
135
196
|
const toTokens = (response: TokenResponse): OneConnectTokens => ({
|
|
@@ -171,23 +232,24 @@ export function createOneConnect(config: OneConnectServerConfig): OneConnect {
|
|
|
171
232
|
const verifier = cookieName ? input.getCookie(cookieName) : undefined;
|
|
172
233
|
|
|
173
234
|
const fail = (
|
|
174
|
-
|
|
235
|
+
failure: ConnectFailureCode,
|
|
175
236
|
message: string,
|
|
176
237
|
): CompleteAuthorizationResult => ({
|
|
177
|
-
outcome,
|
|
238
|
+
outcome: failure === "declined" ? "declined" : "failed",
|
|
239
|
+
code: failure,
|
|
178
240
|
message,
|
|
179
|
-
redirectUrl: returnUrl("error",
|
|
241
|
+
redirectUrl: returnUrl("error", failure),
|
|
180
242
|
clearCookieName: cookieName,
|
|
181
243
|
});
|
|
182
244
|
|
|
183
245
|
if (oauthError === "access_denied")
|
|
184
|
-
return fail("declined", "
|
|
246
|
+
return fail("declined", "The user cancelled on One's page.");
|
|
185
247
|
if (oauthError)
|
|
186
248
|
return fail("failed", `One reported an error: ${oauthError}.`);
|
|
187
|
-
// The returned state names its own cookie. No cookie means a
|
|
188
|
-
// or
|
|
249
|
+
// The returned state names its own cookie. No cookie means a stale,
|
|
250
|
+
// foreign or forged state; the code is never exchanged in that case.
|
|
189
251
|
if (!code || !state || !verifier)
|
|
190
|
-
return fail("
|
|
252
|
+
return fail("expired", "The attempt expired, or its state cookie was missing.");
|
|
191
253
|
|
|
192
254
|
try {
|
|
193
255
|
const tokens = toTokens(
|
|
@@ -200,7 +262,9 @@ export function createOneConnect(config: OneConnectServerConfig): OneConnect {
|
|
|
200
262
|
}),
|
|
201
263
|
),
|
|
202
264
|
);
|
|
203
|
-
|
|
265
|
+
// Under the lock, so a refresh in flight on another server cannot
|
|
266
|
+
// interleave with this save.
|
|
267
|
+
await locked(input.userId, () => tokenStore.saveTokens(input.userId, tokens));
|
|
204
268
|
} catch (error) {
|
|
205
269
|
const status = error instanceof OneConnectError ? error.status : undefined;
|
|
206
270
|
return fail(
|
|
@@ -218,51 +282,123 @@ export function createOneConnect(config: OneConnectServerConfig): OneConnect {
|
|
|
218
282
|
};
|
|
219
283
|
};
|
|
220
284
|
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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 () => {
|
|
225
321
|
const current = await tokenStore.loadTokens(userId);
|
|
226
|
-
if (!current)
|
|
227
|
-
|
|
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;
|
|
228
332
|
try {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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.",
|
|
236
343
|
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (answer.ok) {
|
|
237
347
|
// Both tokens: One rotates the pair on every refresh.
|
|
348
|
+
const next = toTokens(answer.body);
|
|
238
349
|
await tokenStore.saveTokens(userId, next);
|
|
239
350
|
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
351
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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;
|
|
256
372
|
};
|
|
257
373
|
|
|
258
|
-
const
|
|
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;
|
|
259
391
|
const tokens = await tokenStore.loadTokens(userId);
|
|
260
|
-
if (!tokens)
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
392
|
+
if (!tokens) throw notConnected();
|
|
393
|
+
if (!expiresWithin(tokens, withinMs)) return tokens;
|
|
394
|
+
return singleFlight(userId, () =>
|
|
395
|
+
refreshUnderLock(userId, (current) => expiresWithin(current, withinMs)),
|
|
396
|
+
);
|
|
264
397
|
};
|
|
265
398
|
|
|
399
|
+
const getAccessToken = async (userId: string): Promise<string> =>
|
|
400
|
+
(await refreshIfExpiring(userId)).accessToken;
|
|
401
|
+
|
|
266
402
|
const oneFetch = async (
|
|
267
403
|
userId: string,
|
|
268
404
|
path: string,
|
|
@@ -373,6 +509,7 @@ export function createOneConnect(config: OneConnectServerConfig): OneConnect {
|
|
|
373
509
|
getAccessToken,
|
|
374
510
|
getTokens: (userId) => tokenStore.loadTokens(userId),
|
|
375
511
|
refreshTokens,
|
|
512
|
+
refreshIfExpiring,
|
|
376
513
|
disconnect: (userId) => tokenStore.clearTokens(userId),
|
|
377
514
|
listConnections,
|
|
378
515
|
listActions,
|
package/src/server/oauth.ts
CHANGED
|
@@ -85,6 +85,22 @@ export function tenancyHeaders(accessToken: string): Record<string, string> {
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/** When a refresh token stops working, in epoch milliseconds, from its
|
|
89
|
+
* `exp` claim. Null when the token carries no readable expiry, in which
|
|
90
|
+
* case only One can say whether it still works. */
|
|
91
|
+
export function refreshTokenExpiresAt(refreshToken: string): number | null {
|
|
92
|
+
try {
|
|
93
|
+
const payload = JSON.parse(
|
|
94
|
+
Buffer.from(refreshToken.split(".")[1], "base64url").toString(),
|
|
95
|
+
) as { exp?: unknown };
|
|
96
|
+
return typeof payload.exp === "number" && Number.isFinite(payload.exp)
|
|
97
|
+
? payload.exp * 1000
|
|
98
|
+
: null;
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
88
104
|
/** The scopes the token was granted, from its claims. Display only. */
|
|
89
105
|
export function tokenScopes(accessToken: string): string[] {
|
|
90
106
|
try {
|
package/src/server/types.ts
CHANGED
|
@@ -13,13 +13,45 @@ export interface OneConnectTokens {
|
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Where the app keeps each user's tokens: its database, a cache, an
|
|
16
|
-
* encrypted cookie. The SDK never sees a token outside these
|
|
17
|
-
*
|
|
16
|
+
* encrypted cookie. The SDK never sees a token outside these calls.
|
|
17
|
+
* `userId` is the app's own id for its user.
|
|
18
18
|
*/
|
|
19
19
|
export interface OneConnectTokenStore {
|
|
20
20
|
saveTokens: (userId: string, tokens: OneConnectTokens) => Promise<void>;
|
|
21
21
|
loadTokens: (userId: string) => Promise<OneConnectTokens | null>;
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Deletes the user's tokens.
|
|
24
|
+
*
|
|
25
|
+
* `failed` is set when the SDK clears because One declared that pair
|
|
26
|
+
* dead. Delete only when the stored refresh token is still
|
|
27
|
+
* `failed.refreshToken`: a newer pair saved in the meantime (a
|
|
28
|
+
* reconnect, another server's refresh) must survive. `failed` is
|
|
29
|
+
* undefined for `disconnect`, which always deletes.
|
|
30
|
+
*/
|
|
31
|
+
clearTokens: (userId: string, failed?: OneConnectTokens) => Promise<void>;
|
|
32
|
+
/**
|
|
33
|
+
* Runs `run` while holding a lock on this user that every server and
|
|
34
|
+
* worker of the app shares: a Postgres advisory lock, a Redis lock, a
|
|
35
|
+
* row lock. The SDK loads, refreshes and saves the user's tokens
|
|
36
|
+
* inside it.
|
|
37
|
+
*
|
|
38
|
+
* Required when the app runs more than one process (serverless,
|
|
39
|
+
* several instances, a background worker). One rotates the refresh
|
|
40
|
+
* token on every use and treats a second use of the old one as theft,
|
|
41
|
+
* revoking the whole grant, so two processes refreshing the same user
|
|
42
|
+
* at once disconnect that user. Without a lock the SDK can only stop
|
|
43
|
+
* that inside a single process.
|
|
44
|
+
*
|
|
45
|
+
* Hold it for at least 60 seconds before any timeout: it spans one
|
|
46
|
+
* call to One's token endpoint.
|
|
47
|
+
*/
|
|
48
|
+
withLock?: <T>(userId: string, run: () => Promise<T>) => Promise<T>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface RefreshIfExpiringOptions {
|
|
52
|
+
/** Refresh when the access token or the refresh token expires within
|
|
53
|
+
* this many milliseconds. One minute when omitted. */
|
|
54
|
+
withinMs?: number;
|
|
23
55
|
}
|
|
24
56
|
|
|
25
57
|
export interface OneConnectServerConfig {
|
|
@@ -78,11 +110,17 @@ export interface CompleteAuthorizationInput {
|
|
|
78
110
|
getCookie: (name: string) => string | undefined;
|
|
79
111
|
}
|
|
80
112
|
|
|
113
|
+
import type { ConnectFailureCode } from "../types";
|
|
114
|
+
export type { ConnectFailureCode };
|
|
115
|
+
|
|
81
116
|
export type AuthorizationOutcome = "connected" | "declined" | "failed";
|
|
82
117
|
|
|
83
118
|
export interface CompleteAuthorizationResult {
|
|
84
119
|
outcome: AuthorizationOutcome;
|
|
85
|
-
/**
|
|
120
|
+
/** Why it failed, as the code the browser receives. Only the code goes
|
|
121
|
+
* on the return URL; the browser shows fixed text for it. */
|
|
122
|
+
code?: ConnectFailureCode;
|
|
123
|
+
/** What happened, for your logs. Never put it in front of the user. */
|
|
86
124
|
message?: string;
|
|
87
125
|
/** Send the browser here with a 302; it carries `?one_connect=…`. */
|
|
88
126
|
redirectUrl: string;
|
|
@@ -137,6 +175,13 @@ export interface RunActionResult {
|
|
|
137
175
|
data: unknown;
|
|
138
176
|
}
|
|
139
177
|
|
|
178
|
+
/**
|
|
179
|
+
* - `not_connected`: no tokens are stored for this user.
|
|
180
|
+
* - `refresh_failed`: One declared the grant dead (revoked, expired or
|
|
181
|
+
* reused). The tokens were cleared; ask the user to connect again.
|
|
182
|
+
* - `request_failed`: One answered with an error or could not be
|
|
183
|
+
* reached. During a refresh the tokens are kept, so retry later.
|
|
184
|
+
*/
|
|
140
185
|
export type OneConnectErrorCode =
|
|
141
186
|
| "not_connected"
|
|
142
187
|
| "refresh_failed"
|
package/src/svelte.ts
CHANGED
|
@@ -5,12 +5,11 @@
|
|
|
5
5
|
* Svelte compiler or dependency is involved:
|
|
6
6
|
*
|
|
7
7
|
* <div use:connectButton={{ authorizeUrl: "/api/one/authorize",
|
|
8
|
-
* platforms: ["stripe", "notion"],
|
|
8
|
+
* platforms: ["stripe", "notion"], connected: data.hasOneGrant,
|
|
9
9
|
* onSuccess: () => { ... } }} />
|
|
10
10
|
*/
|
|
11
11
|
import {
|
|
12
12
|
mountConnectButton,
|
|
13
|
-
optionsFromProps,
|
|
14
13
|
type ConnectButtonHandle,
|
|
15
14
|
type ConnectButtonProps,
|
|
16
15
|
} from "@withone/connect";
|
|
@@ -24,17 +23,10 @@ export function connectButton(
|
|
|
24
23
|
update: (next: ConnectButtonProps) => void;
|
|
25
24
|
destroy: () => void;
|
|
26
25
|
} {
|
|
27
|
-
|
|
28
|
-
node,
|
|
29
|
-
optionsFromProps(props),
|
|
30
|
-
);
|
|
26
|
+
const handle: ConnectButtonHandle = mountConnectButton(node, props);
|
|
31
27
|
return {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
},
|
|
36
|
-
destroy() {
|
|
37
|
-
handle.destroy();
|
|
38
|
-
},
|
|
28
|
+
// In place: the button keeps its state and focus across updates.
|
|
29
|
+
update: (next) => handle.update(next),
|
|
30
|
+
destroy: () => handle.destroy(),
|
|
39
31
|
};
|
|
40
32
|
}
|
package/src/types.ts
CHANGED
|
@@ -10,35 +10,60 @@
|
|
|
10
10
|
/** Theme of One's hosted connect page. */
|
|
11
11
|
export type OneConnectTheme = "light" | "dark";
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
/** Theme of the button: fixed, or following the visitor's setting. */
|
|
14
|
+
export type ConnectButtonTheme = "light" | "dark" | "auto";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Why a flow ended without a grant. The callback route puts only this
|
|
18
|
+
* code on the return URL; the text shown for it is fixed in the SDK, so
|
|
19
|
+
* a crafted link can never put its own words in front of the user.
|
|
20
|
+
*
|
|
21
|
+
* - `declined`: the user cancelled on One's page.
|
|
22
|
+
* - `expired`: the attempt took too long or was started elsewhere.
|
|
23
|
+
* - `failed`: One could not complete the connection.
|
|
24
|
+
*/
|
|
25
|
+
export type ConnectFailureCode = "declined" | "expired" | "failed";
|
|
26
|
+
|
|
27
|
+
export interface OneConnectFlowOptions {
|
|
14
28
|
/** The app's own backend authorize route. Relative paths such as
|
|
15
29
|
* "/api/one/authorize" resolve against the page's origin. */
|
|
16
30
|
authorizeUrl: string;
|
|
17
31
|
/** Theme for One's hosted page. Carried on the URL fragment, which
|
|
18
32
|
* survives the redirect chain, so the backend forwards nothing. */
|
|
33
|
+
connectTheme?: OneConnectTheme;
|
|
34
|
+
/** @deprecated Renamed to `connectTheme`; removed in the next minor. */
|
|
19
35
|
appTheme?: OneConnectTheme;
|
|
20
|
-
/** The grant completed and the backend stored the tokens.
|
|
36
|
+
/** The grant completed and the backend stored the tokens. Fires once
|
|
37
|
+
* per page load, on the first flow still mounted when the tab
|
|
38
|
+
* returns. Treat it as a hint to refetch: your server is the truth. */
|
|
21
39
|
onSuccess?: () => void;
|
|
22
|
-
/** The flow ended without a grant
|
|
23
|
-
*
|
|
24
|
-
onError?: (message: string) => void;
|
|
40
|
+
/** The flow ended without a grant. `message` is fixed text for
|
|
41
|
+
* `code`, safe to show. */
|
|
42
|
+
onError?: (message: string, code: ConnectFailureCode) => void;
|
|
43
|
+
/** The user came back with the browser's Back button before finishing
|
|
44
|
+
* (the page was restored from the back-forward cache). */
|
|
45
|
+
onCancel?: () => void;
|
|
25
46
|
}
|
|
26
47
|
|
|
27
|
-
export interface
|
|
48
|
+
export interface OneConnectFlow {
|
|
28
49
|
/** Navigates the tab to One's hosted connect flow. */
|
|
29
50
|
open: () => void;
|
|
51
|
+
/** Swaps the options (callbacks, theme) without losing the flow. */
|
|
52
|
+
update: (options: OneConnectFlowOptions) => void;
|
|
53
|
+
/** Stops listening: callbacks no longer fire for this flow. */
|
|
54
|
+
destroy: () => void;
|
|
30
55
|
}
|
|
31
56
|
|
|
32
|
-
/** How the
|
|
33
|
-
* redirect, read off the page URL when the tab returns. */
|
|
57
|
+
/** How the flow ended, read off the page URL when the tab returns. */
|
|
34
58
|
export interface OneConnectReturn {
|
|
35
59
|
status: "success" | "error";
|
|
60
|
+
code?: ConnectFailureCode;
|
|
36
61
|
message?: string;
|
|
37
62
|
}
|
|
38
63
|
|
|
39
64
|
/**
|
|
40
65
|
* A connector chip on the button. Pass One's connector slug ("stripe",
|
|
41
|
-
* "google-calendar") and the SDK shows
|
|
66
|
+
* "google-calendar") and the SDK shows its logo and name; pass an
|
|
42
67
|
* object to override either.
|
|
43
68
|
*/
|
|
44
69
|
export type ConnectButtonPlatformInput =
|
|
@@ -52,33 +77,52 @@ export interface ConnectButtonPlatform {
|
|
|
52
77
|
}
|
|
53
78
|
|
|
54
79
|
export type ConnectButtonVariant = "default" | "accent" | "block";
|
|
80
|
+
export type ConnectButtonSize = "sm" | "md" | "lg";
|
|
55
81
|
export type ConnectButtonState = "idle" | "connecting" | "connected";
|
|
56
82
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
* full-width card with a description and a "Secured by One" foot. */
|
|
65
|
-
variant?: ConnectButtonVariant;
|
|
66
|
-
/** Matches the host page, not One's page (that is connect.appTheme). */
|
|
67
|
-
theme?: OneConnectTheme;
|
|
68
|
-
/** Connector chips. The first three render; the rest fold into a "+N"
|
|
69
|
-
* chip, so that count only ever describes this list. */
|
|
83
|
+
/** One prop shape for every surface: React, Vue, Svelte, the custom
|
|
84
|
+
* element and `mountConnectButton`. */
|
|
85
|
+
export interface ConnectButtonProps {
|
|
86
|
+
/** The app's own backend authorize route; relative is fine. */
|
|
87
|
+
authorizeUrl: string;
|
|
88
|
+
/** Connector slugs, or objects that override the name or the logo.
|
|
89
|
+
* The first three draw as logos; the rest fold into a "+N" chip. */
|
|
70
90
|
platforms?: ConnectButtonPlatformInput[];
|
|
71
|
-
/**
|
|
72
|
-
|
|
73
|
-
|
|
91
|
+
/** Whether this user has a live grant, from your server. When set, it
|
|
92
|
+
* decides the Connected state. When omitted, the button shows
|
|
93
|
+
* Connected only right after a successful return. */
|
|
94
|
+
connected?: boolean;
|
|
95
|
+
/** Not clickable, for example until terms are accepted. */
|
|
96
|
+
disabled?: boolean;
|
|
97
|
+
/** default = neutral, accent = your brand colour, block = a card with
|
|
98
|
+
* a description and a "Secured by One" foot. */
|
|
99
|
+
variant?: ConnectButtonVariant;
|
|
100
|
+
size?: ConnectButtonSize;
|
|
101
|
+
/** Stretches to the width of its container. */
|
|
102
|
+
fullWidth?: boolean;
|
|
103
|
+
/** Matches the host page. "auto" follows the visitor's setting. */
|
|
104
|
+
theme?: ConnectButtonTheme;
|
|
105
|
+
/** Theme of One's hosted page. */
|
|
106
|
+
connectTheme?: OneConnectTheme;
|
|
107
|
+
/** @deprecated Renamed to `connectTheme`; removed in the next minor. */
|
|
108
|
+
appTheme?: OneConnectTheme;
|
|
109
|
+
/** Fill of the accent variant; One's lime when omitted. The label is
|
|
110
|
+
* black or white, whichever reads better on it. */
|
|
74
111
|
accentColor?: string;
|
|
75
|
-
/**
|
|
112
|
+
/** "Connect your apps" unless set. */
|
|
113
|
+
label?: string;
|
|
114
|
+
/** "Connected" unless set. */
|
|
76
115
|
connectedLabel?: string;
|
|
116
|
+
/** Sub-line on the block variant. */
|
|
117
|
+
description?: string;
|
|
118
|
+
onSuccess?: () => void;
|
|
119
|
+
onError?: (message: string, code: ConnectFailureCode) => void;
|
|
120
|
+
onCancel?: () => void;
|
|
77
121
|
}
|
|
78
122
|
|
|
79
123
|
export interface ConnectButtonHandle {
|
|
80
|
-
/**
|
|
81
|
-
|
|
82
|
-
/**
|
|
124
|
+
/** Applies new props in place, keeping the button's state. */
|
|
125
|
+
update: (props: ConnectButtonProps) => void;
|
|
126
|
+
/** Removes the button and stops its callbacks. */
|
|
83
127
|
destroy: () => void;
|
|
84
128
|
}
|