@withone/connect 0.1.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/LICENSE +674 -0
- package/README.md +361 -0
- package/dist/complete.d.ts +12 -0
- package/dist/constants.d.ts +15 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.esm.js +1 -0
- package/dist/index.umd.js +1 -0
- package/dist/types/index.d.ts +52 -0
- package/dist/useOneConnect.d.ts +2 -0
- package/dist/window/index.d.ts +13 -0
- package/package.json +63 -0
- package/src/complete.ts +36 -0
- package/src/constants.ts +23 -0
- package/src/index.ts +7 -0
- package/src/types/index.d.ts +52 -0
- package/src/useOneConnect.ts +142 -0
- package/src/window/index.ts +136 -0
package/README.md
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
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
|
+
|
|
3
|
+
<h3 align="center">One Connect</h3>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://withone.ai"><strong>Website</strong></a>
|
|
7
|
+
·
|
|
8
|
+
<a href="https://withone.ai/docs/connect"><strong>Docs</strong></a>
|
|
9
|
+
·
|
|
10
|
+
<a href="https://app.withone.ai"><strong>Dashboard</strong></a>
|
|
11
|
+
·
|
|
12
|
+
<a href="https://withone.ai/changelog"><strong>Changelog</strong></a>
|
|
13
|
+
·
|
|
14
|
+
<a href="https://x.com/withoneai"><strong>X</strong></a>
|
|
15
|
+
·
|
|
16
|
+
<a href="https://linkedin.com/company/withoneai"><strong>LinkedIn</strong></a>
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
<p align="center">
|
|
20
|
+
<a href="https://npmjs.com/package/@withone/connect"><img src="https://img.shields.io/npm/v/%40withone%2Fconnect" alt="npm version"></a>
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
One Connect lets your users grant your application **scoped, revocable access to their own One-connected tools** — Gmail, Slack, Notion, Stripe and 500+ more — through the standard OAuth 2.1 authorization-code flow with PKCE.
|
|
24
|
+
|
|
25
|
+
Your user owns their connections inside One. You never see their Gmail password — you hold a One access token scoped to **exactly what they granted**, and they can narrow or revoke it at any time. One enforces the grant on every call.
|
|
26
|
+
|
|
27
|
+
Fully compatible with popular frameworks such as React, Next.js, Vue, Svelte, and more.
|
|
28
|
+
|
|
29
|
+
> **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 own One account** and hands you a scoped grant.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
With npm:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm i @withone/connect
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
With yarn:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
yarn add @withone/connect
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## How it works
|
|
46
|
+
|
|
47
|
+
Everything sensitive — `state`, the PKCE verifier, your client secret, the tokens — lives on **your server**. The SDK is a thin modal opener: it 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: Open modal → 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 closes the modal, onSuccess() fires
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The SDK watches the modal frame for `?one_connect=` on your own origin — so **there is no completion page to build**.
|
|
72
|
+
|
|
73
|
+
## 1 · Create your OAuth app
|
|
74
|
+
|
|
75
|
+
Dashboard → **Settings → OAuth Apps → New OAuth app**.
|
|
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
|
|
83
|
+
|
|
84
|
+
```env
|
|
85
|
+
ONE_CLIENT_ID=...
|
|
86
|
+
ONE_CLIENT_SECRET=one_secret_...
|
|
87
|
+
ONE_REDIRECT_URI=https://yourapp.com/api/one/callback
|
|
88
|
+
ONE_PERMISSION_SET=79659c66-...
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
| Variable | Required | Description |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| `ONE_CLIENT_ID` | Yes | Public client ID from your OAuth app |
|
|
94
|
+
| `ONE_CLIENT_SECRET` | Yes | Server-only secret. Never ship to a browser. |
|
|
95
|
+
| `ONE_REDIRECT_URI` | Yes | Must exactly match the URI registered in the dashboard |
|
|
96
|
+
| `ONE_PERMISSION_SET` | No | Pre-fills the consent screen with the access your app needs |
|
|
97
|
+
|
|
98
|
+
## 2 · Using the Connect component
|
|
99
|
+
|
|
100
|
+
Replace the `authorize URL` with your backend authorize endpoint.
|
|
101
|
+
|
|
102
|
+
> ⚠️ **Must be a full URL** — relative paths like `/api/one/authorize` won't work because the Connect card runs in an iframe. Use the complete URL (e.g., `https://your-domain.com/api/one/authorize`).
|
|
103
|
+
|
|
104
|
+
```tsx
|
|
105
|
+
"use client";
|
|
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 modal 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()` | Opens the Connect modal over the current page |
|
|
146
|
+
| `close()` | Tears down the modal frame and its listeners |
|
|
147
|
+
|
|
148
|
+
## 3 · Backend — the authorize route
|
|
149
|
+
|
|
150
|
+
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.
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
// app/api/one/authorize/route.ts (Next.js App Router)
|
|
154
|
+
import { createHash, randomBytes } from "crypto";
|
|
155
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
156
|
+
|
|
157
|
+
const ONE_AUTHORIZE_URL = "https://api.withone.ai/oauth/authorize";
|
|
158
|
+
|
|
159
|
+
export async function GET(req: NextRequest) {
|
|
160
|
+
const state = randomBytes(16).toString("hex");
|
|
161
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
162
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
163
|
+
|
|
164
|
+
const url = new URL(ONE_AUTHORIZE_URL);
|
|
165
|
+
url.searchParams.set("client_id", process.env.ONE_CLIENT_ID!);
|
|
166
|
+
url.searchParams.set("redirect_uri", process.env.ONE_REDIRECT_URI!);
|
|
167
|
+
url.searchParams.set("response_type", "code");
|
|
168
|
+
url.searchParams.set("scope", "user:connections:read user:connections:write");
|
|
169
|
+
url.searchParams.set("state", state);
|
|
170
|
+
url.searchParams.set("code_challenge", challenge);
|
|
171
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
172
|
+
|
|
173
|
+
if (process.env.ONE_PERMISSION_SET) {
|
|
174
|
+
url.searchParams.set("permission_set", process.env.ONE_PERMISSION_SET);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Optional: your user's email. One pre-fills (never locks) their sign-in.
|
|
178
|
+
const userEmail = await getCurrentUserEmail(req); // ← your code
|
|
179
|
+
if (userEmail) url.searchParams.set("login_hint", userEmail);
|
|
180
|
+
|
|
181
|
+
const res = NextResponse.redirect(url.toString(), 302);
|
|
182
|
+
res.cookies.set("one_tx", JSON.stringify({ state, verifier }), {
|
|
183
|
+
httpOnly: true,
|
|
184
|
+
secure: true,
|
|
185
|
+
sameSite: "lax",
|
|
186
|
+
maxAge: 600, // matches One's 10-minute authorization-code lifetime
|
|
187
|
+
path: "/api/one",
|
|
188
|
+
});
|
|
189
|
+
return res;
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## 4 · Backend — the callback route
|
|
194
|
+
|
|
195
|
+
One redirects back with a **single-use code**, worthless without your secret and the PKCE verifier. Exchange it server-side, store the tokens, then redirect anywhere on your site with `?one_connect=success` appended — the SDK watches the frame for that parameter and closes the modal.
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
// app/api/one/callback/route.ts
|
|
199
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
200
|
+
|
|
201
|
+
const ONE_TOKEN_URL = "https://api.withone.ai/oauth/token";
|
|
202
|
+
|
|
203
|
+
export async function GET(req: NextRequest) {
|
|
204
|
+
const code = req.nextUrl.searchParams.get("code");
|
|
205
|
+
const state = req.nextUrl.searchParams.get("state");
|
|
206
|
+
const tx = req.cookies.get("one_tx")?.value;
|
|
207
|
+
|
|
208
|
+
const parsed = tx ? JSON.parse(tx) : null;
|
|
209
|
+
if (!code || !state || !parsed || parsed.state !== state) {
|
|
210
|
+
return NextResponse.redirect(
|
|
211
|
+
new URL(
|
|
212
|
+
"/?one_connect=error&one_connect_message=" +
|
|
213
|
+
encodeURIComponent("The sign-in attempt expired or was tampered with."),
|
|
214
|
+
req.url,
|
|
215
|
+
),
|
|
216
|
+
302,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const basic = Buffer.from(
|
|
221
|
+
`${process.env.ONE_CLIENT_ID}:${process.env.ONE_CLIENT_SECRET}`,
|
|
222
|
+
).toString("base64");
|
|
223
|
+
|
|
224
|
+
const tokenRes = await fetch(ONE_TOKEN_URL, {
|
|
225
|
+
method: "POST",
|
|
226
|
+
headers: {
|
|
227
|
+
Authorization: `Basic ${basic}`,
|
|
228
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
229
|
+
},
|
|
230
|
+
body: new URLSearchParams({
|
|
231
|
+
grant_type: "authorization_code",
|
|
232
|
+
code,
|
|
233
|
+
redirect_uri: process.env.ONE_REDIRECT_URI!,
|
|
234
|
+
code_verifier: parsed.verifier,
|
|
235
|
+
}),
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const res = NextResponse.redirect(
|
|
239
|
+
new URL(
|
|
240
|
+
tokenRes.ok
|
|
241
|
+
? "/?one_connect=success"
|
|
242
|
+
: "/?one_connect=error&one_connect_message=" +
|
|
243
|
+
encodeURIComponent("Token exchange failed."),
|
|
244
|
+
req.url,
|
|
245
|
+
),
|
|
246
|
+
302,
|
|
247
|
+
);
|
|
248
|
+
res.cookies.delete("one_tx");
|
|
249
|
+
|
|
250
|
+
if (tokenRes.ok) {
|
|
251
|
+
// { access_token, refresh_token, token_type: "bearer", expires_in, scope }
|
|
252
|
+
const tokens = await tokenRes.json();
|
|
253
|
+
await saveOneTokens(req, { // ← your code
|
|
254
|
+
accessToken: tokens.access_token,
|
|
255
|
+
refreshToken: tokens.refresh_token,
|
|
256
|
+
expiresAt: Date.now() + tokens.expires_in * 1000,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return res;
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
**Token response (200):**
|
|
264
|
+
|
|
265
|
+
```json
|
|
266
|
+
{
|
|
267
|
+
"access_token": "one_at_...",
|
|
268
|
+
"refresh_token": "one_rt_...",
|
|
269
|
+
"token_type": "bearer",
|
|
270
|
+
"expires_in": 2592000,
|
|
271
|
+
"scope": "user:connections:read user:connections:write"
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
## 5 · Backend — refreshing the token
|
|
276
|
+
|
|
277
|
+
Access tokens last as long as you chose when creating the app (7 days to 1 year). Refresh tokens last **30 days** and are **rotated on every use** — always store BOTH new tokens. Reusing an old refresh token revokes the entire token family (theft protection).
|
|
278
|
+
|
|
279
|
+
```typescript
|
|
280
|
+
const ONE_TOKEN_URL = "https://api.withone.ai/oauth/token";
|
|
281
|
+
|
|
282
|
+
export async function getOneAccessToken(userId: string): Promise<string> {
|
|
283
|
+
const t = await loadOneTokens(userId); // ← your code
|
|
284
|
+
if (Date.now() < t.expiresAt - 60_000) return t.accessToken;
|
|
285
|
+
|
|
286
|
+
// The refresh exchange is authenticated exactly like the code exchange —
|
|
287
|
+
// same Basic header. The public-client form (client_id in the body, no
|
|
288
|
+
// secret) is rejected with 401.
|
|
289
|
+
const basic = Buffer.from(
|
|
290
|
+
`${process.env.ONE_CLIENT_ID}:${process.env.ONE_CLIENT_SECRET}`,
|
|
291
|
+
).toString("base64");
|
|
292
|
+
|
|
293
|
+
const res = await fetch(ONE_TOKEN_URL, {
|
|
294
|
+
method: "POST",
|
|
295
|
+
headers: {
|
|
296
|
+
Authorization: `Basic ${basic}`,
|
|
297
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
298
|
+
},
|
|
299
|
+
body: new URLSearchParams({
|
|
300
|
+
grant_type: "refresh_token",
|
|
301
|
+
refresh_token: t.refreshToken,
|
|
302
|
+
}),
|
|
303
|
+
});
|
|
304
|
+
if (!res.ok) throw new Error("One refresh failed — re-run the connect flow");
|
|
305
|
+
|
|
306
|
+
const tokens = await res.json();
|
|
307
|
+
await saveOneTokens(userId, { // BOTH tokens — rotation!
|
|
308
|
+
accessToken: tokens.access_token,
|
|
309
|
+
refreshToken: tokens.refresh_token,
|
|
310
|
+
expiresAt: Date.now() + tokens.expires_in * 1000,
|
|
311
|
+
});
|
|
312
|
+
return tokens.access_token;
|
|
313
|
+
}
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
## 6 · Backend — using the grant
|
|
317
|
+
|
|
318
|
+
The bearer token works on One's standard `/v1` API — the same routes every other credential uses.
|
|
319
|
+
|
|
320
|
+
```typescript
|
|
321
|
+
const token = await getOneAccessToken(userId);
|
|
322
|
+
|
|
323
|
+
// Discover what the user granted. Ungranted connections are invisible,
|
|
324
|
+
// not merely forbidden.
|
|
325
|
+
const res = await fetch("https://api.withone.ai/v1/connections", {
|
|
326
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
327
|
+
});
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Execute actions through `/v1/passthrough/*` with the same bearer. Every call is checked inside One against what the user granted — a call outside the grant returns `403`, and your code cannot override it. That is the point.
|
|
331
|
+
|
|
332
|
+
## Custom completion pages
|
|
333
|
+
|
|
334
|
+
The standard integration needs no completion page: your callback's final redirect carries `?one_connect=success` on any same-origin URL, and the SDK reads it off the frame directly.
|
|
335
|
+
|
|
336
|
+
If you render your own completion page instead, signal the SDK explicitly:
|
|
337
|
+
|
|
338
|
+
```tsx
|
|
339
|
+
import { completeOneConnect } from "@withone/connect";
|
|
340
|
+
|
|
341
|
+
// Returns false when there was nothing to do (not inside a frame) —
|
|
342
|
+
// render fallback UI in that case.
|
|
343
|
+
completeOneConnect({ status: "success" });
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
## What your users see
|
|
347
|
+
|
|
348
|
+
In their own One dashboard, your app appears under **Authorized apps** with what they granted and when it was last used. They can revoke it at any time — handle `401`/`403` by prompting them to reconnect.
|
|
349
|
+
|
|
350
|
+
In *your* dashboard, your OAuth app lists every user who granted access, and you can revoke individual users too.
|
|
351
|
+
|
|
352
|
+
## Security notes
|
|
353
|
+
|
|
354
|
+
- The client secret lives on your server only. Authenticate the token exchange with the `Authorization: Basic` header, as shown above.
|
|
355
|
+
- The authorization code is single-use and expires in 10 minutes.
|
|
356
|
+
- The SDK never handles tokens. It opens One's card in a modal iframe and watches for the `?one_connect=` result — there is nothing sensitive in the browser to leak.
|
|
357
|
+
- The SDK only trusts messages originating from its own iframe, and only accepts results from your own origin.
|
|
358
|
+
|
|
359
|
+
## License
|
|
360
|
+
|
|
361
|
+
This project is licensed under the GPL-3.0 license. See the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { OneConnectResult } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* OPTIONAL. The standard integration needs no completion page at all:
|
|
4
|
+
* the callback route's final redirect carries ?one_connect=success (or
|
|
5
|
+
* error) on any same-origin URL, and the SDK reads it off the frame's
|
|
6
|
+
* location directly. Use this helper only if you render a custom
|
|
7
|
+
* completion page and want to signal the SDK from it explicitly.
|
|
8
|
+
*
|
|
9
|
+
* Returns false when it had nothing to do (not inside a frame) — e.g.
|
|
10
|
+
* the user opened the page directly. Render fallback UI in that case.
|
|
11
|
+
*/
|
|
12
|
+
export declare function completeOneConnect(result?: OneConnectResult): boolean;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** postMessage envelope type for the result, completion page → host. */
|
|
2
|
+
export declare const MESSAGE_TYPE = "@withone/connect:result";
|
|
3
|
+
/** Posted by One's connect page (cross-origin) when the user closes the
|
|
4
|
+
* card without a result. */
|
|
5
|
+
export declare const EXIT_MESSAGE_TYPE = "@withone/connect:exit";
|
|
6
|
+
/** Query params the consumer's callback route puts on its final
|
|
7
|
+
* redirect (any same-origin URL). The SDK reads them straight off the
|
|
8
|
+
* iframe's location once the frame is back on the consumer's origin —
|
|
9
|
+
* no completion page, no postMessage required. */
|
|
10
|
+
export declare const RETURN_STATUS_PARAM = "one_connect";
|
|
11
|
+
export declare const RETURN_MESSAGE_PARAM = "one_connect_message";
|
|
12
|
+
/** Fragment key on the authorize URL carrying the app-chosen theme.
|
|
13
|
+
* A fragment never reaches any server and survives the whole redirect
|
|
14
|
+
* chain, so the consumer's backend forwards nothing. */
|
|
15
|
+
export declare const THEME_PARAM = "one_theme";
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const e="@withone/connect:result",t="one-connect-frame";function n(){const e=document.getElementById(t);e&&e.remove()}const o="one-connect-success";function i(){var e;null===(e=document.getElementById(o))||void 0===e||e.remove()}function r(){return document.getElementById(t)}const s=s=>{let a=null,c=!1;const d=()=>{"undefined"!=typeof window&&a&&(window.removeEventListener("message",a),a=null),n()},l=(e,t)=>{if(!c){c=!0,d(),"success"===e&&function(e){var t;i();const n="dark"===e,r=document.createElement("div");r.id=o,Object.assign(r.style,{position:"fixed",inset:"0",zIndex:"2147483000",display:"flex",alignItems:"center",justifyContent:"center",background:"rgba(8, 8, 8, 0.5)",opacity:"0",transition:"opacity 160ms ease"});const s=document.createElement("div");Object.assign(s.style,{width:"320px",maxWidth:"calc(100vw - 32px)",padding:"40px 32px",borderRadius:"28px",background:n?"rgba(25, 25, 25, 0.97)":"rgba(255, 255, 255, 0.97)",border:"1px solid "+(n?"rgba(255,255,255,0.08)":"rgba(228,228,223,0.9)"),display:"flex",flexDirection:"column",alignItems:"center",gap:"16px",textAlign:"center",fontFamily:"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"});const a=n?"#a1a1aa":"#6b7280";s.style.position="relative",s.innerHTML=`<button data-one-close aria-label="Close" style="position:absolute;top:16px;right:16px;width:20px;height:20px;padding:0;border:0;background:none;cursor:pointer;color:${a};line-height:0;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg></button><div style="width:56px;height:56px;border-radius:50%;background:#10b981;display:flex;align-items:center;justify-content:center;"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 13l4 4L19 7"/></svg></div><div style="font-size:17px;font-weight:600;letter-spacing:-0.01em;color:${n?"#fafafa":"#111114"};">Access granted</div><div style="font-size:13px;line-height:1.5;max-width:240px;color:${a};">Your tools are connected. You can pick up right where you left off.</div><button data-one-close style="width:100%;margin-top:8px;padding:10px 0;border:0;border-radius:12px;cursor:pointer;font-size:14px;font-weight:500;background:${n?"#fafafa":"#111114"};color:${n?"#111114":"#fafafa"};">Close</button>`,r.appendChild(s),document.body.appendChild(r),requestAnimationFrame(()=>{r.style.opacity="1"});const c=()=>{window.removeEventListener("keydown",d),r.style.opacity="0",window.setTimeout(()=>r.remove(),180)};function d(e){"Escape"===e.key&&c()}for(const e of s.querySelectorAll("[data-one-close]"))e.addEventListener("click",c);r.addEventListener("click",e=>{e.target===r&&c()}),window.addEventListener("keydown",d),null===(t=s.querySelector("[data-one-close]"))||void 0===t||t.focus()}(s.appTheme);try{var n,r;if("success"===e)null===(n=s.onSuccess)||void 0===n||n.call(s);else null===(r=s.onError)||void 0===r||r.call(s,null!=t?t:"The connection was not completed.")}catch{}}},u=t=>{const n=t.data;if(!n)return;const o=r();if(o&&t.source===o.contentWindow)if("@withone/connect:exit"!==n.type)n.type!==e||t.origin!==window.location.origin||"success"!==n.status&&"error"!==n.status||l(n.status,n.message);else{d();try{var i;null===(i=s.onClose)||void 0===i||i.call(s)}catch{}}},p=()=>{var e;const t=r();if(!t)return;let n,o;try{var i,s;n=null!==(i=null===(s=t.contentWindow)||void 0===s?void 0:s.location.href)&&void 0!==i?i:""}catch{return}try{o=new URL(n).searchParams}catch{return}const a=o.get("one_connect");"success"!==a&&"error"!==a||(t.style.visibility="hidden",l(a,null!==(e=o.get("one_connect_message"))&&void 0!==e?e:void 0))};return{open:()=>{if("undefined"==typeof window)return;c=!1,a=u,window.addEventListener("message",a);(function(e){n();const o=document.createElement("iframe");return o.id=t,o.src=e,o.setAttribute("allowtransparency","true"),Object.assign(o.style,{position:"fixed",inset:"0",width:"100%",height:"100%",border:"0",zIndex:"2147483000",background:"transparent",colorScheme:"normal"}),document.body.appendChild(o),o})((()=>{try{const e=new URL(s.authorize.url);return s.appTheme&&(e.hash=`one_theme=${s.appTheme}`),e.toString()}catch{return s.authorize.url}})()).addEventListener("load",p)},close:()=>{i(),d()}}};function a(t={status:"success"}){if("undefined"==typeof window)return!1;if(window.parent&&window.parent!==window){const n={type:e,status:t.status,message:t.message};try{return window.parent.postMessage(n,window.location.origin),!0}catch{return!1}}return!1}export{a as completeOneConnect,s as useOneConnect};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";const e="@withone/connect:result",t="one-connect-frame";function n(){const e=document.getElementById(t);e&&e.remove()}const o="one-connect-success";function i(){var e;null===(e=document.getElementById(o))||void 0===e||e.remove()}function r(){return document.getElementById(t)}exports.completeOneConnect=function(t={status:"success"}){if("undefined"==typeof window)return!1;if(window.parent&&window.parent!==window){const n={type:e,status:t.status,message:t.message};try{return window.parent.postMessage(n,window.location.origin),!0}catch{return!1}}return!1},exports.useOneConnect=s=>{let c=null,a=!1;const d=()=>{"undefined"!=typeof window&&c&&(window.removeEventListener("message",c),c=null),n()},l=(e,t)=>{if(!a){a=!0,d(),"success"===e&&function(e){var t;i();const n="dark"===e,r=document.createElement("div");r.id=o,Object.assign(r.style,{position:"fixed",inset:"0",zIndex:"2147483000",display:"flex",alignItems:"center",justifyContent:"center",background:"rgba(8, 8, 8, 0.5)",opacity:"0",transition:"opacity 160ms ease"});const s=document.createElement("div");Object.assign(s.style,{width:"320px",maxWidth:"calc(100vw - 32px)",padding:"40px 32px",borderRadius:"28px",background:n?"rgba(25, 25, 25, 0.97)":"rgba(255, 255, 255, 0.97)",border:"1px solid "+(n?"rgba(255,255,255,0.08)":"rgba(228,228,223,0.9)"),display:"flex",flexDirection:"column",alignItems:"center",gap:"16px",textAlign:"center",fontFamily:"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"});const c=n?"#a1a1aa":"#6b7280";s.style.position="relative",s.innerHTML=`<button data-one-close aria-label="Close" style="position:absolute;top:16px;right:16px;width:20px;height:20px;padding:0;border:0;background:none;cursor:pointer;color:${c};line-height:0;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg></button><div style="width:56px;height:56px;border-radius:50%;background:#10b981;display:flex;align-items:center;justify-content:center;"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 13l4 4L19 7"/></svg></div><div style="font-size:17px;font-weight:600;letter-spacing:-0.01em;color:${n?"#fafafa":"#111114"};">Access granted</div><div style="font-size:13px;line-height:1.5;max-width:240px;color:${c};">Your tools are connected. You can pick up right where you left off.</div><button data-one-close style="width:100%;margin-top:8px;padding:10px 0;border:0;border-radius:12px;cursor:pointer;font-size:14px;font-weight:500;background:${n?"#fafafa":"#111114"};color:${n?"#111114":"#fafafa"};">Close</button>`,r.appendChild(s),document.body.appendChild(r),requestAnimationFrame(()=>{r.style.opacity="1"});const a=()=>{window.removeEventListener("keydown",d),r.style.opacity="0",window.setTimeout(()=>r.remove(),180)};function d(e){"Escape"===e.key&&a()}for(const e of s.querySelectorAll("[data-one-close]"))e.addEventListener("click",a);r.addEventListener("click",e=>{e.target===r&&a()}),window.addEventListener("keydown",d),null===(t=s.querySelector("[data-one-close]"))||void 0===t||t.focus()}(s.appTheme);try{var n,r;if("success"===e)null===(n=s.onSuccess)||void 0===n||n.call(s);else null===(r=s.onError)||void 0===r||r.call(s,null!=t?t:"The connection was not completed.")}catch{}}},u=t=>{const n=t.data;if(!n)return;const o=r();if(o&&t.source===o.contentWindow)if("@withone/connect:exit"!==n.type)n.type!==e||t.origin!==window.location.origin||"success"!==n.status&&"error"!==n.status||l(n.status,n.message);else{d();try{var i;null===(i=s.onClose)||void 0===i||i.call(s)}catch{}}},p=()=>{var e;const t=r();if(!t)return;let n,o;try{var i,s;n=null!==(i=null===(s=t.contentWindow)||void 0===s?void 0:s.location.href)&&void 0!==i?i:""}catch{return}try{o=new URL(n).searchParams}catch{return}const c=o.get("one_connect");"success"!==c&&"error"!==c||(t.style.visibility="hidden",l(c,null!==(e=o.get("one_connect_message"))&&void 0!==e?e:void 0))};return{open:()=>{if("undefined"==typeof window)return;a=!1,c=u,window.addEventListener("message",c);(function(e){n();const o=document.createElement("iframe");return o.id=t,o.src=e,o.setAttribute("allowtransparency","true"),Object.assign(o.style,{position:"fixed",inset:"0",width:"100%",height:"100%",border:"0",zIndex:"2147483000",background:"transparent",colorScheme:"normal"}),document.body.appendChild(o),o})((()=>{try{const e=new URL(s.authorize.url);return s.appTheme&&(e.hash=`one_theme=${s.appTheme}`),e.toString()}catch{return s.authorize.url}})()).addEventListener("load",p)},close:()=>{i(),d()}}};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public types for @withone/connect.
|
|
3
|
+
*
|
|
4
|
+
* The SDK deliberately knows nothing about OAuth internals: state, PKCE
|
|
5
|
+
* and the client secret live on the consumer's backend (see README).
|
|
6
|
+
* The SDK only opens One's connect experience as a modal over the host
|
|
7
|
+
* page and reports how the flow ended.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Result posted back from the consumer's completion page. */
|
|
11
|
+
export interface OneConnectResult {
|
|
12
|
+
status: "success" | "error";
|
|
13
|
+
/** Human-readable detail for the error case. */
|
|
14
|
+
message?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface OneConnectProps {
|
|
18
|
+
/**
|
|
19
|
+
* The consumer's OWN backend route that starts the flow. It must
|
|
20
|
+
* generate `state` + PKCE, set them in an httpOnly cookie, and 302
|
|
21
|
+
* to One's /oauth/authorize (full recipe in the README). Must be an
|
|
22
|
+
* absolute URL.
|
|
23
|
+
*/
|
|
24
|
+
authorize: {
|
|
25
|
+
url: string;
|
|
26
|
+
};
|
|
27
|
+
/** Theme for One's card. Carried on the URL fragment (#one_theme=…),
|
|
28
|
+
* which survives the redirect chain — the consumer's backend forwards
|
|
29
|
+
* nothing. */
|
|
30
|
+
appTheme?: "dark" | "light";
|
|
31
|
+
/** Fired when the completion page reports success. The token exchange
|
|
32
|
+
* already happened on the consumer's backend by this point. */
|
|
33
|
+
onSuccess?: () => void;
|
|
34
|
+
/** Fired when the completion page reports an error. */
|
|
35
|
+
onError?: (error: string) => void;
|
|
36
|
+
/** Fired when the user closes the card without a result. */
|
|
37
|
+
onClose?: () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface OneConnectHandle {
|
|
41
|
+
/** Opens One's connect modal over the current page. */
|
|
42
|
+
open: () => void;
|
|
43
|
+
/** Tears everything down: modal frame + listeners. */
|
|
44
|
+
close: () => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Message posted from the completion page up to the host page. */
|
|
48
|
+
export interface OneConnectMessage {
|
|
49
|
+
type: string; // MESSAGE_TYPE constant
|
|
50
|
+
status: "success" | "error";
|
|
51
|
+
message?: string;
|
|
52
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const IFRAME_ID = "one-connect-frame";
|
|
2
|
+
export declare function createEmbedIframe(url: string): HTMLIFrameElement;
|
|
3
|
+
export declare function removeEmbedIframe(): void;
|
|
4
|
+
export declare const SUCCESS_ID = "one-connect-success";
|
|
5
|
+
/** The "Access granted" confirmation shown after the grant completes —
|
|
6
|
+
* the same beat as authkit's "Connection established" screen, and
|
|
7
|
+
* dismissed the same way: the user closes it with the ✕ or the Close
|
|
8
|
+
* button, never a timer. By this point the card's iframe has already
|
|
9
|
+
* navigated home and been removed, so the SDK paints this itself.
|
|
10
|
+
* Pure inline styles — the SDK ships no CSS and loads no assets. */
|
|
11
|
+
export declare function showSuccessOverlay(theme?: "dark" | "light"): void;
|
|
12
|
+
export declare function removeSuccessOverlay(): void;
|
|
13
|
+
export declare function getEmbedIframe(): HTMLIFrameElement | null;
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@withone/connect",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Frontend bindings for One Connect, a drop-in OAuth flow that lets your users grant your application scoped, revocable access to their own connected tools. The user owns the connections; you hold a token scoped to exactly what they granted.",
|
|
5
|
+
"files": [
|
|
6
|
+
"dist",
|
|
7
|
+
"src",
|
|
8
|
+
"README.md",
|
|
9
|
+
"LICENSE"
|
|
10
|
+
],
|
|
11
|
+
"main": "dist/index.umd.js",
|
|
12
|
+
"module": "dist/index.esm.js",
|
|
13
|
+
"types": "dist/index.d.ts",
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@babel/core": "^7.24.0",
|
|
16
|
+
"@babel/plugin-transform-class-properties": "^7.28.6",
|
|
17
|
+
"@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6",
|
|
18
|
+
"@babel/plugin-transform-optional-chaining": "^7.28.6",
|
|
19
|
+
"@babel/preset-env": "^7.24.0",
|
|
20
|
+
"@babel/preset-typescript": "^7.28.5",
|
|
21
|
+
"@rollup/plugin-babel": "^6.0.4",
|
|
22
|
+
"@rollup/plugin-commonjs": "^25.0.7",
|
|
23
|
+
"@rollup/plugin-json": "^6.1.0",
|
|
24
|
+
"@rollup/plugin-node-resolve": "^15.2.3",
|
|
25
|
+
"@rollup/plugin-terser": "^0.4.4",
|
|
26
|
+
"@tsconfig/recommended": "^1.0.3",
|
|
27
|
+
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
|
28
|
+
"@typescript-eslint/parser": "^7.0.0",
|
|
29
|
+
"eslint": "^8.57.0",
|
|
30
|
+
"prettier": "^3.2.5",
|
|
31
|
+
"rollup": "^4.12.0",
|
|
32
|
+
"rollup-plugin-copy": "^3.5.0",
|
|
33
|
+
"rollup-plugin-typescript2": "^0.36.0",
|
|
34
|
+
"tslib": "^2.6.2",
|
|
35
|
+
"typescript": "^5.3.3"
|
|
36
|
+
},
|
|
37
|
+
"author": "@withoneai",
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"prepublishOnly": "npm run build",
|
|
43
|
+
"build": "rollup -c && npm run verify",
|
|
44
|
+
"verify": "node -e \"const fs=require('fs'),p=require('./package.json');const miss=[p.main,p.module,p.types].filter(f=>!fs.existsSync(f));if(miss.length){console.error('Build output missing: '+miss.join(', '));process.exit(1)}console.log('Build verified: '+[p.main,p.module,p.types].join(', '))\"",
|
|
45
|
+
"lint": "eslint src --ext .ts,.tsx",
|
|
46
|
+
"format": "prettier --write \"src/**/*.{ts,tsx}\""
|
|
47
|
+
},
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "git+https://github.com/withoneai/connect"
|
|
51
|
+
},
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/withoneai/connect/issues"
|
|
54
|
+
},
|
|
55
|
+
"keywords": [
|
|
56
|
+
"withone",
|
|
57
|
+
"oauth",
|
|
58
|
+
"integrations",
|
|
59
|
+
"agents"
|
|
60
|
+
],
|
|
61
|
+
"license": "GPL-3.0",
|
|
62
|
+
"homepage": "https://withone.ai"
|
|
63
|
+
}
|
package/src/complete.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { MESSAGE_TYPE } from "./constants";
|
|
2
|
+
import type { OneConnectMessage, OneConnectResult } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* OPTIONAL. The standard integration needs no completion page at all:
|
|
6
|
+
* the callback route's final redirect carries ?one_connect=success (or
|
|
7
|
+
* error) on any same-origin URL, and the SDK reads it off the frame's
|
|
8
|
+
* location directly. Use this helper only if you render a custom
|
|
9
|
+
* completion page and want to signal the SDK from it explicitly.
|
|
10
|
+
*
|
|
11
|
+
* Returns false when it had nothing to do (not inside a frame) — e.g.
|
|
12
|
+
* the user opened the page directly. Render fallback UI in that case.
|
|
13
|
+
*/
|
|
14
|
+
export function completeOneConnect(
|
|
15
|
+
result: OneConnectResult = { status: "success" }
|
|
16
|
+
): boolean {
|
|
17
|
+
if (typeof window === "undefined") return false;
|
|
18
|
+
|
|
19
|
+
if (window.parent && window.parent !== window) {
|
|
20
|
+
const message: OneConnectMessage = {
|
|
21
|
+
type: MESSAGE_TYPE,
|
|
22
|
+
status: result.status,
|
|
23
|
+
message: result.message,
|
|
24
|
+
};
|
|
25
|
+
try {
|
|
26
|
+
// Target the consumer origin only — never "*". The host page
|
|
27
|
+
// additionally checks event.origin before trusting the message.
|
|
28
|
+
window.parent.postMessage(message, window.location.origin);
|
|
29
|
+
return true;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return false;
|
|
36
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Wire-protocol constants shared by useOneConnect (host page) and
|
|
2
|
+
// completeOneConnect (the consumer's completion page). Changing any of
|
|
3
|
+
// these is a breaking change between SDK versions running on the two
|
|
4
|
+
// pages — bump with care.
|
|
5
|
+
|
|
6
|
+
/** postMessage envelope type for the result, completion page → host. */
|
|
7
|
+
export const MESSAGE_TYPE = "@withone/connect:result";
|
|
8
|
+
|
|
9
|
+
/** Posted by One's connect page (cross-origin) when the user closes the
|
|
10
|
+
* card without a result. */
|
|
11
|
+
export const EXIT_MESSAGE_TYPE = "@withone/connect:exit";
|
|
12
|
+
|
|
13
|
+
/** Query params the consumer's callback route puts on its final
|
|
14
|
+
* redirect (any same-origin URL). The SDK reads them straight off the
|
|
15
|
+
* iframe's location once the frame is back on the consumer's origin —
|
|
16
|
+
* no completion page, no postMessage required. */
|
|
17
|
+
export const RETURN_STATUS_PARAM = "one_connect";
|
|
18
|
+
export const RETURN_MESSAGE_PARAM = "one_connect_message";
|
|
19
|
+
|
|
20
|
+
/** Fragment key on the authorize URL carrying the app-chosen theme.
|
|
21
|
+
* A fragment never reaches any server and survives the whole redirect
|
|
22
|
+
* chain, so the consumer's backend forwards nothing. */
|
|
23
|
+
export const THEME_PARAM = "one_theme";
|