@withone/connect 0.12.2 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -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 +118 -15
- 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.1",
|
|
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,13 +1,14 @@
|
|
|
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
|
|
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, storing and refreshing the grant, and calling One with it.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# One Connect
|
|
7
7
|
|
|
8
8
|
You are adding One Connect to this application. Its users will grant the app
|
|
9
9
|
scoped, revocable access to their own tools. The package does the OAuth work;
|
|
10
|
-
you wire
|
|
10
|
+
you wire four things: a button, two routes, a daily refresh job, and the calls
|
|
11
|
+
made with the grant.
|
|
11
12
|
|
|
12
13
|
```
|
|
13
14
|
Browser Your backend One
|
|
@@ -16,8 +17,12 @@ Browser Your backend One
|
|
|
16
17
|
stores the tokens, redirects home
|
|
17
18
|
<------ onSuccess fires
|
|
18
19
|
Later: oneConnect.runAction(userId, ...) -> One, grant enforced
|
|
20
|
+
Daily: oneConnect.refreshIfExpiring(userId, ...) for every connected user
|
|
19
21
|
```
|
|
20
22
|
|
|
23
|
+
Use **token mode**, described in sections 1 to 8. Key mode (section 9) is a
|
|
24
|
+
second way to hold the grant; use it only when the human asks for it.
|
|
25
|
+
|
|
21
26
|
Secrets and tokens stay on the server.
|
|
22
27
|
|
|
23
28
|
## 1 - Ask the human for these
|
|
@@ -30,7 +35,9 @@ They create the app in the One dashboard: Developers -> Connect -> New app.
|
|
|
30
35
|
| `ONE_CLIENT_SECRET` | Starts with `one_secret_`. Shown once. |
|
|
31
36
|
| Redirect URI | Registered on the app. Must match the callback route exactly, e.g. `http://localhost:3000/api/one/callback`. |
|
|
32
37
|
| `ONE_PERMISSION_SET` | Optional. The tools and access levels to ask for. |
|
|
33
|
-
|
|
38
|
+
|
|
39
|
+
Never ask the human to paste the secret into the chat. Tell them which
|
|
40
|
+
environment variable to set and read it from there.
|
|
34
41
|
|
|
35
42
|
## 2 - Environment (server only)
|
|
36
43
|
|
|
@@ -39,7 +46,6 @@ ONE_CLIENT_ID=...
|
|
|
39
46
|
ONE_CLIENT_SECRET=one_secret_...
|
|
40
47
|
ONE_REDIRECT_URI=https://yourapp.com/api/one/callback
|
|
41
48
|
ONE_PERMISSION_SET=... # optional
|
|
42
|
-
ONE_API_URL=... # optional
|
|
43
49
|
```
|
|
44
50
|
|
|
45
51
|
## 3 - Install and create the client
|
|
@@ -57,7 +63,6 @@ export const oneConnect = createOneConnect({
|
|
|
57
63
|
clientSecret: process.env.ONE_CLIENT_SECRET!,
|
|
58
64
|
redirectUri: process.env.ONE_REDIRECT_URI!,
|
|
59
65
|
permissionSet: process.env.ONE_PERMISSION_SET,
|
|
60
|
-
oneApiUrl: process.env.ONE_API_URL,
|
|
61
66
|
tokenStore: {
|
|
62
67
|
saveTokens: (userId, tokens) => /* save in the app's database, encrypted */,
|
|
63
68
|
loadTokens: (userId) => /* read; null when never connected */,
|
|
@@ -115,7 +120,33 @@ Vue: `@withone/connect/vue`, same props. Svelte: `use:connectButton` from
|
|
|
115
120
|
`<one-connect-button authorize-url="/api/one/authorize" platforms="gmail, stripe">`.
|
|
116
121
|
A custom button in React: `useOneConnect({ authorizeUrl })` returns `{ open, status }`.
|
|
117
122
|
|
|
118
|
-
## 6 -
|
|
123
|
+
## 6 - Keeping users connected
|
|
124
|
+
|
|
125
|
+
Tokens expire. Two things keep them fresh, and only the first is automatic.
|
|
126
|
+
|
|
127
|
+
| | Who does it | When |
|
|
128
|
+
|---|---|---|
|
|
129
|
+
| Refresh before a call | the package, on its own | whenever the app calls One and the token is about to expire |
|
|
130
|
+
| Refresh for users who have not called in a while | **the app**, with a daily job | once a day, for every connected user |
|
|
131
|
+
|
|
132
|
+
You must add the daily job. Without it, a user who stays away for 30 days
|
|
133
|
+
has to connect again: the refresh token lives 30 days, and only a live
|
|
134
|
+
refresh token can renew the pair.
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
// a scheduled job, once a day
|
|
138
|
+
for (const userId of /* every user with stored tokens */) {
|
|
139
|
+
await oneConnect.refreshIfExpiring(userId, { withinMs: 3 * 24 * 3_600_000 });
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
It renews both tokens when either is within 3 days of expiring, and does
|
|
144
|
+
nothing otherwise. The access token lives as long as the app's Token lifetime
|
|
145
|
+
says (30 days unless changed under Advanced when creating the app); keep the
|
|
146
|
+
window shorter than that, or every run refreshes. Use the scheduler the app
|
|
147
|
+
already has (a cron route, a queue, a worker).
|
|
148
|
+
|
|
149
|
+
## 7 - Calling One with the grant
|
|
119
150
|
|
|
120
151
|
```ts
|
|
121
152
|
const connections = await oneConnect.listConnections(userId); // [{ key, platform, access }]
|
|
@@ -131,23 +162,95 @@ const reply = await oneConnect.runAction(userId, {
|
|
|
131
162
|
// { status, ok, data }
|
|
132
163
|
```
|
|
133
164
|
|
|
134
|
-
|
|
135
|
-
|
|
165
|
+
Do not set any header. The package adds the user's credential to every call
|
|
166
|
+
it makes, and refreshes it first when it is about to expire.
|
|
167
|
+
|
|
168
|
+
A `403` reply means the call is outside what the user granted. Do not retry it.
|
|
169
|
+
|
|
170
|
+
Errors are `OneConnectError` with a `code`. Handle them where the app calls One:
|
|
171
|
+
|
|
172
|
+
| `code` | Meaning | Do |
|
|
173
|
+
|---|---|---|
|
|
174
|
+
| `not_connected` | Nothing is stored for this user. | Show the Connect button. |
|
|
175
|
+
| `refresh_failed` | The grant ended. The tokens were cleared. | Ask the user to connect again. |
|
|
176
|
+
| `request_failed` | One answered with an error or could not be reached. Nothing stored changed. | Retry later. |
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
import { OneConnectError } from "@withone/connect/server";
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
await oneConnect.runAction(userId, action);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (
|
|
185
|
+
error instanceof OneConnectError &&
|
|
186
|
+
["not_connected", "refresh_failed"].includes(error.code)
|
|
187
|
+
) {
|
|
188
|
+
// show the Connect button again
|
|
189
|
+
} else {
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
```
|
|
136
194
|
|
|
137
|
-
##
|
|
195
|
+
## 8 - Rules
|
|
138
196
|
|
|
139
|
-
- Never put the client secret in browser code, logs
|
|
197
|
+
- Never put the client secret in browser code, logs, error reports, source
|
|
198
|
+
files or prompts. Environment variables only.
|
|
140
199
|
- Store tokens encrypted, keyed by the app's user.
|
|
200
|
+
- Add the daily refresh job. It is part of the integration, not an extra.
|
|
141
201
|
- The registered redirect URI and `ONE_REDIRECT_URI` must be identical.
|
|
142
202
|
- Do not build a completion page; the callback redirect is the completion.
|
|
143
|
-
- Do not write OAuth steps by hand; use the package.
|
|
203
|
+
- Do not write OAuth steps or One request headers by hand; use the package.
|
|
204
|
+
- Do not switch an existing app from one mode to the other unless asked.
|
|
205
|
+
|
|
206
|
+
## 9 - Key mode (only when asked)
|
|
207
|
+
|
|
208
|
+
A second way to hold the grant: one connect key for the app and one permanent
|
|
209
|
+
id per user, with nothing to refresh. The routes, the button and the calls are
|
|
210
|
+
the same. Two limits today:
|
|
211
|
+
|
|
212
|
+
- `listActions` does not work in key mode yet. The app has to know the
|
|
213
|
+
actions it runs.
|
|
214
|
+
- It works in Production only. The human creates the key with the dashboard
|
|
215
|
+
on Production.
|
|
216
|
+
|
|
217
|
+
The human creates the key on the app's page: Credentials -> Connect key ->
|
|
218
|
+
Create key. It is shown once. They set it as `ONE_CONNECT_KEY`; never ask
|
|
219
|
+
them to paste it into the chat.
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
export const oneConnect = createOneConnect({
|
|
223
|
+
clientId: process.env.ONE_CLIENT_ID!,
|
|
224
|
+
clientSecret: process.env.ONE_CLIENT_SECRET!,
|
|
225
|
+
redirectUri: process.env.ONE_REDIRECT_URI!,
|
|
226
|
+
permissionSet: process.env.ONE_PERMISSION_SET,
|
|
227
|
+
connectKey: process.env.ONE_CONNECT_KEY!,
|
|
228
|
+
userStore: {
|
|
229
|
+
saveUser: (userId, reference) => /* save the string on the app's user row */,
|
|
230
|
+
loadUser: (userId) => /* read it; null when never connected */,
|
|
231
|
+
clearUser: (userId) => /* set it to null */,
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
- `reference` is one short string. Store it as given and hand it back
|
|
237
|
+
unchanged; do not parse or rebuild it. It is an identifier, not a secret.
|
|
238
|
+
- There is no daily refresh job in key mode.
|
|
239
|
+
- When the user revokes access, the next call throws `reconnect_required`
|
|
240
|
+
(not `refresh_failed`). Ask them to connect again. The stored value is kept.
|
|
241
|
+
- A `403` reply carries `blockedByGrant: true` when the call is outside what
|
|
242
|
+
the user granted.
|
|
243
|
+
- The mode is whichever credential `createOneConnect` is given: `tokenStore`
|
|
244
|
+
for token mode, `connectKey` and `userStore` for key mode.
|
|
144
245
|
|
|
145
|
-
##
|
|
246
|
+
## 10 - Done when
|
|
146
247
|
|
|
147
248
|
1. The button leads to One's page; after signing in and authorizing, the user
|
|
148
249
|
lands back in the app and `onSuccess` fires.
|
|
149
|
-
2.
|
|
150
|
-
3. `
|
|
250
|
+
2. The tokens are saved for the user.
|
|
251
|
+
3. `listConnections` returns only the granted connections.
|
|
252
|
+
4. `runAction` works for an action inside the grant and returns `403` for
|
|
151
253
|
one outside it.
|
|
152
|
-
|
|
254
|
+
5. The daily refresh job exists and runs for every connected user.
|
|
255
|
+
6. After the user revokes the app in their One dashboard, the app asks them
|
|
153
256
|
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
|
+
}
|