@withone/connect 0.16.1 → 0.17.1
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 +35 -2
- package/dist/constants.d.ts +8 -0
- package/dist/index.cjs.js +1 -1
- package/dist/index.esm.js +1 -1
- package/dist/next.cjs.js +13 -2
- package/dist/next.d.ts +0 -16
- package/dist/next.esm.js +13 -2
- package/dist/react.cjs.js +1 -1
- package/dist/react.esm.js +1 -1
- package/dist/server/index.cjs.js +74 -12
- package/dist/server/index.esm.js +74 -12
- package/dist/server/oauth.d.ts +13 -1
- package/dist/server/types.d.ts +24 -3
- package/dist/types.d.ts +15 -3
- package/dist/vue.cjs.js +1 -1
- package/dist/vue.d.ts +18 -0
- package/dist/vue.esm.js +1 -1
- package/package.json +1 -1
- package/skills/one-connect/SKILL.md +25 -6
- package/src/button.ts +6 -0
- package/src/constants.ts +10 -0
- package/src/flow.ts +11 -3
- package/src/next.ts +6 -1
- package/src/react.ts +2 -0
- package/src/server/index.ts +37 -15
- package/src/server/oauth.ts +32 -1
- package/src/server/types.ts +25 -3
- package/src/types.ts +15 -3
- package/src/vue.ts +2 -0
package/src/react.ts
CHANGED
package/src/server/index.ts
CHANGED
|
@@ -38,6 +38,8 @@ import {
|
|
|
38
38
|
createPkceVerifier,
|
|
39
39
|
createState,
|
|
40
40
|
pkceChallenge,
|
|
41
|
+
appPath,
|
|
42
|
+
parseTxCookieValue,
|
|
41
43
|
txCookie,
|
|
42
44
|
txCookieName,
|
|
43
45
|
} from "./oauth";
|
|
@@ -57,6 +59,7 @@ import {
|
|
|
57
59
|
type ReachableConnection,
|
|
58
60
|
type RefreshIfExpiringOptions,
|
|
59
61
|
type RunActionInput,
|
|
62
|
+
type OneRefusal,
|
|
60
63
|
type RunActionResult,
|
|
61
64
|
type SearchActionsOptions,
|
|
62
65
|
type StartAuthorizationInput,
|
|
@@ -67,6 +70,24 @@ export * from "./types";
|
|
|
67
70
|
export { encodeUserReference, parseUserReference } from "./key";
|
|
68
71
|
export { refreshTokenExpiresAt, tenancyHeaders, tokenScopes } from "./oauth";
|
|
69
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Why One itself refused a call, from its reply. One's own errors carry a
|
|
75
|
+
* correlationId; a provider's error does not, so it reads `null`. Only a
|
|
76
|
+
* 403 is the grant: One also answers a plan limit (402), a rate limit
|
|
77
|
+
* (429) and its own failures (5xx) with a correlationId, and none of those
|
|
78
|
+
* mean the call is outside what the user granted.
|
|
79
|
+
*/
|
|
80
|
+
function oneRefusal(response: Response, data: unknown): OneRefusal | null {
|
|
81
|
+
if (response.ok) return null;
|
|
82
|
+
const fromOne =
|
|
83
|
+
typeof data === "object" && data !== null && "correlationId" in (data as Record<string, unknown>);
|
|
84
|
+
if (!fromOne) return null;
|
|
85
|
+
if (response.status === 403) return "grant";
|
|
86
|
+
if (response.status === 402) return "plan_limit";
|
|
87
|
+
if (response.status === 429) return "rate_limit";
|
|
88
|
+
return "one_error";
|
|
89
|
+
}
|
|
90
|
+
|
|
70
91
|
const CATALOG_PAGE_SIZE = 100;
|
|
71
92
|
const CATALOG_MAX_PAGES = 20;
|
|
72
93
|
const SEARCH_DEFAULT_LIMIT = 5;
|
|
@@ -246,8 +267,12 @@ export function createOneConnect(
|
|
|
246
267
|
const returnTo = config.returnTo ?? "/";
|
|
247
268
|
const scopes = config.scopes ?? DEFAULT_SCOPES;
|
|
248
269
|
|
|
249
|
-
const returnUrl = (
|
|
250
|
-
|
|
270
|
+
const returnUrl = (
|
|
271
|
+
status: "success" | "error",
|
|
272
|
+
code?: ConnectFailureCode,
|
|
273
|
+
flowReturnTo?: string,
|
|
274
|
+
): string => {
|
|
275
|
+
const url = new URL(flowReturnTo ?? returnTo, config.redirectUri);
|
|
251
276
|
url.searchParams.set(RETURN_STATUS_PARAM, status);
|
|
252
277
|
if (code) url.searchParams.set(RETURN_ERROR_PARAM, code);
|
|
253
278
|
return url.toString();
|
|
@@ -319,7 +344,7 @@ export function createOneConnect(
|
|
|
319
344
|
if (input.loginHint) url.searchParams.set("login_hint", input.loginHint);
|
|
320
345
|
return {
|
|
321
346
|
redirectUrl: url.toString(),
|
|
322
|
-
cookie: txCookie(state, verifier, config.redirectUri),
|
|
347
|
+
cookie: txCookie(state, verifier, config.redirectUri, appPath(input.returnTo)),
|
|
323
348
|
};
|
|
324
349
|
};
|
|
325
350
|
|
|
@@ -331,7 +356,10 @@ export function createOneConnect(
|
|
|
331
356
|
const state = params.get("state");
|
|
332
357
|
const oauthError = params.get("error");
|
|
333
358
|
const cookieName = state ? txCookieName(state) : undefined;
|
|
334
|
-
const
|
|
359
|
+
const stored = cookieName ? input.getCookie(cookieName) : undefined;
|
|
360
|
+
const { verifier, returnTo: flowReturnTo } = stored
|
|
361
|
+
? parseTxCookieValue(stored)
|
|
362
|
+
: { verifier: undefined, returnTo: undefined };
|
|
335
363
|
|
|
336
364
|
const fail = (
|
|
337
365
|
failure: ConnectFailureCode,
|
|
@@ -340,7 +368,7 @@ export function createOneConnect(
|
|
|
340
368
|
outcome: failure === "declined" ? "declined" : "failed",
|
|
341
369
|
code: failure,
|
|
342
370
|
message,
|
|
343
|
-
redirectUrl: returnUrl("error", failure),
|
|
371
|
+
redirectUrl: returnUrl("error", failure, flowReturnTo),
|
|
344
372
|
clearCookieName: cookieName,
|
|
345
373
|
});
|
|
346
374
|
|
|
@@ -378,7 +406,7 @@ export function createOneConnect(
|
|
|
378
406
|
|
|
379
407
|
return {
|
|
380
408
|
outcome: "connected",
|
|
381
|
-
redirectUrl: returnUrl("success"),
|
|
409
|
+
redirectUrl: returnUrl("success", undefined, flowReturnTo),
|
|
382
410
|
clearCookieName: cookieName,
|
|
383
411
|
};
|
|
384
412
|
};
|
|
@@ -615,7 +643,7 @@ export function createOneConnect(
|
|
|
615
643
|
// tells them apart: if that still answers, the consent stands
|
|
616
644
|
// and it was this call One refused.
|
|
617
645
|
if (await consentStands(userId, refused))
|
|
618
|
-
return { status: response.status, ok: false, blockedByGrant: true, data: text };
|
|
646
|
+
return { status: response.status, ok: false, blockedByGrant: true, refusal: "grant", data: text };
|
|
619
647
|
throw refused;
|
|
620
648
|
}
|
|
621
649
|
// One refusing the credential is not an answer to the action.
|
|
@@ -627,14 +655,8 @@ export function createOneConnect(
|
|
|
627
655
|
} catch {
|
|
628
656
|
/* the provider answered with something other than JSON */
|
|
629
657
|
}
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
const blockedByGrant =
|
|
633
|
-
!response.ok &&
|
|
634
|
-
typeof data === "object" &&
|
|
635
|
-
data !== null &&
|
|
636
|
-
"correlationId" in (data as Record<string, unknown>);
|
|
637
|
-
return { status: response.status, ok: response.ok, blockedByGrant, data };
|
|
658
|
+
const refusal = oneRefusal(response, data);
|
|
659
|
+
return { status: response.status, ok: response.ok, blockedByGrant: refusal === "grant", refusal, data };
|
|
638
660
|
};
|
|
639
661
|
|
|
640
662
|
const shared = {
|
package/src/server/oauth.ts
CHANGED
|
@@ -36,18 +36,49 @@ export function txCookieName(state: string): string {
|
|
|
36
36
|
return `${TX_COOKIE_PREFIX}${state}`;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/** A return path this one flow may use: a path on the app itself. A
|
|
40
|
+
* full URL, a protocol-relative one or anything else is dropped, so
|
|
41
|
+
* the callback never sends a user to another site. */
|
|
42
|
+
export function appPath(value: string | undefined): string | undefined {
|
|
43
|
+
if (!value || !value.startsWith("/")) return undefined;
|
|
44
|
+
if (value.startsWith("//") || value.startsWith("/\\")) return undefined;
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The cookie value: the verifier, then the flow's own return path when
|
|
49
|
+
* it has one. A dot separates them; the verifier is base64url, which
|
|
50
|
+
* never contains one, so a value written before 0.17 still reads. */
|
|
51
|
+
export function txCookieValue(verifier: string, returnTo?: string): string {
|
|
52
|
+
return returnTo
|
|
53
|
+
? `${verifier}.${Buffer.from(returnTo).toString("base64url")}`
|
|
54
|
+
: verifier;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function parseTxCookieValue(value: string): {
|
|
58
|
+
verifier: string;
|
|
59
|
+
returnTo?: string;
|
|
60
|
+
} {
|
|
61
|
+
const dot = value.indexOf(".");
|
|
62
|
+
if (dot < 0) return { verifier: value };
|
|
63
|
+
return {
|
|
64
|
+
verifier: value.slice(0, dot),
|
|
65
|
+
returnTo: appPath(Buffer.from(value.slice(dot + 1), "base64url").toString()),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
39
69
|
/** The cookie scoped to the routes that need it: the directory of the
|
|
40
70
|
* callback path, which is also where the authorize route lives. */
|
|
41
71
|
export function txCookie(
|
|
42
72
|
state: string,
|
|
43
73
|
verifier: string,
|
|
44
74
|
redirectUri: string,
|
|
75
|
+
returnTo?: string,
|
|
45
76
|
): OneConnectCookie {
|
|
46
77
|
const url = new URL(redirectUri);
|
|
47
78
|
const path = url.pathname.replace(/\/[^/]*$/, "") || "/";
|
|
48
79
|
return {
|
|
49
80
|
name: txCookieName(state),
|
|
50
|
-
value: verifier,
|
|
81
|
+
value: txCookieValue(verifier, returnTo),
|
|
51
82
|
options: {
|
|
52
83
|
httpOnly: true,
|
|
53
84
|
secure: url.protocol === "https:",
|
package/src/server/types.ts
CHANGED
|
@@ -161,6 +161,10 @@ export interface OneConnectCookie {
|
|
|
161
161
|
export interface StartAuthorizationInput {
|
|
162
162
|
/** Pre-fills the sign-in email on One's page. Never locks it. */
|
|
163
163
|
loginHint?: string;
|
|
164
|
+
/** Where this one flow returns to: a path on your app ("/chat/42").
|
|
165
|
+
* Kept in the flow's cookie, never sent to One. Anything that is not
|
|
166
|
+
* a plain path is ignored and the configured `returnTo` applies. */
|
|
167
|
+
returnTo?: string;
|
|
164
168
|
}
|
|
165
169
|
|
|
166
170
|
export interface StartAuthorizationResult {
|
|
@@ -275,13 +279,31 @@ export interface RunActionInput {
|
|
|
275
279
|
encoding?: ActionBodyEncoding;
|
|
276
280
|
}
|
|
277
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Why One itself turned a call down, when it did. A provider's own error
|
|
284
|
+
* (Gmail's 404, Stripe's 400) is not One's refusal and reads `null`.
|
|
285
|
+
*
|
|
286
|
+
* - `grant`: the call is outside what the user granted (403). Do not retry.
|
|
287
|
+
* - `plan_limit`: a plan limit was hit (402, "Please try again later or
|
|
288
|
+
* upgrade your plan"). Not about the grant: wait and retry later.
|
|
289
|
+
* - `rate_limit`: too many calls (429). Wait and retry later.
|
|
290
|
+
* - `one_error`: any other error from One (for example a 5xx). Retry with
|
|
291
|
+
* a backoff.
|
|
292
|
+
*/
|
|
293
|
+
export type OneRefusal = "grant" | "plan_limit" | "rate_limit" | "one_error";
|
|
294
|
+
|
|
278
295
|
export interface RunActionResult {
|
|
279
296
|
status: number;
|
|
280
297
|
ok: boolean;
|
|
281
|
-
/** True when One refused the call because it is outside the grant
|
|
282
|
-
* The provider was never called. Do not retry. Key mode sets
|
|
283
|
-
* token mode it stays false today, so treat any 403 there as
|
|
298
|
+
/** True only when One refused the call because it is outside the grant
|
|
299
|
+
* (a 403). The provider was never called. Do not retry. Key mode sets
|
|
300
|
+
* it; in token mode it stays false today, so treat any 403 there as
|
|
301
|
+
* refused. One's other refusals (plan limit, rate limit, its own
|
|
302
|
+
* errors) are not about the grant: see `refusal`. */
|
|
284
303
|
blockedByGrant: boolean;
|
|
304
|
+
/** Why One turned the call down, or `null` when it didn't (the call
|
|
305
|
+
* succeeded, or the provider answered with its own error). */
|
|
306
|
+
refusal: OneRefusal | null;
|
|
285
307
|
data: unknown;
|
|
286
308
|
}
|
|
287
309
|
|
package/src/types.ts
CHANGED
|
@@ -31,6 +31,14 @@ export interface OneConnectFlowOptions {
|
|
|
31
31
|
/** Theme for One's hosted page. Carried on the URL fragment, which
|
|
32
32
|
* survives the redirect chain, so the backend forwards nothing. */
|
|
33
33
|
connectTheme?: OneConnectTheme;
|
|
34
|
+
/** A connector slug ("gmail"). One's page opens that connector's
|
|
35
|
+
* connect screen first, then shows the full list as usual. Carried
|
|
36
|
+
* on the URL fragment like the theme. */
|
|
37
|
+
connector?: string;
|
|
38
|
+
/** Where the user lands afterwards, for this flow only: a path on
|
|
39
|
+
* your app such as "/chat/42". Your authorize route reads it and
|
|
40
|
+
* the callback uses it instead of the server's `returnTo`. */
|
|
41
|
+
returnTo?: string;
|
|
34
42
|
/** The grant completed and the backend stored the tokens. Fires once
|
|
35
43
|
* per page load, on the first flow still mounted when the tab
|
|
36
44
|
* returns. Treat it as a hint to refetch: your server is the truth. */
|
|
@@ -75,9 +83,9 @@ export interface ConnectButtonLogo {
|
|
|
75
83
|
imageUrl: string;
|
|
76
84
|
}
|
|
77
85
|
|
|
78
|
-
/** @deprecated Renamed to `ConnectButtonLogoInput`; removed in 0.
|
|
86
|
+
/** @deprecated Renamed to `ConnectButtonLogoInput`; removed in 0.18.0. */
|
|
79
87
|
export type ConnectButtonPlatformInput = ConnectButtonLogoInput;
|
|
80
|
-
/** @deprecated Renamed to `ConnectButtonLogo`; removed in 0.
|
|
88
|
+
/** @deprecated Renamed to `ConnectButtonLogo`; removed in 0.18.0. */
|
|
81
89
|
export type ConnectButtonPlatform = ConnectButtonLogo;
|
|
82
90
|
|
|
83
91
|
export type ConnectButtonVariant = "default" | "accent" | "block";
|
|
@@ -94,7 +102,7 @@ export interface ConnectButtonProps {
|
|
|
94
102
|
* into a "+N" chip. Decoration only; the permission set decides what
|
|
95
103
|
* One asks for. */
|
|
96
104
|
logos?: ConnectButtonLogoInput[];
|
|
97
|
-
/** @deprecated Renamed to `logos`; removed in 0.
|
|
105
|
+
/** @deprecated Renamed to `logos`; removed in 0.18.0. */
|
|
98
106
|
platforms?: ConnectButtonLogoInput[];
|
|
99
107
|
/** Whether this user has a live grant, from your server. When set, it
|
|
100
108
|
* decides the Connected state. When omitted, the button shows
|
|
@@ -113,6 +121,10 @@ export interface ConnectButtonProps {
|
|
|
113
121
|
theme?: ConnectButtonTheme;
|
|
114
122
|
/** Theme of One's hosted page. */
|
|
115
123
|
connectTheme?: OneConnectTheme;
|
|
124
|
+
/** A connector slug ("gmail") to open first on One's page. */
|
|
125
|
+
connector?: string;
|
|
126
|
+
/** Where this flow returns to: a path on your app. */
|
|
127
|
+
returnTo?: string;
|
|
116
128
|
/** "Connect your apps" unless set. */
|
|
117
129
|
label?: string;
|
|
118
130
|
/** "Connected" unless set. */
|
package/src/vue.ts
CHANGED
|
@@ -62,6 +62,8 @@ export const ConnectButton = defineComponent({
|
|
|
62
62
|
type: String as PropType<OneConnectTheme>,
|
|
63
63
|
default: undefined,
|
|
64
64
|
},
|
|
65
|
+
connector: { type: String, default: undefined },
|
|
66
|
+
returnTo: { type: String, default: undefined },
|
|
65
67
|
label: { type: String, default: undefined },
|
|
66
68
|
connectedLabel: { type: String, default: undefined },
|
|
67
69
|
description: { type: String, default: undefined },
|