@withone/connect 0.12.1 → 0.13.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 +97 -251
- package/dist/next.d.ts +2 -2
- package/dist/node.d.ts +2 -2
- package/dist/server/credential.d.ts +45 -0
- package/dist/server/index.cjs.js +441 -135
- package/dist/server/index.d.ts +42 -22
- package/dist/server/index.esm.js +440 -136
- package/dist/server/key.d.ts +32 -0
- package/dist/server/token.d.ts +33 -0
- package/dist/server/types.d.ts +76 -8
- package/package.json +1 -1
- package/skills/one-connect/SKILL.md +177 -148
- package/src/next.ts +2 -2
- package/src/node.ts +2 -2
- package/src/server/credential.ts +44 -0
- package/src/server/index.ts +197 -221
- package/src/server/key.ts +142 -0
- package/src/server/token.ts +235 -0
- package/src/server/types.ts +82 -7
package/README.md
CHANGED
|
@@ -20,355 +20,201 @@
|
|
|
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 app **scoped, revocable access to their own
|
|
23
|
+
One Connect lets your users grant your app **scoped, revocable access to their own tools**: Gmail, Slack, Notion, Stripe and 500 more. The connections stay in your user's One account. Your app holds only what they granted, and One checks that on every call.
|
|
24
24
|
|
|
25
|
-
You
|
|
25
|
+
You add three things: a button, two backend routes, and the calls you make with the grant.
|
|
26
26
|
|
|
27
27
|
```
|
|
28
|
-
Browser
|
|
29
|
-
<ConnectButton>
|
|
30
|
-
sign in · pick tools · set access
|
|
28
|
+
Browser Your server One
|
|
29
|
+
<ConnectButton> ──────► GET /api/one/authorize ──302──► hosted page: sign in, pick tools, set access
|
|
31
30
|
GET /api/one/callback ◄──302── ?code&state
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
later: oneConnect.runAction(userId, …) ──► /v1/passthrough (grant enforced)
|
|
31
|
+
saves one id for the user, redirects home
|
|
32
|
+
onSuccess() ◄──────
|
|
33
|
+
later: oneConnect.runAction(userId, …) ──► One, grant enforced
|
|
36
34
|
```
|
|
37
35
|
|
|
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.
|
|
39
|
-
|
|
40
36
|
## Install
|
|
41
37
|
|
|
42
38
|
```bash
|
|
43
39
|
npm install @withone/connect
|
|
44
40
|
```
|
|
45
41
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
```bash
|
|
49
|
-
npx skills add withoneai/connect
|
|
50
|
-
```
|
|
42
|
+
Using a coding agent? `npx skills add withoneai/connect` teaches it the whole setup.
|
|
51
43
|
|
|
52
44
|
## 1 · Create your app in One
|
|
53
45
|
|
|
54
|
-
Dashboard → **Developers → Connect → New app**.
|
|
55
|
-
|
|
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.
|
|
46
|
+
Dashboard → **Developers → Connect → New app**. Register your callback URL exactly, for example `https://yourapp.com/api/one/callback`. Optionally choose the tools and access levels to ask for; the consent page lists them in the order you add them.
|
|
60
47
|
|
|
61
|
-
|
|
48
|
+
Then, on the app's page, under **Credentials → Connect key**, create a key. It is shown once, and it is made for the environment your dashboard is on: switch the dashboard to Sandbox to create a Sandbox key.
|
|
62
49
|
|
|
63
50
|
```env
|
|
64
51
|
ONE_CLIENT_ID=…
|
|
65
|
-
ONE_CLIENT_SECRET=one_secret_…
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
52
|
+
ONE_CLIENT_SECRET=one_secret_… # server only
|
|
53
|
+
ONE_CONNECT_KEY=sk_live_… # server only: the app's connect key
|
|
54
|
+
ONE_REDIRECT_URI=https://yourapp.com/api/one/callback # exactly the registered URL
|
|
55
|
+
ONE_PERMISSION_SET=… # optional: the tools you ask for
|
|
56
|
+
ONE_API_URL=https://api.withone.ai # optional: production when unset
|
|
69
57
|
```
|
|
70
58
|
|
|
71
|
-
| Variable | Required | What it is |
|
|
72
|
-
|---|---|---|
|
|
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 |
|
|
78
|
-
|
|
79
59
|
## 2 · The button
|
|
80
60
|
|
|
81
|
-
Any element wired to `open()` works. The pre-built button draws connector logos from their slugs and handles Connect → Connecting → Connected itself. It renders in its own shadow root, so your page's CSS can't break it, and it keeps its look under a strict Content Security Policy (`style-src 'self'`).
|
|
82
|
-
|
|
83
61
|
```tsx
|
|
84
|
-
// React / Next.js: safe to import from a Server Component (the bundle is "use client")
|
|
85
62
|
import { ConnectButton } from "@withone/connect/react";
|
|
86
63
|
|
|
87
64
|
<ConnectButton
|
|
88
65
|
authorizeUrl="/api/one/authorize"
|
|
89
|
-
platforms={["
|
|
90
|
-
connected={user.hasOneGrant}
|
|
91
|
-
onSuccess={() =>
|
|
92
|
-
onError={(message
|
|
66
|
+
platforms={["gmail", "google-calendar", "stripe"]}
|
|
67
|
+
connected={user.hasOneGrant} // from your server
|
|
68
|
+
onSuccess={() => refresh()}
|
|
69
|
+
onError={(message) => showError(message)}
|
|
93
70
|
/>
|
|
94
71
|
```
|
|
95
72
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
73
|
+
| Prop | What it does |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `authorizeUrl` | Your authorize route. Required. |
|
|
76
|
+
| `platforms` | Connector slugs to show as logos. |
|
|
77
|
+
| `connected` | Whether the user already has a grant, from your server. |
|
|
78
|
+
| `variant` | `default`, `accent` (your brand colour via `accentColor`), or `block` (a card with a `description`). |
|
|
79
|
+
| `size` | `sm`, `md` or `lg`. |
|
|
80
|
+
| `fullWidth` | Fills its container. |
|
|
81
|
+
| `theme` | `light`, `dark` or `auto`. `connectTheme` sets One's page. |
|
|
82
|
+
| `label` | Button text. |
|
|
83
|
+
| `disabled` | Not clickable. |
|
|
84
|
+
| `onSuccess` / `onError` / `onCancel` | How the flow ended. |
|
|
85
|
+
|
|
86
|
+
Other frameworks take the same props:
|
|
105
87
|
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
<script>
|
|
109
|
-
import { connectButton } from "@withone/connect/svelte";
|
|
110
|
-
</script>
|
|
111
|
-
<div use:connectButton={{ authorizeUrl: "/api/one/authorize", platforms: ["stripe", "notion"], connected: data.hasGrant, onSuccess }} />
|
|
88
|
+
```vue
|
|
89
|
+
<ConnectButton authorize-url="/api/one/authorize" :platforms="['gmail']" @success="onConnected" /> <!-- @withone/connect/vue -->
|
|
112
90
|
```
|
|
113
91
|
|
|
114
92
|
```html
|
|
115
|
-
|
|
116
|
-
<
|
|
117
|
-
<script type="module">
|
|
118
|
-
import "@withone/connect";
|
|
119
|
-
document.querySelector("one-connect-button").addEventListener("success", () => location.reload());
|
|
120
|
-
</script>
|
|
93
|
+
<one-connect-button authorize-url="/api/one/authorize" platforms="gmail, stripe"></one-connect-button>
|
|
94
|
+
<script type="module">import "@withone/connect";</script>
|
|
121
95
|
```
|
|
122
96
|
|
|
123
|
-
|
|
97
|
+
Svelte: `use:connectButton={{ authorizeUrl, platforms }}` from `@withone/connect/svelte`.
|
|
124
98
|
|
|
125
|
-
|
|
126
|
-
|---|---|
|
|
127
|
-
| `authorizeUrl` (`authorize-url`) | Your authorize route. Relative paths resolve against the page. Required. |
|
|
128
|
-
| `platforms` | Connector slugs: `["stripe", "google-calendar"]`. Logos come from One's CDN and names from the slug, spelled the way each brand spells itself (`hubspot` → HubSpot). Pass `{ slug, name, imageUrl }` to override either. As an attribute: `"stripe, notion"`. The first three draw as logos; the rest fold into a `+N` chip. |
|
|
129
|
-
| `connected` | Whether this user has a live grant, **from your server**. When set, it decides the Connected state, so the button stays right after a reload. When omitted, the button shows Connected only right after a successful return. |
|
|
130
|
-
| `disabled` | Not clickable, for example until terms are accepted. |
|
|
131
|
-
| `variant` | `default` neutral · `accent` your brand colour · `block` a card with a description and a "Secured by One" foot |
|
|
132
|
-
| `size` | `sm` · `md` (default) · `lg` |
|
|
133
|
-
| `fullWidth` (`full-width`) | Stretches to its container. |
|
|
134
|
-
| `theme` | `light` (default), `dark`, or `auto` to follow the visitor's setting. Matches *your* page. |
|
|
135
|
-
| `connectTheme` (`connect-theme`) | `light` or `dark` for One's hosted page. `appTheme` still works and is deprecated. |
|
|
136
|
-
| `accentColor` (`accent-color`) | Fill of the `accent` variant; One's lime when omitted. The label is black or white, whichever reads better on it. |
|
|
137
|
-
| `label` · `connectedLabel` (`connected-label`) | Button text. Defaults: "Connect your apps" · "Connected". |
|
|
138
|
-
| `description` | Sub-line on the `block` variant. |
|
|
139
|
-
| `onSuccess` | The grant was stored. Fires once per page load, on the first button still mounted. Refetch; your server is the truth. |
|
|
140
|
-
| `onError(message, code)` | The flow ended without a grant. `code` is `declined`, `expired` or `failed`, and `message` is the SDK's fixed text for it, safe to show. |
|
|
141
|
-
| `onCancel` | The user came back with the browser's Back button before finishing. The button is already clickable again. |
|
|
142
|
-
|
|
143
|
-
The custom element dispatches `success`, `error` (`detail: { message, code }`) and `cancel` events.
|
|
144
|
-
|
|
145
|
-
**Matching your design system.** The button inherits your page's font. Two custom properties and two parts do the rest:
|
|
146
|
-
|
|
147
|
-
```css
|
|
148
|
-
one-connect-button, .one-connect {
|
|
149
|
-
--one-connect-font: var(--font-sans);
|
|
150
|
-
--one-connect-radius: 8px;
|
|
151
|
-
}
|
|
152
|
-
.one-connect::part(button) { box-shadow: none; } /* the React, Vue and Svelte host */
|
|
153
|
-
one-connect-button::part(label) { font-weight: 600; }
|
|
154
|
-
```
|
|
99
|
+
Your own button in React: `const { open, status } = useOneConnect({ authorizeUrl: "/api/one/authorize" })`.
|
|
155
100
|
|
|
156
|
-
|
|
101
|
+
To match your design, set `--one-connect-font` and `--one-connect-radius`, or style `::part(button)`.
|
|
157
102
|
|
|
158
|
-
|
|
103
|
+
## 3 · Choose a mode
|
|
159
104
|
|
|
160
|
-
|
|
161
|
-
import { useOneConnect } from "@withone/connect/react";
|
|
105
|
+
Your server holds a user's grant in one of two ways. The button, the two routes and every call are the same in both. **The mode is whichever credential you configure.**
|
|
162
106
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
107
|
+
| | Key mode (default) | Token mode |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| Your server holds | one connect key for the app | nothing app-wide |
|
|
110
|
+
| You store per user | one id, saved once | an access token and a refresh token |
|
|
111
|
+
| Expires | nothing expires; One checks the consent on every call | the tokens expire, on the lifetime set on your app |
|
|
112
|
+
| Refreshing | none | the SDK does it; several servers need a shared lock |
|
|
113
|
+
| Pick it when | you call One from your own server. Start here. | you need a standard OAuth bearer token |
|
|
167
114
|
|
|
168
|
-
|
|
115
|
+
**Key mode**: pass the connect key and a place to save one value per user.
|
|
169
116
|
|
|
170
117
|
```ts
|
|
171
|
-
|
|
118
|
+
// lib/one.ts
|
|
119
|
+
import { createOneConnect } from "@withone/connect/server";
|
|
172
120
|
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
121
|
+
export const oneConnect = createOneConnect({
|
|
122
|
+
clientId: process.env.ONE_CLIENT_ID!,
|
|
123
|
+
clientSecret: process.env.ONE_CLIENT_SECRET!,
|
|
124
|
+
redirectUri: process.env.ONE_REDIRECT_URI!,
|
|
125
|
+
permissionSet: process.env.ONE_PERMISSION_SET,
|
|
126
|
+
oneApiUrl: process.env.ONE_API_URL,
|
|
127
|
+
connectKey: process.env.ONE_CONNECT_KEY!, // ← this makes it key mode
|
|
128
|
+
userStore: {
|
|
129
|
+
saveUser: (userId, reference) => db.users.update(userId, { oneConnect: reference }),
|
|
130
|
+
loadUser: async (userId) => (await db.users.find(userId))?.oneConnect ?? null,
|
|
131
|
+
clearUser: (userId) => db.users.update(userId, { oneConnect: null }),
|
|
132
|
+
},
|
|
178
133
|
});
|
|
179
|
-
button.addEventListener("click", flow.open);
|
|
180
|
-
// later: flow.destroy()
|
|
181
134
|
```
|
|
182
135
|
|
|
183
|
-
|
|
136
|
+
`reference` is one short string: the user's permanent One id, plus the space they granted from. Save it in one column and hand it back. It is an identifier, not a secret, and it stays the same if the user disconnects and connects again.
|
|
184
137
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
Create the client once, on the server:
|
|
138
|
+
**Token mode**: pass a token store instead.
|
|
188
139
|
|
|
189
140
|
```ts
|
|
190
|
-
// lib/one.ts
|
|
191
|
-
import { createOneConnect } from "@withone/connect/server";
|
|
192
|
-
|
|
193
141
|
export const oneConnect = createOneConnect({
|
|
194
142
|
clientId: process.env.ONE_CLIENT_ID!,
|
|
195
143
|
clientSecret: process.env.ONE_CLIENT_SECRET!,
|
|
196
144
|
redirectUri: process.env.ONE_REDIRECT_URI!,
|
|
197
145
|
permissionSet: process.env.ONE_PERMISSION_SET,
|
|
198
146
|
oneApiUrl: process.env.ONE_API_URL,
|
|
199
|
-
tokenStore: {
|
|
200
|
-
// Your database, keyed by your own user id. Store them encrypted.
|
|
147
|
+
tokenStore: { // ← this makes it token mode
|
|
201
148
|
saveTokens: (userId, tokens) => db.oneTokens.upsert(userId, tokens),
|
|
202
149
|
loadTokens: (userId) => db.oneTokens.find(userId),
|
|
203
150
|
clearTokens: (userId) => db.oneTokens.delete(userId),
|
|
204
|
-
// Required when you run more than one server or a worker. See "Tokens" below.
|
|
205
|
-
withLock: (userId, run) => db.withUserLock(userId, run),
|
|
206
151
|
},
|
|
207
152
|
});
|
|
208
153
|
```
|
|
209
154
|
|
|
210
|
-
|
|
155
|
+
To be explicit, add `mode: "key"` or `mode: "token"`. `oneConnect.mode` tells you which one is running. An app written before key mode existed passes only a `tokenStore`, so it keeps running in token mode with no change.
|
|
156
|
+
|
|
157
|
+
## 4 · The two routes
|
|
211
158
|
|
|
212
|
-
|
|
159
|
+
The same file in both modes:
|
|
213
160
|
|
|
214
161
|
```ts
|
|
215
|
-
// app/api/one/[action]/route.ts
|
|
162
|
+
// app/api/one/[action]/route.ts (Next.js; also Remix, SvelteKit, Hono, Bun)
|
|
216
163
|
import { createOneConnectRoutes } from "@withone/connect/next";
|
|
217
164
|
import { oneConnect } from "@/lib/one";
|
|
218
165
|
|
|
219
166
|
export const { GET } = createOneConnectRoutes(oneConnect, {
|
|
220
167
|
identifyUser: async (request) => (await getSession(request))?.userId ?? null,
|
|
221
|
-
loginHintFor: async (request) => (await getSession(request))?.email ?? null,
|
|
222
|
-
signInUrl: "/login",
|
|
223
168
|
});
|
|
224
169
|
```
|
|
225
170
|
|
|
226
|
-
That
|
|
227
|
-
|
|
228
|
-
**Express, Fastify, Koa, plain Node:**
|
|
229
|
-
|
|
230
|
-
```ts
|
|
231
|
-
import { createOneConnectHandlers } from "@withone/connect/node";
|
|
232
|
-
import { oneConnect } from "./one";
|
|
233
|
-
|
|
234
|
-
const { authorize, callback } = createOneConnectHandlers(oneConnect, {
|
|
235
|
-
identifyUser: (request) => request.session?.userId ?? null,
|
|
236
|
-
});
|
|
237
|
-
app.get("/api/one/authorize", authorize);
|
|
238
|
-
app.get("/api/one/callback", callback);
|
|
239
|
-
```
|
|
171
|
+
That file serves `/api/one/authorize` and `/api/one/callback`. For Express or plain Node, use `createOneConnectHandlers` from `@withone/connect/node`.
|
|
240
172
|
|
|
241
|
-
|
|
173
|
+
## 5 · Using the grant
|
|
242
174
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
## 4 · Using the grant
|
|
246
|
-
|
|
247
|
-
Everything runs on your server through the same client. Every call refreshes the tokens first when they are about to expire (see [Tokens](#5--tokens-storing-refreshing-keeping-alive)).
|
|
175
|
+
The same calls in both modes:
|
|
248
176
|
|
|
249
177
|
```ts
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
// [{ key, platform, name, title, image, access: { policy: "full" | "methods" | "actions", … } }]
|
|
253
|
-
|
|
254
|
-
// What actions a platform has (what exists, not what is permitted)
|
|
255
|
-
const actions = await oneConnect.listActions(userId, "gmail");
|
|
256
|
-
// [{ _id, title, method, path }]
|
|
178
|
+
const connections = await oneConnect.listConnections(userId); // what the user granted
|
|
179
|
+
const actions = await oneConnect.listActions(userId, "gmail"); // what a platform can do
|
|
257
180
|
|
|
258
|
-
// Run one
|
|
259
181
|
const reply = await oneConnect.runAction(userId, {
|
|
260
182
|
connectionKey: connections[0].key,
|
|
261
183
|
actionId: actions[0]._id,
|
|
262
184
|
method: actions[0].method,
|
|
263
185
|
path: actions[0].path,
|
|
264
|
-
body: { … },
|
|
265
186
|
});
|
|
266
187
|
// { status, ok, blockedByGrant, data }
|
|
267
188
|
```
|
|
268
189
|
|
|
269
|
-
`
|
|
270
|
-
|
|
271
|
-
Other calls on the client: `isConnected`, `getAccessToken`, `getTokens`, `refreshTokens`, `refreshIfExpiring`, `disconnect`.
|
|
272
|
-
|
|
273
|
-
## 5 · Tokens: storing, refreshing, keeping alive
|
|
274
|
-
|
|
275
|
-
Your app owns the tokens. The SDK never stores anything itself: it calls your `tokenStore`, and it refreshes through it.
|
|
276
|
-
|
|
277
|
-
**What One issues**
|
|
278
|
-
|
|
279
|
-
| | Lifetime | On refresh |
|
|
280
|
-
|---|---|---|
|
|
281
|
-
| Access token | The lifetime set on your app: 1 hour by default, or 7, 30, 90 or 365 days | Replaced |
|
|
282
|
-
| Refresh token | 30 days | Replaced, with a fresh 30 days |
|
|
283
|
-
|
|
284
|
-
Every refresh returns a **new pair** and retires the old refresh token. If the old refresh token is ever used again, One treats it as stolen and **revokes the whole grant**. The user then has to connect again.
|
|
285
|
-
|
|
286
|
-
**Store them in your database, encrypted, keyed by your user id**
|
|
287
|
-
|
|
288
|
-
```sql
|
|
289
|
-
create table one_tokens (
|
|
290
|
-
user_id text primary key,
|
|
291
|
-
access_token text not null, -- encrypted
|
|
292
|
-
refresh_token text not null, -- encrypted
|
|
293
|
-
expires_at timestamptz not null -- tokens.expiresAt
|
|
294
|
-
);
|
|
295
|
-
```
|
|
296
|
-
|
|
297
|
-
**Give the store a lock when more than one process can refresh**
|
|
298
|
-
|
|
299
|
-
Serverless functions, several instances, a background worker: any two of them can decide to refresh the same user at the same moment.
|
|
300
|
-
|
|
301
|
-
```
|
|
302
|
-
without a lock with withLock
|
|
303
|
-
web ──refresh(R1)──► One: here is R2 web ──lock──refresh(R1)──► R2 ──save──unlock
|
|
304
|
-
worker ─refresh(R1)─► One: R1 reused! worker ──wait─────────────────────────────┐
|
|
305
|
-
revoke everything ✗ reload: R2 is fresh, use it ✓
|
|
306
|
-
```
|
|
307
|
-
|
|
308
|
-
The SDK takes the lock around every refresh and around the save in the callback. It re-reads the store inside the lock, so the process that waited uses the pair the first one saved instead of spending the old refresh token again. The SDK never takes the lock twice for the same call. Postgres, with a transaction-scoped advisory lock:
|
|
309
|
-
|
|
310
|
-
```ts
|
|
311
|
-
withLock: async (userId, run) => {
|
|
312
|
-
const client = await pool.connect();
|
|
313
|
-
try {
|
|
314
|
-
await client.query("begin");
|
|
315
|
-
await client.query("select pg_advisory_xact_lock(hashtextextended($1, 0))", [`one-connect:${userId}`]);
|
|
316
|
-
return await run();
|
|
317
|
-
} finally {
|
|
318
|
-
await client.query("commit").catch(() => {});
|
|
319
|
-
client.release();
|
|
320
|
-
}
|
|
321
|
-
},
|
|
322
|
-
```
|
|
323
|
-
|
|
324
|
-
A single long-running process can leave `withLock` out; the SDK already runs one refresh per user at a time inside a process. If your lock has a timeout, make it at least 60 seconds, because it spans one call to One.
|
|
325
|
-
|
|
326
|
-
**Refreshing ahead of time**
|
|
327
|
-
|
|
328
|
-
`getAccessToken`, `runAction`, `listConnections` and `fetch` refresh on their own when the access token has less than a minute left. For work that runs in the background, refresh ahead with `refreshIfExpiring`. It refreshes only when the access token **or** the refresh token expires within the window, and otherwise returns the stored pair without calling One.
|
|
329
|
-
|
|
330
|
-
```ts
|
|
331
|
-
// Every 10 minutes: users whose access token expires in the next 15 minutes
|
|
332
|
-
// (your table knows expires_at).
|
|
333
|
-
await oneConnect.refreshIfExpiring(userId, { withinMs: 15 * 60_000 });
|
|
334
|
-
|
|
335
|
-
// Once a day, for every connected user: renews refresh tokens before their
|
|
336
|
-
// 30 days run out, so a user who has not been active stays connected.
|
|
337
|
-
await oneConnect.refreshIfExpiring(userId, { withinMs: 7 * 24 * 3_600_000 });
|
|
338
|
-
```
|
|
190
|
+
A `403` means the call is outside what the user granted. Don't retry it. In key mode `blockedByGrant` is `true` for those.
|
|
339
191
|
|
|
340
|
-
|
|
192
|
+
Errors are `OneConnectError` with a `code`:
|
|
341
193
|
|
|
342
|
-
| `
|
|
194
|
+
| `code` | Mode | What it means | What to do |
|
|
343
195
|
|---|---|---|---|
|
|
344
|
-
| `
|
|
345
|
-
| `
|
|
346
|
-
| `
|
|
347
|
-
|
|
348
|
-
The SDK clears tokens only when One says the grant is dead (`invalid_grant`), or when the refresh token's own expiry has passed. It calls `clearTokens(userId, failed)` with the pair that failed. Just before that, it re-reads the store: if a newer pair has landed (the user reconnected while an old refresh was failing), it keeps and returns the newer pair. To make that check atomic, delete only when the stored refresh token is still `failed.refreshToken`. `disconnect(userId)` calls `clearTokens(userId)` without `failed`, and always deletes.
|
|
349
|
-
|
|
350
|
-
**Asking for more tools later**
|
|
351
|
-
|
|
352
|
-
Edit the app's permission set in the dashboard (Developers → Connect). The next time a user presses the button, One shows them the new tools as "{your app} needs one more connection", with what they already granted pre-selected. Authorizing replaces the user's grant and old tokens at once, and the callback saves the new pair.
|
|
196
|
+
| `not_connected` | both | Nothing is stored for this user. | Show the Connect button. |
|
|
197
|
+
| `reconnect_required` | key | One will not act for this user: they revoked access, or the app is deactivated. | Ask the user to connect again. Your stored value is kept. |
|
|
198
|
+
| `refresh_failed` | token | The grant ended (revoked or expired). The tokens were cleared. | Ask the user to connect again. |
|
|
199
|
+
| `request_failed` | both | One answered with an error or could not be reached. Nothing stored changed. | Retry later. |
|
|
353
200
|
|
|
354
|
-
##
|
|
201
|
+
## 6 · Key mode notes
|
|
355
202
|
|
|
356
|
-
|
|
203
|
+
- Keep the connect key on the server, in an environment variable. It is bound to your app and does nothing without a user's id.
|
|
204
|
+
- A key works in one environment. Use the Production key in production and the Sandbox key in sandbox; the wrong one refuses every user.
|
|
205
|
+
- `isConnected(userId)` says whether the user has connected before. One confirms the consent on each call, so a user who revoked is found by the next call throwing `reconnect_required`.
|
|
206
|
+
- `oneConnect.getConnectUserId(userId)` returns the user's One id (`cu_…`) if you want it for your own records.
|
|
207
|
+
- Lost or leaked a key? Create another on the app's page and delete the old one under **Developers → API keys**.
|
|
357
208
|
|
|
358
|
-
|
|
209
|
+
## 7 · Token mode notes
|
|
359
210
|
|
|
360
|
-
|
|
211
|
+
- Store the tokens in your database, encrypted, keyed by your user id. The SDK refreshes them for you.
|
|
212
|
+
- The access token lives as long as your app's **Token lifetime** says: 30 days unless you changed it under **Advanced** when creating or editing the app. The refresh token lives 30 days, and only a live refresh token can renew the pair.
|
|
213
|
+
- Running more than one server or a background worker? Add `withLock(userId, run)` to your token store, for example a Postgres advisory lock. Two servers refreshing at once would otherwise disconnect the user.
|
|
214
|
+
- Required: once a day, for each connected user, call `oneConnect.refreshIfExpiring(userId, { withinMs: 3 * 24 * 3_600_000 })`. It renews both tokens when either is within 3 days of expiring. Without it, users have to connect again when the tokens run out. Keep the window shorter than your Token lifetime, or every run refreshes.
|
|
215
|
+
- `getAccessToken`, `getTokens`, `refreshTokens` and `refreshIfExpiring` exist in token mode only.
|
|
361
216
|
|
|
362
|
-
|
|
363
|
-
- The authorization code is single use and expires ten minutes after consent.
|
|
364
|
-
- Refresh tokens rotate on every use. Reusing an old one revokes the whole family, which is why the client refreshes one user at a time: inside a process on its own, and across processes through `tokenStore.withLock`.
|
|
365
|
-
- The browser half of this package never sees a token. It navigates and reads one query parameter.
|
|
366
|
-
|
|
367
|
-
## Development
|
|
368
|
-
|
|
369
|
-
```bash
|
|
370
|
-
npm run check # typecheck, tests, build
|
|
371
|
-
```
|
|
217
|
+
To ask for more tools later, edit your app's tools in the dashboard. Users see only the new ones the next time they connect.
|
|
372
218
|
|
|
373
219
|
## License
|
|
374
220
|
|
package/dist/next.d.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* That serves /api/one/authorize and /api/one/callback. Register
|
|
15
15
|
* `https://yourapp.com/api/one/callback` as the app's redirect URI.
|
|
16
16
|
*/
|
|
17
|
-
import type {
|
|
17
|
+
import type { OneConnectClient } from "./server";
|
|
18
18
|
export interface OneConnectRoutesOptions {
|
|
19
19
|
/** The app's own id for the signed-in user, or null when nobody is
|
|
20
20
|
* signed in. The grant is stored under this id. */
|
|
@@ -33,4 +33,4 @@ export declare const CALLBACK_ROUTE = "callback";
|
|
|
33
33
|
export declare function readCookie(request: Request, name: string): string | undefined;
|
|
34
34
|
/** The last path segment names the leg: ".../authorize" or ".../callback". */
|
|
35
35
|
export declare function routeFor(url: string): string;
|
|
36
|
-
export declare function createOneConnectRoutes(oneConnect:
|
|
36
|
+
export declare function createOneConnectRoutes(oneConnect: Pick<OneConnectClient, "startAuthorization" | "completeAuthorization">, options: OneConnectRoutesOptions): OneConnectRoutes;
|
package/dist/node.d.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* app.get("/api/one/callback", callback);
|
|
16
16
|
*/
|
|
17
17
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
18
|
-
import type {
|
|
18
|
+
import type { OneConnectClient } from "./server";
|
|
19
19
|
export interface OneConnectNodeOptions {
|
|
20
20
|
/** The app's own id for the signed-in user, or null when nobody is
|
|
21
21
|
* signed in. */
|
|
@@ -30,4 +30,4 @@ export interface OneConnectHandlers {
|
|
|
30
30
|
authorize: NodeHandler;
|
|
31
31
|
callback: NodeHandler;
|
|
32
32
|
}
|
|
33
|
-
export declare function createOneConnectHandlers(oneConnect:
|
|
33
|
+
export declare function createOneConnectHandlers(oneConnect: Pick<OneConnectClient, "startAuthorization" | "completeAuthorization">, options: OneConnectNodeOptions): OneConnectHandlers;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The seam between the two ways an app can hold a user's grant.
|
|
3
|
+
*
|
|
4
|
+
* Everything around it is shared: the authorize leg, the callback, and
|
|
5
|
+
* the calls made with the grant. A credential answers only what differs:
|
|
6
|
+
* what to keep when a user connects, what to send on a call, and how to
|
|
7
|
+
* read One refusing it.
|
|
8
|
+
*
|
|
9
|
+
* - key mode (`./key`): the app's connect key plus a permanent id for
|
|
10
|
+
* the user. Nothing expires, so nothing is refreshed.
|
|
11
|
+
* - token mode (`./token`): an access token and a refresh token per
|
|
12
|
+
* user, rotated before they expire.
|
|
13
|
+
*/
|
|
14
|
+
import type { OneConnectError } from "./types";
|
|
15
|
+
/** What One's token endpoint answers on success. `connect_user_id` is
|
|
16
|
+
* present whenever a durable grant backs the token. */
|
|
17
|
+
export interface TokenResponse {
|
|
18
|
+
access_token: string;
|
|
19
|
+
refresh_token: string;
|
|
20
|
+
expires_in: number;
|
|
21
|
+
connect_user_id?: string;
|
|
22
|
+
}
|
|
23
|
+
/** What One's token endpoint answered. A network failure throws instead. */
|
|
24
|
+
export type TokenAnswer = {
|
|
25
|
+
ok: true;
|
|
26
|
+
body: TokenResponse;
|
|
27
|
+
} | {
|
|
28
|
+
ok: false;
|
|
29
|
+
status: number;
|
|
30
|
+
error?: string;
|
|
31
|
+
};
|
|
32
|
+
export type PostToken = (body: URLSearchParams) => Promise<TokenAnswer>;
|
|
33
|
+
export interface Credential {
|
|
34
|
+
/** The callback exchanged the code: keep what this mode needs. */
|
|
35
|
+
connected: (userId: string, response: TokenResponse) => Promise<void>;
|
|
36
|
+
/** The headers that make a /v1 call act for this user. Throws
|
|
37
|
+
* `not_connected` when the user has nothing stored. */
|
|
38
|
+
headers: (userId: string) => Promise<Record<string, string>>;
|
|
39
|
+
isConnected: (userId: string) => Promise<boolean>;
|
|
40
|
+
/** Drops the app's copy. The user revokes the grant itself in One. */
|
|
41
|
+
disconnect: (userId: string) => Promise<void>;
|
|
42
|
+
/** One refused the credential itself, rather than the call it carried:
|
|
43
|
+
* the error to throw, or null when this answer is not that. */
|
|
44
|
+
refusal: (status: number, body: string) => OneConnectError | null;
|
|
45
|
+
}
|