@withone/connect 0.12.2 → 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 +71 -10
- 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 +129 -23
- 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
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key mode: the app holds one connect key, and one permanent id per
|
|
3
|
+
* user. Both go on every call; One reads the user's consent live each
|
|
4
|
+
* time. Nothing expires, so there is nothing to refresh, rotate or lock.
|
|
5
|
+
*
|
|
6
|
+
* X-One-Secret: the app's connect key
|
|
7
|
+
* X-One-Connect-User-Id: cu_… for the user being acted for
|
|
8
|
+
*
|
|
9
|
+
* The id comes from the one code exchange the callback makes. It is the
|
|
10
|
+
* same for a user for the life of the app, through revocation and
|
|
11
|
+
* re-consent, so it is saved once and never deleted by the SDK.
|
|
12
|
+
*/
|
|
13
|
+
import type { Credential } from "./credential";
|
|
14
|
+
import { type OneConnectUserReference, type OneConnectUserStore } from "./types";
|
|
15
|
+
/**
|
|
16
|
+
* The one value an app stores per user, as a string for one column:
|
|
17
|
+
* `cu_…`, then the space the user granted from when it is not their
|
|
18
|
+
* personal one (`cu_…;org=<id>;project=<id>`).
|
|
19
|
+
*
|
|
20
|
+
* The space travels with the id because One resolves a call's tenant
|
|
21
|
+
* from headers: a grant made from an organization lives there, and a
|
|
22
|
+
* call that names none runs in the user's personal space and reaches
|
|
23
|
+
* nothing. In token mode the access token names the space on every
|
|
24
|
+
* call; here there is no token after the exchange, so it is kept.
|
|
25
|
+
*/
|
|
26
|
+
export declare function encodeUserReference(reference: OneConnectUserReference): string;
|
|
27
|
+
/** Reads a stored value back. Null when it is not one this SDK wrote. */
|
|
28
|
+
export declare function parseUserReference(value: string): OneConnectUserReference | null;
|
|
29
|
+
export interface KeyCredential extends Credential {
|
|
30
|
+
getConnectUserId: (userId: string) => Promise<string | null>;
|
|
31
|
+
}
|
|
32
|
+
export declare function createKeyCredential(connectKey: string, userStore: OneConnectUserStore): KeyCredential;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token mode: the app holds an access token and a refresh token per
|
|
3
|
+
* user, and the SDK refreshes them with One.
|
|
4
|
+
*
|
|
5
|
+
* Lifetimes. The access token lives as long as the app's Token lifetime
|
|
6
|
+
* setting says (dashboard, Advanced, when creating or editing the app: 7
|
|
7
|
+
* days, 30 days, 90 days or 1 year; 30 days unless changed). An app that
|
|
8
|
+
* never had the setting gets an hour. The refresh token always lives 30
|
|
9
|
+
* days, whatever the access token's lifetime, and only a live refresh
|
|
10
|
+
* token can buy a new pair.
|
|
11
|
+
*
|
|
12
|
+
* Who refreshes. Before each call the client refreshes a pair that is
|
|
13
|
+
* within a minute of expiring. Refreshing earlier than that is the app's
|
|
14
|
+
* job: it runs `refreshIfExpiring` on a schedule, so the refresh token
|
|
15
|
+
* never runs out. When it has run out, the access token is used until it
|
|
16
|
+
* expires too, and only then is the user asked to connect again.
|
|
17
|
+
*
|
|
18
|
+
* Rotation. Every refresh rotates both tokens, and One treats a second
|
|
19
|
+
* use of a rotated refresh token as theft and revokes the whole grant.
|
|
20
|
+
* So the client refreshes one user at a time (in this process always,
|
|
21
|
+
* across processes through `tokenStore.withLock`), re-reads the store
|
|
22
|
+
* before spending a refresh token, clears tokens only when the grant is
|
|
23
|
+
* dead, and never lets a failing old pair delete a newer one.
|
|
24
|
+
*/
|
|
25
|
+
import type { Credential, PostToken } from "./credential";
|
|
26
|
+
import { type OneConnectTokenStore, type OneConnectTokens, type RefreshIfExpiringOptions } from "./types";
|
|
27
|
+
export interface TokenCredential extends Credential {
|
|
28
|
+
getAccessToken: (userId: string) => Promise<string>;
|
|
29
|
+
getTokens: (userId: string) => Promise<OneConnectTokens | null>;
|
|
30
|
+
refreshTokens: (userId: string) => Promise<OneConnectTokens>;
|
|
31
|
+
refreshIfExpiring: (userId: string, options?: RefreshIfExpiringOptions) => Promise<OneConnectTokens>;
|
|
32
|
+
}
|
|
33
|
+
export declare function createTokenCredential(tokenStore: OneConnectTokenStore, postToken: PostToken): TokenCredential;
|
package/dist/server/types.d.ts
CHANGED
|
@@ -2,6 +2,39 @@
|
|
|
2
2
|
* Types for `@withone/connect/server`: the half of the SDK that runs on
|
|
3
3
|
* the app's server and holds the client secret.
|
|
4
4
|
*/
|
|
5
|
+
/**
|
|
6
|
+
* How the app holds a user's grant.
|
|
7
|
+
*
|
|
8
|
+
* - `"key"`: the app's connect key plus a permanent id per user. Nothing
|
|
9
|
+
* expires, so there is nothing to refresh. The default.
|
|
10
|
+
* - `"token"`: an access token and a refresh token per user, which the
|
|
11
|
+
* SDK keeps fresh.
|
|
12
|
+
*/
|
|
13
|
+
export type OneConnectMode = "key" | "token";
|
|
14
|
+
/** Key mode: who a stored user is to One, and where their grant lives. */
|
|
15
|
+
export interface OneConnectUserReference {
|
|
16
|
+
/** One's permanent id for this user, for this app: `cu_…`. */
|
|
17
|
+
connectUserId: string;
|
|
18
|
+
/** The organization the user granted from; absent for their personal
|
|
19
|
+
* space. */
|
|
20
|
+
organizationId?: string;
|
|
21
|
+
/** The project the user granted from, when it was one. */
|
|
22
|
+
projectId?: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Key mode: where the app keeps the one value the SDK gives it per user.
|
|
26
|
+
* A single string, written when the user connects and the same until
|
|
27
|
+
* they connect again, so one column on the app's user row is enough.
|
|
28
|
+
* `userId` is the app's own id for its user.
|
|
29
|
+
*
|
|
30
|
+
* It is an identifier, not a credential: it does nothing without the
|
|
31
|
+
* app's connect key. No lock is needed, because nothing rotates.
|
|
32
|
+
*/
|
|
33
|
+
export interface OneConnectUserStore {
|
|
34
|
+
saveUser: (userId: string, reference: string) => Promise<void>;
|
|
35
|
+
loadUser: (userId: string) => Promise<string | null>;
|
|
36
|
+
clearUser: (userId: string) => Promise<void>;
|
|
37
|
+
}
|
|
5
38
|
/** What the app stores per user after the exchange. */
|
|
6
39
|
export interface OneConnectTokens {
|
|
7
40
|
accessToken: string;
|
|
@@ -47,10 +80,13 @@ export interface OneConnectTokenStore {
|
|
|
47
80
|
}
|
|
48
81
|
export interface RefreshIfExpiringOptions {
|
|
49
82
|
/** Refresh when the access token or the refresh token expires within
|
|
50
|
-
* this many milliseconds. One minute when omitted.
|
|
83
|
+
* this many milliseconds. One minute when omitted. A refresh token
|
|
84
|
+
* that has already run out cannot be refreshed: the pair is returned
|
|
85
|
+
* as it is while its access token still works. */
|
|
51
86
|
withinMs?: number;
|
|
52
87
|
}
|
|
53
|
-
|
|
88
|
+
/** What every app configures, whichever mode it uses. */
|
|
89
|
+
export interface OneConnectBaseConfig {
|
|
54
90
|
/** The app's client id from the dashboard. */
|
|
55
91
|
clientId: string;
|
|
56
92
|
/** The app's client secret. Server only. */
|
|
@@ -69,8 +105,35 @@ export interface OneConnectServerConfig {
|
|
|
69
105
|
/** OAuth scopes. All three tenancy tiers when omitted, so the user may
|
|
70
106
|
* grant from any space. */
|
|
71
107
|
scopes?: string[];
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Key mode, the default: pass the app's connect key and a place to keep
|
|
111
|
+
* one value per user.
|
|
112
|
+
*/
|
|
113
|
+
export interface OneConnectKeyConfig extends OneConnectBaseConfig {
|
|
114
|
+
mode?: "key";
|
|
115
|
+
/** The app's connect key, minted on the app's page in the dashboard.
|
|
116
|
+
* Server only. One key per environment. */
|
|
117
|
+
connectKey: string;
|
|
118
|
+
userStore: OneConnectUserStore;
|
|
119
|
+
tokenStore?: never;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Token mode: pass a token store and the SDK keeps each user's tokens
|
|
123
|
+
* fresh.
|
|
124
|
+
*/
|
|
125
|
+
export interface OneConnectTokenConfig extends OneConnectBaseConfig {
|
|
126
|
+
mode?: "token";
|
|
72
127
|
tokenStore: OneConnectTokenStore;
|
|
128
|
+
connectKey?: never;
|
|
129
|
+
userStore?: never;
|
|
73
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* The mode is whichever credential is configured: a `connectKey` is key
|
|
133
|
+
* mode, a `tokenStore` alone is token mode. Set `mode` to say so
|
|
134
|
+
* explicitly.
|
|
135
|
+
*/
|
|
136
|
+
export type OneConnectServerConfig = OneConnectKeyConfig | OneConnectTokenConfig;
|
|
74
137
|
/** The transaction cookie the authorize leg sets and the callback reads. */
|
|
75
138
|
export interface OneConnectCookie {
|
|
76
139
|
name: string;
|
|
@@ -160,18 +223,23 @@ export interface RunActionResult {
|
|
|
160
223
|
status: number;
|
|
161
224
|
ok: boolean;
|
|
162
225
|
/** True when One refused the call because it is outside the grant.
|
|
163
|
-
* The provider was never called. Do not retry.
|
|
226
|
+
* The provider was never called. Do not retry. Key mode sets it; in
|
|
227
|
+
* token mode it stays false today, so treat any 403 there as refused. */
|
|
164
228
|
blockedByGrant: boolean;
|
|
165
229
|
data: unknown;
|
|
166
230
|
}
|
|
167
231
|
/**
|
|
168
|
-
* - `not_connected`:
|
|
169
|
-
* - `
|
|
170
|
-
*
|
|
232
|
+
* - `not_connected`: nothing is stored for this user.
|
|
233
|
+
* - `reconnect_required` (key mode): One will not act for this user.
|
|
234
|
+
* Their consent was revoked, or the app is deactivated. What the app
|
|
235
|
+
* stored is kept; ask the user to connect again.
|
|
236
|
+
* - `refresh_failed` (token mode): One declared the grant dead (revoked,
|
|
237
|
+
* expired or reused). The tokens were cleared; ask the user to connect
|
|
238
|
+
* again.
|
|
171
239
|
* - `request_failed`: One answered with an error or could not be
|
|
172
|
-
* reached.
|
|
240
|
+
* reached. Nothing stored was changed, so retry later.
|
|
173
241
|
*/
|
|
174
|
-
export type OneConnectErrorCode = "not_connected" | "refresh_failed" | "request_failed";
|
|
242
|
+
export type OneConnectErrorCode = "not_connected" | "reconnect_required" | "refresh_failed" | "request_failed";
|
|
175
243
|
export declare class OneConnectError extends Error {
|
|
176
244
|
readonly code: OneConnectErrorCode;
|
|
177
245
|
readonly status?: number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@withone/connect",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "One Connect for your app: the button your users press, the two backend routes as one import, and a server client that calls One with the grant. Users keep their connections in One; your app holds only what they granted.",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: one-connect
|
|
3
|
-
description: Add One Connect to an application so its users can grant the app scoped, revocable access to their own One-connected tools (Gmail, Slack, Notion, Stripe and 500 more). Use when wiring @withone/connect into an app - the button, the two backend routes, and calling One with the grant.
|
|
3
|
+
description: Add One Connect to an application so its users can grant the app scoped, revocable access to their own One-connected tools (Gmail, Slack, Notion, Stripe and 500 more). Use when wiring @withone/connect into an app - the button, the two backend routes, choosing key mode or token mode, and calling One with the grant.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# One Connect
|
|
@@ -13,12 +13,12 @@ you wire three things: a button, two routes, and the calls made with the grant.
|
|
|
13
13
|
Browser Your backend One
|
|
14
14
|
<ConnectButton> ------> GET /api/one/authorize ---302---> hosted page: sign in, pick tools, set access
|
|
15
15
|
GET /api/one/callback <--302---- ?code&state
|
|
16
|
-
|
|
16
|
+
saves one id for the user, redirects home
|
|
17
17
|
<------ onSuccess fires
|
|
18
18
|
Later: oneConnect.runAction(userId, ...) -> One, grant enforced
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
The client secret and the connect key stay on the server.
|
|
22
22
|
|
|
23
23
|
## 1 - Ask the human for these
|
|
24
24
|
|
|
@@ -28,30 +28,91 @@ They create the app in the One dashboard: Developers -> Connect -> New app.
|
|
|
28
28
|
|---|---|
|
|
29
29
|
| `ONE_CLIENT_ID` | From the app. |
|
|
30
30
|
| `ONE_CLIENT_SECRET` | Starts with `one_secret_`. Shown once. |
|
|
31
|
+
| `ONE_CONNECT_KEY` | Key mode only. On the app's page: Credentials -> Connect key -> Create key. Shown once. It is made for the environment the dashboard is on (Production or Sandbox). |
|
|
31
32
|
| Redirect URI | Registered on the app. Must match the callback route exactly, e.g. `http://localhost:3000/api/one/callback`. |
|
|
32
33
|
| `ONE_PERMISSION_SET` | Optional. The tools and access levels to ask for. |
|
|
33
34
|
| `ONE_API_URL` | Optional. Production when unset; `https://development-api.withone.ai` for the development dashboard. |
|
|
34
35
|
|
|
36
|
+
Never ask the human to paste the secret or the connect key into the chat.
|
|
37
|
+
Tell them which environment variable to set and read it from there.
|
|
38
|
+
|
|
35
39
|
## 2 - Environment (server only)
|
|
36
40
|
|
|
37
41
|
```bash
|
|
38
42
|
ONE_CLIENT_ID=...
|
|
39
43
|
ONE_CLIENT_SECRET=one_secret_...
|
|
44
|
+
ONE_CONNECT_KEY=sk_live_... # key mode
|
|
40
45
|
ONE_REDIRECT_URI=https://yourapp.com/api/one/callback
|
|
41
46
|
ONE_PERMISSION_SET=... # optional
|
|
42
47
|
ONE_API_URL=... # optional
|
|
43
48
|
```
|
|
44
49
|
|
|
45
|
-
## 3 -
|
|
50
|
+
## 3 - Choose a mode
|
|
51
|
+
|
|
52
|
+
The server holds a user's grant in one of two ways. The button, the two
|
|
53
|
+
routes and every call are identical in both. The mode is whichever
|
|
54
|
+
credential you pass to `createOneConnect`.
|
|
55
|
+
|
|
56
|
+
| | Key mode (default) | Token mode |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| The server holds | one connect key for the app | nothing app-wide |
|
|
59
|
+
| Stored per user | one string, saved once | an access token and a refresh token |
|
|
60
|
+
| Expires | nothing; One checks the consent on every call | the tokens, on the lifetime set on the app |
|
|
61
|
+
| Refreshing | none | the SDK does it; several servers need a shared lock |
|
|
62
|
+
|
|
63
|
+
How to decide:
|
|
64
|
+
|
|
65
|
+
- New integration: **key mode**.
|
|
66
|
+
- The app already passes a `tokenStore`: it is in token mode. Leave it as it
|
|
67
|
+
is unless the human asks to move.
|
|
68
|
+
- The human asks for OAuth bearer tokens: token mode.
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
// key mode
|
|
72
|
+
createOneConnect({ ...app, connectKey: process.env.ONE_CONNECT_KEY!, userStore });
|
|
73
|
+
|
|
74
|
+
// token mode
|
|
75
|
+
createOneConnect({ ...app, tokenStore });
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`mode: "key"` or `mode: "token"` may be added to be explicit.
|
|
79
|
+
`oneConnect.mode` reports which one is running.
|
|
80
|
+
|
|
81
|
+
## 4 - Install and create the client
|
|
46
82
|
|
|
47
83
|
```bash
|
|
48
84
|
npm install @withone/connect
|
|
49
85
|
```
|
|
50
86
|
|
|
87
|
+
Key mode:
|
|
88
|
+
|
|
51
89
|
```ts
|
|
52
90
|
// lib/one.ts (server only)
|
|
53
91
|
import { createOneConnect } from "@withone/connect/server";
|
|
54
92
|
|
|
93
|
+
export const oneConnect = createOneConnect({
|
|
94
|
+
clientId: process.env.ONE_CLIENT_ID!,
|
|
95
|
+
clientSecret: process.env.ONE_CLIENT_SECRET!,
|
|
96
|
+
redirectUri: process.env.ONE_REDIRECT_URI!,
|
|
97
|
+
permissionSet: process.env.ONE_PERMISSION_SET,
|
|
98
|
+
oneApiUrl: process.env.ONE_API_URL,
|
|
99
|
+
connectKey: process.env.ONE_CONNECT_KEY!,
|
|
100
|
+
userStore: {
|
|
101
|
+
saveUser: (userId, reference) => /* save the string on the app's user row */,
|
|
102
|
+
loadUser: (userId) => /* read it; null when never connected */,
|
|
103
|
+
clearUser: (userId) => /* set it to null */,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`reference` is one short string: the user's permanent One id and the space
|
|
109
|
+
they granted from. One text column on the user row is enough. Store it as
|
|
110
|
+
given and hand it back unchanged; do not parse or rebuild it. It is an
|
|
111
|
+
identifier, not a secret, so it needs no encryption and no lock.
|
|
112
|
+
|
|
113
|
+
Token mode:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
55
116
|
export const oneConnect = createOneConnect({
|
|
56
117
|
clientId: process.env.ONE_CLIENT_ID!,
|
|
57
118
|
clientSecret: process.env.ONE_CLIENT_SECRET!,
|
|
@@ -66,13 +127,21 @@ export const oneConnect = createOneConnect({
|
|
|
66
127
|
});
|
|
67
128
|
```
|
|
68
129
|
|
|
69
|
-
|
|
130
|
+
In token mode, if the app runs more than one server process or a background
|
|
131
|
+
worker, also add `withLock: (userId, run) => ...`, which runs `run()` while
|
|
132
|
+
holding a per-user lock all processes share (for example a Postgres advisory
|
|
133
|
+
lock). The access token lives as long as the app's Token lifetime says (30
|
|
134
|
+
days unless changed under Advanced when creating the app); the refresh token
|
|
135
|
+
lives 30 days. Required: once a day, for each connected user, call
|
|
136
|
+
`oneConnect.refreshIfExpiring(userId, { withinMs: 3 * 24 * 3_600_000 })`. It
|
|
137
|
+
renews both tokens when either is within 3 days of expiring; without it,
|
|
138
|
+
users have to connect again when the tokens run out. Keep the window shorter
|
|
139
|
+
than the Token lifetime, or every run refreshes.
|
|
70
140
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
per-user lock all processes share (for example a Postgres advisory lock).
|
|
141
|
+
In both modes use the app's own user id as the key and the database it
|
|
142
|
+
already has.
|
|
74
143
|
|
|
75
|
-
##
|
|
144
|
+
## 5 - The two routes
|
|
76
145
|
|
|
77
146
|
Next.js App Router (also Remix, SvelteKit, Hono, Bun):
|
|
78
147
|
|
|
@@ -92,7 +161,7 @@ Express or plain Node: `createOneConnectHandlers(oneConnect, { identifyUser })`
|
|
|
92
161
|
from `@withone/connect/node`, mounted at `/api/one/authorize` and
|
|
93
162
|
`/api/one/callback`.
|
|
94
163
|
|
|
95
|
-
##
|
|
164
|
+
## 6 - The button
|
|
96
165
|
|
|
97
166
|
```tsx
|
|
98
167
|
import { ConnectButton } from "@withone/connect/react";
|
|
@@ -115,7 +184,7 @@ Vue: `@withone/connect/vue`, same props. Svelte: `use:connectButton` from
|
|
|
115
184
|
`<one-connect-button authorize-url="/api/one/authorize" platforms="gmail, stripe">`.
|
|
116
185
|
A custom button in React: `useOneConnect({ authorizeUrl })` returns `{ open, status }`.
|
|
117
186
|
|
|
118
|
-
##
|
|
187
|
+
## 7 - Calling One with the grant
|
|
119
188
|
|
|
120
189
|
```ts
|
|
121
190
|
const connections = await oneConnect.listConnections(userId); // [{ key, platform, access }]
|
|
@@ -128,26 +197,63 @@ const reply = await oneConnect.runAction(userId, {
|
|
|
128
197
|
path: action.path,
|
|
129
198
|
body: payload,
|
|
130
199
|
});
|
|
131
|
-
// { status, ok, data }
|
|
200
|
+
// { status, ok, blockedByGrant, data }
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
A `403` reply means the call is outside what the user granted. Do not retry
|
|
204
|
+
it. In key mode `blockedByGrant` is `true` for those.
|
|
205
|
+
|
|
206
|
+
Errors are `OneConnectError` with a `code`. Handle them where the app calls One:
|
|
207
|
+
|
|
208
|
+
| `code` | Mode | Meaning | Do |
|
|
209
|
+
|---|---|---|---|
|
|
210
|
+
| `not_connected` | both | Nothing is stored for this user. | Show the Connect button. |
|
|
211
|
+
| `reconnect_required` | key | One will not act for this user: they revoked access, or the app is deactivated. The stored value is kept. | Ask the user to connect again. |
|
|
212
|
+
| `refresh_failed` | token | The grant ended. The tokens were cleared. | Ask the user to connect again. |
|
|
213
|
+
| `request_failed` | both | One answered with an error or could not be reached. Nothing stored changed. | Retry later. |
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
import { OneConnectError } from "@withone/connect/server";
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
await oneConnect.runAction(userId, action);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (
|
|
222
|
+
error instanceof OneConnectError &&
|
|
223
|
+
["not_connected", "reconnect_required", "refresh_failed"].includes(error.code)
|
|
224
|
+
) {
|
|
225
|
+
// show the Connect button again
|
|
226
|
+
} else {
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
132
230
|
```
|
|
133
231
|
|
|
134
|
-
|
|
135
|
-
|
|
232
|
+
In key mode `isConnected(userId)` says the user has connected before. One
|
|
233
|
+
confirms the consent on each call, so a user who revoked is found by the
|
|
234
|
+
next call throwing `reconnect_required`.
|
|
136
235
|
|
|
137
|
-
##
|
|
236
|
+
## 8 - Rules
|
|
138
237
|
|
|
139
|
-
- Never put the client secret in browser code, logs
|
|
140
|
-
|
|
238
|
+
- Never put the client secret or the connect key in browser code, logs,
|
|
239
|
+
error reports, source files or prompts. Environment variables only.
|
|
240
|
+
- A connect key works in one environment. Use the Production key against
|
|
241
|
+
production and the Sandbox key against sandbox.
|
|
242
|
+
- Key mode: store the per-user string as given. Token mode: store tokens
|
|
243
|
+
encrypted, keyed by the app's user.
|
|
141
244
|
- The registered redirect URI and `ONE_REDIRECT_URI` must be identical.
|
|
142
245
|
- Do not build a completion page; the callback redirect is the completion.
|
|
143
|
-
- Do not write OAuth steps by hand; use the package.
|
|
246
|
+
- Do not write OAuth steps or One request headers by hand; use the package.
|
|
247
|
+
- Do not switch an existing app from one mode to the other unless asked.
|
|
144
248
|
|
|
145
|
-
##
|
|
249
|
+
## 9 - Done when
|
|
146
250
|
|
|
147
251
|
1. The button leads to One's page; after signing in and authorizing, the user
|
|
148
252
|
lands back in the app and `onSuccess` fires.
|
|
149
|
-
2.
|
|
150
|
-
3. `
|
|
253
|
+
2. Key mode: one string is saved for the user. Token mode: the tokens are saved.
|
|
254
|
+
3. `listConnections` returns only the granted connections.
|
|
255
|
+
4. `runAction` works for an action inside the grant and returns `403` for
|
|
151
256
|
one outside it.
|
|
152
|
-
|
|
153
|
-
|
|
257
|
+
5. After the user revokes the app in their One dashboard, the next call
|
|
258
|
+
fails with `reconnect_required` (key mode) or `refresh_failed` (token
|
|
259
|
+
mode) and the app asks them to connect again.
|
package/src/next.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
|
|
|
19
19
|
export interface OneConnectRoutesOptions {
|
|
20
20
|
/** The app's own id for the signed-in user, or null when nobody is
|
|
@@ -80,7 +80,7 @@ export function routeFor(url: string): string {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
export function createOneConnectRoutes(
|
|
83
|
-
oneConnect:
|
|
83
|
+
oneConnect: Pick<OneConnectClient, "startAuthorization" | "completeAuthorization">,
|
|
84
84
|
options: OneConnectRoutesOptions,
|
|
85
85
|
): OneConnectRoutes {
|
|
86
86
|
const cookiePath = (): string =>
|
package/src/node.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
18
18
|
|
|
19
19
|
import { createOneConnectRoutes, type OneConnectRoutesOptions } from "@withone/connect/next";
|
|
20
|
-
import type {
|
|
20
|
+
import type { OneConnectClient } from "./server";
|
|
21
21
|
|
|
22
22
|
export interface OneConnectNodeOptions {
|
|
23
23
|
/** The app's own id for the signed-in user, or null when nobody is
|
|
@@ -76,7 +76,7 @@ async function send(response: ServerResponse, web: Response): Promise<void> {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
export function createOneConnectHandlers(
|
|
79
|
-
oneConnect:
|
|
79
|
+
oneConnect: Pick<OneConnectClient, "startAuthorization" | "completeAuthorization">,
|
|
80
80
|
options: OneConnectNodeOptions,
|
|
81
81
|
): OneConnectHandlers {
|
|
82
82
|
// The web adapter receives a Request; the Node callbacks want the
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
|
|
16
|
+
/** What One's token endpoint answers on success. `connect_user_id` is
|
|
17
|
+
* present whenever a durable grant backs the token. */
|
|
18
|
+
export interface TokenResponse {
|
|
19
|
+
access_token: string;
|
|
20
|
+
refresh_token: string;
|
|
21
|
+
expires_in: number;
|
|
22
|
+
connect_user_id?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What One's token endpoint answered. A network failure throws instead. */
|
|
26
|
+
export type TokenAnswer =
|
|
27
|
+
| { ok: true; body: TokenResponse }
|
|
28
|
+
| { ok: false; status: number; error?: string };
|
|
29
|
+
|
|
30
|
+
export type PostToken = (body: URLSearchParams) => Promise<TokenAnswer>;
|
|
31
|
+
|
|
32
|
+
export interface Credential {
|
|
33
|
+
/** The callback exchanged the code: keep what this mode needs. */
|
|
34
|
+
connected: (userId: string, response: TokenResponse) => Promise<void>;
|
|
35
|
+
/** The headers that make a /v1 call act for this user. Throws
|
|
36
|
+
* `not_connected` when the user has nothing stored. */
|
|
37
|
+
headers: (userId: string) => Promise<Record<string, string>>;
|
|
38
|
+
isConnected: (userId: string) => Promise<boolean>;
|
|
39
|
+
/** Drops the app's copy. The user revokes the grant itself in One. */
|
|
40
|
+
disconnect: (userId: string) => Promise<void>;
|
|
41
|
+
/** One refused the credential itself, rather than the call it carried:
|
|
42
|
+
* the error to throw, or null when this answer is not that. */
|
|
43
|
+
refusal: (status: number, body: string) => OneConnectError | null;
|
|
44
|
+
}
|