@withone/connect 0.8.2 → 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 -137
- 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
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<img src="https://assets.withone.ai/banners/connect.png" alt="One Connect
|
|
1
|
+
<img src="https://assets.withone.ai/banners/connect.png" alt="One Connect. Let your users grant your app scoped, revocable access to their own tools." style="border-radius: 5px;">
|
|
2
2
|
|
|
3
3
|
<h3 align="center">One Connect</h3>
|
|
4
4
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
·
|
|
8
8
|
<a href="https://withone.ai/docs/connect"><strong>Docs</strong></a>
|
|
9
9
|
·
|
|
10
|
-
<a href="https://app.withone.ai"><strong>Dashboard</strong></a>
|
|
10
|
+
<a href="https://app.withone.ai/developers/connect"><strong>Dashboard</strong></a>
|
|
11
11
|
·
|
|
12
12
|
<a href="https://withone.ai/changelog"><strong>Changelog</strong></a>
|
|
13
13
|
·
|
|
@@ -20,157 +20,77 @@
|
|
|
20
20
|
<a href="https://npmjs.com/package/@withone/connect"><img src="https://img.shields.io/npm/v/%40withone%2Fconnect" alt="npm version"></a>
|
|
21
21
|
</p>
|
|
22
22
|
|
|
23
|
-
One Connect lets your users grant your
|
|
23
|
+
One Connect lets your users grant your app **scoped, revocable access to their own One-connected tools**: Gmail, Slack, Notion, Stripe and 500 more. Your user keeps their connections in One. Your app holds only what they granted, and One checks that on every call.
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
You build three things: a button, two backend routes, and the calls you make with the grant. This package gives you all three.
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
```
|
|
28
|
+
Browser Your server One
|
|
29
|
+
<ConnectButton> ───────► GET /api/one/authorize ──302──► One's hosted connect page
|
|
30
|
+
sign in · pick tools · set access
|
|
31
|
+
GET /api/one/callback ◄──302── ?code&state
|
|
32
|
+
exchanges the code, stores the tokens
|
|
33
|
+
302 → /?one_connect=success
|
|
34
|
+
onSuccess() fires ◄────────
|
|
35
|
+
later: oneConnect.runAction(userId, …) ──► /v1/passthrough (grant enforced)
|
|
36
|
+
```
|
|
28
37
|
|
|
29
|
-
> **Connect vs. Auth
|
|
38
|
+
> **Connect vs. Auth.** [`@withone/auth`](https://github.com/withoneai/auth) puts connections in *your* One project: you own them. Connect puts connections in *your user's* One account and hands you a grant.
|
|
30
39
|
|
|
31
40
|
## Install
|
|
32
41
|
|
|
33
|
-
With npm:
|
|
34
|
-
|
|
35
42
|
```bash
|
|
36
|
-
npm
|
|
43
|
+
npm install @withone/connect
|
|
37
44
|
```
|
|
38
45
|
|
|
39
|
-
|
|
46
|
+
Or let your coding agent do the whole setup:
|
|
40
47
|
|
|
41
48
|
```bash
|
|
42
|
-
|
|
49
|
+
npx skills add withoneai/connect
|
|
43
50
|
```
|
|
44
51
|
|
|
45
|
-
##
|
|
46
|
-
|
|
47
|
-
Everything sensitive — `state`, the PKCE verifier, your client secret, the tokens — lives on **your server**. The SDK is a thin navigator: it sends the tab to One's hosted connect page and never touches a token.
|
|
48
|
-
|
|
49
|
-
You build exactly **two backend routes and one button**.
|
|
50
|
-
|
|
51
|
-
```mermaid
|
|
52
|
-
sequenceDiagram
|
|
53
|
-
participant User
|
|
54
|
-
participant YourApp as Your Application
|
|
55
|
-
participant YourBackend as Your Backend
|
|
56
|
-
participant One as One Connect
|
|
57
|
-
|
|
58
|
-
User->>YourApp: Clicks "Connect your tools"
|
|
59
|
-
YourApp->>YourBackend: Navigate tab → GET /api/one/authorize
|
|
60
|
-
YourBackend->>YourBackend: Mint state + PKCE, set httpOnly cookie
|
|
61
|
-
YourBackend->>One: 302 → /oauth/authorize
|
|
62
|
-
User->>One: Sign in, connect tools, narrow & grant access
|
|
63
|
-
One->>YourBackend: 302 → /api/one/callback?code&state
|
|
64
|
-
YourBackend->>YourBackend: Verify state
|
|
65
|
-
YourBackend->>One: POST /oauth/token (code + verifier + secret)
|
|
66
|
-
One->>YourBackend: Access token + refresh token
|
|
67
|
-
YourBackend->>YourApp: 302 → /?one_connect=success
|
|
68
|
-
YourApp->>User: SDK detects the return, onSuccess() fires
|
|
69
|
-
```
|
|
52
|
+
## 1 · Create your app in One
|
|
70
53
|
|
|
71
|
-
|
|
54
|
+
Dashboard → **Developers → Connect → New app**.
|
|
72
55
|
|
|
73
|
-
|
|
56
|
+
- You get a **client id** (public) and a **client secret** (shown once; server only).
|
|
57
|
+
- Register the **redirect URI** of your callback route, exactly: `https://yourapp.com/api/one/callback`.
|
|
58
|
+
- Choose what the app asks for: specific connectors and levels (an *ask*, which gives you a permission set id), or everything the user has connected.
|
|
59
|
+
- Optionally write the one line users see about why you ask.
|
|
74
60
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
- You get a **Client ID** (public) and a **Client Secret** (shown once — server-only, never in a browser).
|
|
78
|
-
- Register your **redirect URI** (e.g. `https://yourapp.com/api/one/callback`).
|
|
79
|
-
- Pick the **access-token lifetime**: 7 days, 30 days, 90 days or 1 year.
|
|
80
|
-
- Optionally create a **permission set** — the connectors your app needs and the access level for each (full / read & write / read only / specific actions). Your user sees it pre-filled at consent and can only *narrow* it. Without one, your app asks for access to the user's connections generally, which they can also narrow.
|
|
81
|
-
|
|
82
|
-
### Environment Variables
|
|
61
|
+
Environment variables, server side:
|
|
83
62
|
|
|
84
63
|
```env
|
|
85
|
-
ONE_CLIENT_ID
|
|
86
|
-
ONE_CLIENT_SECRET=one_secret_
|
|
64
|
+
ONE_CLIENT_ID=…
|
|
65
|
+
ONE_CLIENT_SECRET=one_secret_…
|
|
87
66
|
ONE_REDIRECT_URI=https://yourapp.com/api/one/callback
|
|
88
|
-
ONE_PERMISSION_SET
|
|
67
|
+
ONE_PERMISSION_SET=… # optional: the ask to open on
|
|
68
|
+
ONE_API_URL=https://api.withone.ai # optional: production when unset
|
|
89
69
|
```
|
|
90
70
|
|
|
91
|
-
| Variable | Required |
|
|
71
|
+
| Variable | Required | What it is |
|
|
92
72
|
|---|---|---|
|
|
93
|
-
| `ONE_CLIENT_ID` |
|
|
94
|
-
| `ONE_CLIENT_SECRET` |
|
|
95
|
-
| `ONE_REDIRECT_URI` |
|
|
96
|
-
| `ONE_PERMISSION_SET` |
|
|
97
|
-
|
|
98
|
-
## 2 · Using the Connect component
|
|
73
|
+
| `ONE_CLIENT_ID` | yes | Public client id from your app |
|
|
74
|
+
| `ONE_CLIENT_SECRET` | yes | Server only. Never in a browser, a log or an error report. |
|
|
75
|
+
| `ONE_REDIRECT_URI` | yes | Must equal the registered URI character for character |
|
|
76
|
+
| `ONE_PERMISSION_SET` | no | The ask the consent page opens on. Without it, the page lists everything the user has connected. |
|
|
77
|
+
| `ONE_API_URL` | no | `https://development-api.withone.ai` for the development environment |
|
|
99
78
|
|
|
100
|
-
|
|
79
|
+
## 2 · The button
|
|
101
80
|
|
|
102
|
-
|
|
81
|
+
Any element wired to `open()` works. The pre-built button draws connector logos from their slugs, and manages Connect → Connecting → Connected on its own.
|
|
103
82
|
|
|
104
83
|
```tsx
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
import { useOneConnect } from "@withone/connect";
|
|
108
|
-
|
|
109
|
-
export function ConnectWithOne() {
|
|
110
|
-
const { open } = useOneConnect({
|
|
111
|
-
authorize: {
|
|
112
|
-
url: "https://your-domain.com/api/one/authorize",
|
|
113
|
-
},
|
|
114
|
-
appTheme: "light",
|
|
115
|
-
onSuccess: () => {
|
|
116
|
-
// Your backend already stored the tokens by the time this fires.
|
|
117
|
-
console.log("Access granted");
|
|
118
|
-
},
|
|
119
|
-
onError: (error) => {
|
|
120
|
-
console.error("Connect failed:", error);
|
|
121
|
-
},
|
|
122
|
-
onClose: () => {
|
|
123
|
-
console.log("Connect flow closed");
|
|
124
|
-
},
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
return <button onClick={open}>Connect your tools</button>;
|
|
128
|
-
}
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
### Configuration Options
|
|
132
|
-
|
|
133
|
-
| Option | Type | Description |
|
|
134
|
-
|---|---|---|
|
|
135
|
-
| `authorize.url` | `string` | Full URL of your backend authorize endpoint. Must be absolute. |
|
|
136
|
-
| `appTheme` | `"dark" \| "light"` | Theme for the Connect card. The SDK carries it on the URL fragment — nothing for your backend to forward. |
|
|
137
|
-
| `onSuccess` | `() => void` | The grant completed and your server stored the tokens |
|
|
138
|
-
| `onError` | `(error: string) => void` | The flow failed, with a human-readable message |
|
|
139
|
-
| `onClose` | `() => void` | The user closed the card without a result |
|
|
140
|
-
|
|
141
|
-
### Returned handle
|
|
142
|
-
|
|
143
|
-
| Method | Description |
|
|
144
|
-
|---|---|
|
|
145
|
-
| `open()` | Navigates the tab to One's hosted connect flow |
|
|
146
|
-
| `close()` | No-op kept for API stability — safe to call on unmount |
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
### Optional: the pre-built button
|
|
150
|
-
|
|
151
|
-
Any element wired to `open()` works — the button is **optional**. It
|
|
152
|
-
ships as a custom element, `<one-connect-button>`, so the SAME tag
|
|
153
|
-
works in React, Next, Vue, Svelte, or plain HTML — no refs, no mount
|
|
154
|
-
calls. Importing the package registers it. It wires the whole flow
|
|
155
|
-
itself and manages Connect → Connecting → Connected.
|
|
156
|
-
|
|
157
|
-
```tsx
|
|
158
|
-
// React / Next — a real component:
|
|
84
|
+
// React / Next.js
|
|
159
85
|
import { ConnectButton } from "@withone/connect/react";
|
|
160
86
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
{ name: "PostHog", imageUrl: "/icons/posthog.svg" },
|
|
169
|
-
]}
|
|
170
|
-
onSuccess={() => {/* tokens stored server-side — refresh app state */}}
|
|
171
|
-
/>
|
|
172
|
-
);
|
|
173
|
-
}
|
|
87
|
+
<ConnectButton
|
|
88
|
+
authorizeUrl="/api/one/authorize"
|
|
89
|
+
platforms={["stripe", "google-calendar", "gmail"]}
|
|
90
|
+
moreCount={274}
|
|
91
|
+
onSuccess={() => refreshAppState()}
|
|
92
|
+
onError={(message) => showBanner(message)}
|
|
93
|
+
/>
|
|
174
94
|
```
|
|
175
95
|
|
|
176
96
|
```vue
|
|
@@ -179,293 +99,161 @@ export function ConnectWithOne() {
|
|
|
179
99
|
import { ConnectButton } from "@withone/connect/vue";
|
|
180
100
|
</script>
|
|
181
101
|
<template>
|
|
182
|
-
<ConnectButton
|
|
183
|
-
authorize-url="/api/one/authorize"
|
|
184
|
-
:platforms="[{ name: 'Stripe', imageUrl: '/icons/stripe.svg' }]"
|
|
185
|
-
@success="onConnected"
|
|
186
|
-
/>
|
|
102
|
+
<ConnectButton authorize-url="/api/one/authorize" :platforms="['stripe', 'notion']" @success="onConnected" />
|
|
187
103
|
</template>
|
|
188
104
|
```
|
|
189
105
|
|
|
190
106
|
```svelte
|
|
191
|
-
<!-- Svelte
|
|
107
|
+
<!-- Svelte, as an action -->
|
|
192
108
|
<script>
|
|
193
109
|
import { connectButton } from "@withone/connect/svelte";
|
|
194
110
|
</script>
|
|
195
|
-
<div use:connectButton={{
|
|
196
|
-
authorizeUrl: "/api/one/authorize",
|
|
197
|
-
platforms: [{ name: "Stripe", imageUrl: "/icons/stripe.svg" }],
|
|
198
|
-
onSuccess: () => { /* refresh app state */ },
|
|
199
|
-
}} />
|
|
111
|
+
<div use:connectButton={{ authorizeUrl: "/api/one/authorize", platforms: ["stripe", "notion"], onSuccess }} />
|
|
200
112
|
```
|
|
201
113
|
|
|
202
|
-
Plain HTML (or any other framework) uses the SAME widget as a custom
|
|
203
|
-
element — `import "@withone/connect"` registers `<one-connect-button>`:
|
|
204
|
-
|
|
205
114
|
```html
|
|
206
|
-
<!-- Plain HTML
|
|
207
|
-
<one-connect-button
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
<script>
|
|
212
|
-
document.querySelector("one-connect-button")
|
|
213
|
-
.addEventListener("success", () => location.reload());
|
|
115
|
+
<!-- Plain HTML or any other framework: importing the package registers the element -->
|
|
116
|
+
<one-connect-button authorize-url="/api/one/authorize" platforms="stripe, notion" more-count="274"></one-connect-button>
|
|
117
|
+
<script type="module">
|
|
118
|
+
import "@withone/connect";
|
|
119
|
+
document.querySelector("one-connect-button").addEventListener("success", () => location.reload());
|
|
214
120
|
</script>
|
|
215
121
|
```
|
|
216
122
|
|
|
217
|
-
|
|
|
123
|
+
| Prop (attribute) | What it does |
|
|
218
124
|
|---|---|
|
|
219
|
-
| `authorize-url` | Your
|
|
220
|
-
| `
|
|
221
|
-
| `
|
|
222
|
-
| `
|
|
223
|
-
| `
|
|
224
|
-
| `
|
|
225
|
-
| `more-count` | The `+N` chip (e.g. `274`) |
|
|
125
|
+
| `authorizeUrl` (`authorize-url`) | Your authorize route. Relative paths resolve against the page. Required. |
|
|
126
|
+
| `platforms` | Connector slugs: `["stripe", "google-calendar"]`. Logos and names come from One. To override either, pass `{ slug, name, imageUrl }`. As an attribute: `"stripe, notion"`. |
|
|
127
|
+
| `moreCount` (`more-count`) | The `+N` chip after the first three logos |
|
|
128
|
+
| `label` | Button text. Default "Connect your apps". |
|
|
129
|
+
| `connectedLabel` (`connected-label`) | Text after a successful return. Default "Connected". |
|
|
130
|
+
| `variant` | `default` pill · `accent` brand-colored pill · `block` card with a description |
|
|
226
131
|
| `description` | Sub-line on the `block` variant |
|
|
227
|
-
| `
|
|
228
|
-
| `
|
|
132
|
+
| `theme` | `light` or `dark`, matching *your* page |
|
|
133
|
+
| `appTheme` (`app-theme`) | `light` or `dark` for One's page |
|
|
134
|
+
| `accentColor` (`accent-color`) | Fill of the `accent` variant. One's lime when omitted. |
|
|
135
|
+
| `onSuccess` / `onError` | Fire once when the tab returns. `onError` receives a message safe to show. The element also dispatches `success` and `error` events. |
|
|
229
136
|
|
|
230
|
-
|
|
231
|
-
`onSuccess` / `onError` / `onClose` function props (React 19, Vue and
|
|
232
|
-
Svelte set these naturally).
|
|
233
|
-
|
|
234
|
-
TypeScript + React: add this once so JSX accepts the tag:
|
|
137
|
+
Your own element:
|
|
235
138
|
|
|
236
139
|
```ts
|
|
237
|
-
|
|
238
|
-
declare module "react" {
|
|
239
|
-
namespace JSX {
|
|
240
|
-
interface IntrinsicElements {
|
|
241
|
-
"one-connect-button": React.DetailedHTMLProps<
|
|
242
|
-
React.HTMLAttributes<HTMLElement>,
|
|
243
|
-
HTMLElement
|
|
244
|
-
> & {
|
|
245
|
-
"authorize-url"?: string; "app-theme"?: string; label?: string;
|
|
246
|
-
variant?: string; theme?: string; platforms?: string;
|
|
247
|
-
"more-count"?: string; description?: string;
|
|
248
|
-
"accent-color"?: string; "connected-label"?: string;
|
|
249
|
-
onSuccess?: () => void; onError?: (e: string) => void;
|
|
250
|
-
onClose?: () => void;
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
export {};
|
|
256
|
-
```
|
|
140
|
+
import { useOneConnect } from "@withone/connect";
|
|
257
141
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
Generates `state` (CSRF proof) and PKCE (proof that whoever redeems the code is this server), stashes both in an httpOnly cookie, and 302s the browser to One.
|
|
265
|
-
|
|
266
|
-
```typescript
|
|
267
|
-
// app/api/one/authorize/route.ts (Next.js App Router)
|
|
268
|
-
import { createHash, randomBytes } from "crypto";
|
|
269
|
-
import { NextRequest, NextResponse } from "next/server";
|
|
270
|
-
|
|
271
|
-
const ONE_AUTHORIZE_URL = "https://api.withone.ai/oauth/authorize";
|
|
272
|
-
|
|
273
|
-
export async function GET(req: NextRequest) {
|
|
274
|
-
const state = randomBytes(16).toString("hex");
|
|
275
|
-
const verifier = randomBytes(32).toString("base64url");
|
|
276
|
-
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
277
|
-
|
|
278
|
-
const url = new URL(ONE_AUTHORIZE_URL);
|
|
279
|
-
url.searchParams.set("client_id", process.env.ONE_CLIENT_ID!);
|
|
280
|
-
url.searchParams.set("redirect_uri", process.env.ONE_REDIRECT_URI!);
|
|
281
|
-
url.searchParams.set("response_type", "code");
|
|
282
|
-
url.searchParams.set("scope", "user:connections:read user:connections:write org:connections:read org:connections:write project:connections:read project:connections:write"); // all 3 tenancy tiers — org/project grants 403 without theirs
|
|
283
|
-
url.searchParams.set("state", state);
|
|
284
|
-
url.searchParams.set("code_challenge", challenge);
|
|
285
|
-
url.searchParams.set("code_challenge_method", "S256");
|
|
286
|
-
|
|
287
|
-
if (process.env.ONE_PERMISSION_SET) {
|
|
288
|
-
url.searchParams.set("permission_set", process.env.ONE_PERMISSION_SET);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// Optional: your user's email. One pre-fills (never locks) their sign-in.
|
|
292
|
-
const userEmail = await getCurrentUserEmail(req); // ← your code
|
|
293
|
-
if (userEmail) url.searchParams.set("login_hint", userEmail);
|
|
294
|
-
|
|
295
|
-
const res = NextResponse.redirect(url.toString(), 302);
|
|
296
|
-
// One cookie PER flow — the name carries the state. Users open the
|
|
297
|
-
// flow more than once (retries, second tabs); a single shared cookie
|
|
298
|
-
// would be overwritten by each start, so only the LAST-opened flow
|
|
299
|
-
// could ever complete. Expiry reaps the strays.
|
|
300
|
-
res.cookies.set(`one_tx_${state}`, verifier, {
|
|
301
|
-
httpOnly: true,
|
|
302
|
-
secure: true,
|
|
303
|
-
// The callback is a TOP-LEVEL navigation on your own site, so Lax
|
|
304
|
-
// survives the cross-site redirect chain (One -> here).
|
|
305
|
-
sameSite: "lax",
|
|
306
|
-
maxAge: 600, // matches One's 10-minute authorization-code lifetime
|
|
307
|
-
path: "/api/one",
|
|
308
|
-
});
|
|
309
|
-
return res;
|
|
310
|
-
}
|
|
142
|
+
const { open } = useOneConnect({
|
|
143
|
+
authorizeUrl: "/api/one/authorize",
|
|
144
|
+
onSuccess: () => {},
|
|
145
|
+
onError: (message) => {},
|
|
146
|
+
});
|
|
147
|
+
button.addEventListener("click", open);
|
|
311
148
|
```
|
|
312
149
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
```typescript
|
|
318
|
-
// app/api/one/callback/route.ts
|
|
319
|
-
import { NextRequest, NextResponse } from "next/server";
|
|
320
|
-
|
|
321
|
-
const ONE_TOKEN_URL = "https://api.withone.ai/oauth/token";
|
|
322
|
-
|
|
323
|
-
export async function GET(req: NextRequest) {
|
|
324
|
-
const code = req.nextUrl.searchParams.get("code");
|
|
325
|
-
const state = req.nextUrl.searchParams.get("state");
|
|
326
|
-
// The state that came back selects its own cookie — not finding one
|
|
327
|
-
// IS the CSRF failure (forged or stale state has no cookie).
|
|
328
|
-
const verifier = state ? req.cookies.get(`one_tx_${state}`)?.value : undefined;
|
|
329
|
-
|
|
330
|
-
if (!code || !state || !verifier) {
|
|
331
|
-
return NextResponse.redirect(
|
|
332
|
-
new URL(
|
|
333
|
-
"/?one_connect=error&one_connect_message=" +
|
|
334
|
-
encodeURIComponent("The sign-in attempt expired or was tampered with."),
|
|
335
|
-
req.url,
|
|
336
|
-
),
|
|
337
|
-
302,
|
|
338
|
-
);
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
const basic = Buffer.from(
|
|
342
|
-
`${process.env.ONE_CLIENT_ID}:${process.env.ONE_CLIENT_SECRET}`,
|
|
343
|
-
).toString("base64");
|
|
344
|
-
|
|
345
|
-
const tokenRes = await fetch(ONE_TOKEN_URL, {
|
|
346
|
-
method: "POST",
|
|
347
|
-
headers: {
|
|
348
|
-
Authorization: `Basic ${basic}`,
|
|
349
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
350
|
-
},
|
|
351
|
-
body: new URLSearchParams({
|
|
352
|
-
grant_type: "authorization_code",
|
|
353
|
-
code,
|
|
354
|
-
redirect_uri: process.env.ONE_REDIRECT_URI!,
|
|
355
|
-
code_verifier: verifier,
|
|
356
|
-
}),
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
const res = NextResponse.redirect(
|
|
360
|
-
new URL(
|
|
361
|
-
tokenRes.ok
|
|
362
|
-
? "/?one_connect=success"
|
|
363
|
-
: "/?one_connect=error&one_connect_message=" +
|
|
364
|
-
encodeURIComponent("Token exchange failed."),
|
|
365
|
-
req.url,
|
|
366
|
-
),
|
|
367
|
-
302,
|
|
368
|
-
);
|
|
369
|
-
res.cookies.delete(`one_tx_${state}`);
|
|
370
|
-
|
|
371
|
-
if (tokenRes.ok) {
|
|
372
|
-
// { access_token, refresh_token, token_type: "bearer", expires_in, scope }
|
|
373
|
-
const tokens = await tokenRes.json();
|
|
374
|
-
await saveOneTokens(req, { // ← your code
|
|
375
|
-
accessToken: tokens.access_token,
|
|
376
|
-
refreshToken: tokens.refresh_token,
|
|
377
|
-
expiresAt: Date.now() + tokens.expires_in * 1000,
|
|
378
|
-
});
|
|
379
|
-
}
|
|
380
|
-
return res;
|
|
381
|
-
}
|
|
382
|
-
```
|
|
150
|
+
The flow is a full-page redirect in the same tab, so it works in every browser with no popup or iframe. When your callback route redirects home it appends `?one_connect=success` (or `?one_connect=error&one_connect_message=…`); the SDK reads that on load, fires your callback once, and removes the params from the address bar.
|
|
151
|
+
|
|
152
|
+
## 3 · The two routes, as one import
|
|
383
153
|
|
|
384
|
-
|
|
154
|
+
Create the client once, on the server:
|
|
385
155
|
|
|
386
|
-
```
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
156
|
+
```ts
|
|
157
|
+
// lib/one.ts
|
|
158
|
+
import { createOneConnect } from "@withone/connect/server";
|
|
159
|
+
|
|
160
|
+
export const oneConnect = createOneConnect({
|
|
161
|
+
clientId: process.env.ONE_CLIENT_ID!,
|
|
162
|
+
clientSecret: process.env.ONE_CLIENT_SECRET!,
|
|
163
|
+
redirectUri: process.env.ONE_REDIRECT_URI!,
|
|
164
|
+
permissionSet: process.env.ONE_PERMISSION_SET,
|
|
165
|
+
oneApiUrl: process.env.ONE_API_URL,
|
|
166
|
+
tokenStore: {
|
|
167
|
+
// Your database, keyed by your own user id. Store them encrypted.
|
|
168
|
+
saveTokens: (userId, tokens) => db.oneTokens.upsert(userId, tokens),
|
|
169
|
+
loadTokens: (userId) => db.oneTokens.find(userId),
|
|
170
|
+
clearTokens: (userId) => db.oneTokens.delete(userId),
|
|
171
|
+
},
|
|
172
|
+
});
|
|
394
173
|
```
|
|
395
174
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
```
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
const basic = Buffer.from(
|
|
411
|
-
`${process.env.ONE_CLIENT_ID}:${process.env.ONE_CLIENT_SECRET}`,
|
|
412
|
-
).toString("base64");
|
|
413
|
-
|
|
414
|
-
const res = await fetch(ONE_TOKEN_URL, {
|
|
415
|
-
method: "POST",
|
|
416
|
-
headers: {
|
|
417
|
-
Authorization: `Basic ${basic}`,
|
|
418
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
419
|
-
},
|
|
420
|
-
body: new URLSearchParams({
|
|
421
|
-
grant_type: "refresh_token",
|
|
422
|
-
refresh_token: t.refreshToken,
|
|
423
|
-
}),
|
|
424
|
-
});
|
|
425
|
-
if (!res.ok) throw new Error("One refresh failed — re-run the connect flow");
|
|
426
|
-
|
|
427
|
-
const tokens = await res.json();
|
|
428
|
-
await saveOneTokens(userId, { // BOTH tokens — rotation!
|
|
429
|
-
accessToken: tokens.access_token,
|
|
430
|
-
refreshToken: tokens.refresh_token,
|
|
431
|
-
expiresAt: Date.now() + tokens.expires_in * 1000,
|
|
432
|
-
});
|
|
433
|
-
return tokens.access_token;
|
|
434
|
-
}
|
|
175
|
+
Then mount the routes for your server.
|
|
176
|
+
|
|
177
|
+
**Next.js (App Router), Remix, SvelteKit, Hono, Bun** and anything else that speaks the web `Request`:
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
// app/api/one/[action]/route.ts
|
|
181
|
+
import { createOneConnectRoutes } from "@withone/connect/next";
|
|
182
|
+
import { oneConnect } from "@/lib/one";
|
|
183
|
+
|
|
184
|
+
export const { GET } = createOneConnectRoutes(oneConnect, {
|
|
185
|
+
identifyUser: async (request) => (await getSession(request))?.userId ?? null,
|
|
186
|
+
loginHintFor: async (request) => (await getSession(request))?.email ?? null,
|
|
187
|
+
signInUrl: "/login",
|
|
188
|
+
});
|
|
435
189
|
```
|
|
436
190
|
|
|
437
|
-
|
|
191
|
+
That one file serves `/api/one/authorize` and `/api/one/callback`.
|
|
438
192
|
|
|
439
|
-
|
|
193
|
+
**Express, Fastify, Koa, plain Node:**
|
|
440
194
|
|
|
441
|
-
```
|
|
442
|
-
|
|
195
|
+
```ts
|
|
196
|
+
import { createOneConnectHandlers } from "@withone/connect/node";
|
|
197
|
+
import { oneConnect } from "./one";
|
|
443
198
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
const res = await fetch("https://api.withone.ai/v1/connections", {
|
|
447
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
199
|
+
const { authorize, callback } = createOneConnectHandlers(oneConnect, {
|
|
200
|
+
identifyUser: (request) => request.session?.userId ?? null,
|
|
448
201
|
});
|
|
202
|
+
app.get("/api/one/authorize", authorize);
|
|
203
|
+
app.get("/api/one/callback", callback);
|
|
449
204
|
```
|
|
450
205
|
|
|
451
|
-
|
|
206
|
+
What the routes do for you: mint `state` and a PKCE verifier, keep them in a per-flow httpOnly cookie, send the browser to One, verify the returned state, exchange the code with your secret over HTTP Basic, store both tokens through your `tokenStore`, and redirect home with the outcome. A declined consent, an expired attempt and a failed exchange all come back as `?one_connect=error` with a message you can show.
|
|
207
|
+
|
|
208
|
+
**Another language?** The routes are ordinary OAuth 2.1 authorization code with PKCE. The reference behaviour is in `src/server/index.ts`; the same steps work in Python, Go or Ruby.
|
|
209
|
+
|
|
210
|
+
## 4 · Using the grant
|
|
211
|
+
|
|
212
|
+
Everything runs on your server through the same client. Tokens are refreshed for you before they expire; a refresh One refuses clears the stored tokens and throws `OneConnectError` with code `refresh_failed`, which means "ask the user to connect again".
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
// What the grant reaches, each connection with its access
|
|
216
|
+
const connections = await oneConnect.listConnections(userId);
|
|
217
|
+
// [{ key, platform, name, title, image, access: { policy: "full" | "methods" | "actions", … } }]
|
|
218
|
+
|
|
219
|
+
// What actions a platform has (what exists, not what is permitted)
|
|
220
|
+
const actions = await oneConnect.listActions(userId, "gmail");
|
|
221
|
+
// [{ _id, title, method, path }]
|
|
222
|
+
|
|
223
|
+
// Run one
|
|
224
|
+
const reply = await oneConnect.runAction(userId, {
|
|
225
|
+
connectionKey: connections[0].key,
|
|
226
|
+
actionId: actions[0]._id,
|
|
227
|
+
method: actions[0].method,
|
|
228
|
+
path: actions[0].path,
|
|
229
|
+
body: { … },
|
|
230
|
+
});
|
|
231
|
+
// { status, ok, blockedByGrant, data }
|
|
232
|
+
```
|
|
452
233
|
|
|
453
|
-
|
|
234
|
+
`blockedByGrant` is true when One refused the call because it is outside what the user granted. The provider was never called. Do not retry; the user chose that. Anything else on One's `/v1` API: `oneConnect.fetch(userId, "/connections", init)` adds the bearer and the tenancy headers for you.
|
|
454
235
|
|
|
455
|
-
|
|
236
|
+
Other calls on the client: `isConnected`, `getAccessToken`, `getTokens`, `refreshTokens`, `disconnect`.
|
|
456
237
|
|
|
457
238
|
## What your users see
|
|
458
239
|
|
|
459
|
-
In their
|
|
240
|
+
In their One dashboard the app appears under **Access control**, with what they granted and when it was last used. They can lower a level, remove an account, or revoke the app at any time. Your next call reflects it: a `401` means reconnect, a `403` means outside the grant.
|
|
460
241
|
|
|
461
|
-
In *your* dashboard
|
|
242
|
+
In *your* dashboard the app lists every user who said yes, what each one granted, and lets you revoke a user.
|
|
462
243
|
|
|
463
244
|
## Security notes
|
|
464
245
|
|
|
465
|
-
- The client secret
|
|
466
|
-
- The authorization code is single
|
|
467
|
-
-
|
|
246
|
+
- The client secret is used on your server only, for the code exchange and refresh, over HTTP Basic.
|
|
247
|
+
- The authorization code is single use and expires ten minutes after consent.
|
|
248
|
+
- Refresh tokens rotate on every use. Reusing an old one revokes the whole family, which is why the client serialises refreshes per user.
|
|
249
|
+
- The browser half of this package never sees a token. It navigates and reads one query parameter.
|
|
250
|
+
|
|
251
|
+
## Development
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
npm run check # typecheck, tests, build
|
|
255
|
+
```
|
|
468
256
|
|
|
469
257
|
## License
|
|
470
258
|
|
|
471
|
-
|
|
259
|
+
GPL-3.0. See [LICENSE](LICENSE).
|