@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
|
@@ -0,0 +1,142 @@
|
|
|
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, TokenResponse } from "./credential";
|
|
14
|
+
import { tenancyHeaders } from "./oauth";
|
|
15
|
+
import {
|
|
16
|
+
OneConnectError,
|
|
17
|
+
type OneConnectUserReference,
|
|
18
|
+
type OneConnectUserStore,
|
|
19
|
+
} from "./types";
|
|
20
|
+
|
|
21
|
+
/** `cu_` and a hex HMAC-SHA256, as One derives it. */
|
|
22
|
+
const CONNECT_USER_ID = /^cu_[0-9a-f]{64}$/;
|
|
23
|
+
const ORGANIZATION = "org=";
|
|
24
|
+
const PROJECT = "project=";
|
|
25
|
+
const SEPARATOR = ";";
|
|
26
|
+
|
|
27
|
+
const ORGANIZATION_HEADER = "X-One-Organization-Id";
|
|
28
|
+
const PROJECT_HEADER = "X-One-Project-Id";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The one value an app stores per user, as a string for one column:
|
|
32
|
+
* `cu_…`, then the space the user granted from when it is not their
|
|
33
|
+
* personal one (`cu_…;org=<id>;project=<id>`).
|
|
34
|
+
*
|
|
35
|
+
* The space travels with the id because One resolves a call's tenant
|
|
36
|
+
* from headers: a grant made from an organization lives there, and a
|
|
37
|
+
* call that names none runs in the user's personal space and reaches
|
|
38
|
+
* nothing. In token mode the access token names the space on every
|
|
39
|
+
* call; here there is no token after the exchange, so it is kept.
|
|
40
|
+
*/
|
|
41
|
+
export function encodeUserReference(reference: OneConnectUserReference): string {
|
|
42
|
+
const parts = [reference.connectUserId];
|
|
43
|
+
if (reference.organizationId) parts.push(`${ORGANIZATION}${reference.organizationId}`);
|
|
44
|
+
if (reference.projectId) parts.push(`${PROJECT}${reference.projectId}`);
|
|
45
|
+
return parts.join(SEPARATOR);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Reads a stored value back. Null when it is not one this SDK wrote. */
|
|
49
|
+
export function parseUserReference(value: string): OneConnectUserReference | null {
|
|
50
|
+
const [connectUserId, ...rest] = value.trim().split(SEPARATOR);
|
|
51
|
+
if (!CONNECT_USER_ID.test(connectUserId)) return null;
|
|
52
|
+
const reference: OneConnectUserReference = { connectUserId };
|
|
53
|
+
for (const part of rest) {
|
|
54
|
+
if (part.startsWith(ORGANIZATION)) reference.organizationId = part.slice(ORGANIZATION.length);
|
|
55
|
+
else if (part.startsWith(PROJECT)) reference.projectId = part.slice(PROJECT.length);
|
|
56
|
+
}
|
|
57
|
+
return reference;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One answers a credential it will not accept with the bare status line
|
|
62
|
+
* and nothing else. Anything richer came from the route or from the
|
|
63
|
+
* provider behind a passthrough, and is not a verdict on the credential.
|
|
64
|
+
*/
|
|
65
|
+
const isBareRefusal = (status: number, body: string, reason: string): boolean =>
|
|
66
|
+
body.trim() === `${status} ${reason}`;
|
|
67
|
+
|
|
68
|
+
export interface KeyCredential extends Credential {
|
|
69
|
+
getConnectUserId: (userId: string) => Promise<string | null>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function createKeyCredential(
|
|
73
|
+
connectKey: string,
|
|
74
|
+
userStore: OneConnectUserStore,
|
|
75
|
+
): KeyCredential {
|
|
76
|
+
const load = async (userId: string): Promise<OneConnectUserReference | null> => {
|
|
77
|
+
const stored = await userStore.loadUser(userId);
|
|
78
|
+
return stored ? parseUserReference(stored) : null;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
connected: async (userId, response: TokenResponse) => {
|
|
83
|
+
const connectUserId = response.connect_user_id;
|
|
84
|
+
if (!connectUserId || !CONNECT_USER_ID.test(connectUserId)) {
|
|
85
|
+
throw new OneConnectError(
|
|
86
|
+
"request_failed",
|
|
87
|
+
"One did not return a connect user id for this user, so key mode cannot act for them.",
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
// The space the user granted from, read once from the token that
|
|
91
|
+
// named it. The token itself is not kept.
|
|
92
|
+
const space = tenancyHeaders(response.access_token);
|
|
93
|
+
await userStore.saveUser(
|
|
94
|
+
userId,
|
|
95
|
+
encodeUserReference({
|
|
96
|
+
connectUserId,
|
|
97
|
+
organizationId: space[ORGANIZATION_HEADER],
|
|
98
|
+
projectId: space[PROJECT_HEADER],
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
headers: async (userId) => {
|
|
104
|
+
const reference = await load(userId);
|
|
105
|
+
if (!reference)
|
|
106
|
+
throw new OneConnectError("not_connected", "This user is not connected.");
|
|
107
|
+
const headers: Record<string, string> = {
|
|
108
|
+
"X-One-Secret": connectKey,
|
|
109
|
+
"X-One-Connect-User-Id": reference.connectUserId,
|
|
110
|
+
};
|
|
111
|
+
if (reference.organizationId) headers[ORGANIZATION_HEADER] = reference.organizationId;
|
|
112
|
+
if (reference.projectId) headers[PROJECT_HEADER] = reference.projectId;
|
|
113
|
+
return headers;
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
isConnected: async (userId) => (await load(userId)) !== null,
|
|
117
|
+
disconnect: (userId) => userStore.clearUser(userId),
|
|
118
|
+
|
|
119
|
+
refusal: (status, body) => {
|
|
120
|
+
// One does not say which: the consent was revoked, was never given
|
|
121
|
+
// in this key's environment, or the app is deactivated. The saved id
|
|
122
|
+
// is kept either way, because it is the same id after a reconnect.
|
|
123
|
+
if (status === 403 && isBareRefusal(status, body, "Forbidden")) {
|
|
124
|
+
return new OneConnectError(
|
|
125
|
+
"reconnect_required",
|
|
126
|
+
"One will not act for this user: their consent was revoked, or the app is deactivated. Ask the user to connect again. If every user fails, check that the connect key is for this environment and that the app is active.",
|
|
127
|
+
status,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (status === 401 && isBareRefusal(status, body, "Unauthorized")) {
|
|
131
|
+
return new OneConnectError(
|
|
132
|
+
"request_failed",
|
|
133
|
+
"One did not accept the connect key. Check that it is this app's connect key, minted on the app's page in the One dashboard.",
|
|
134
|
+
status,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
getConnectUserId: async (userId) => (await load(userId))?.connectUserId ?? null,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
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, TokenAnswer, TokenResponse } from "./credential";
|
|
26
|
+
import { refreshTokenExpiresAt, tenancyHeaders } from "./oauth";
|
|
27
|
+
import {
|
|
28
|
+
OneConnectError,
|
|
29
|
+
type OneConnectTokenStore,
|
|
30
|
+
type OneConnectTokens,
|
|
31
|
+
type RefreshIfExpiringOptions,
|
|
32
|
+
} from "./types";
|
|
33
|
+
|
|
34
|
+
/** Refresh this long before expiry, so a call never races the clock. */
|
|
35
|
+
const REFRESH_MARGIN_MS = 60_000;
|
|
36
|
+
|
|
37
|
+
/** The one refusal that means the grant is gone for good: revoked by the
|
|
38
|
+
* user, expired, or burned by a reused refresh token (RFC 6749 §5.2). */
|
|
39
|
+
const isDeadGrant = (answer: TokenAnswer): boolean =>
|
|
40
|
+
!answer.ok && answer.status === 400 && answer.error === "invalid_grant";
|
|
41
|
+
|
|
42
|
+
export interface TokenCredential extends Credential {
|
|
43
|
+
getAccessToken: (userId: string) => Promise<string>;
|
|
44
|
+
getTokens: (userId: string) => Promise<OneConnectTokens | null>;
|
|
45
|
+
refreshTokens: (userId: string) => Promise<OneConnectTokens>;
|
|
46
|
+
refreshIfExpiring: (
|
|
47
|
+
userId: string,
|
|
48
|
+
options?: RefreshIfExpiringOptions,
|
|
49
|
+
) => Promise<OneConnectTokens>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createTokenCredential(
|
|
53
|
+
tokenStore: OneConnectTokenStore,
|
|
54
|
+
postToken: PostToken,
|
|
55
|
+
): TokenCredential {
|
|
56
|
+
/** One refresh in flight per user: two concurrent refreshes with the
|
|
57
|
+
* same refresh token trip One's reuse detection. */
|
|
58
|
+
const refreshing = new Map<string, Promise<OneConnectTokens>>();
|
|
59
|
+
|
|
60
|
+
/** Runs `run` under the app's cross-process lock for this user, when
|
|
61
|
+
* the store has one. */
|
|
62
|
+
const locked = <T>(userId: string, run: () => Promise<T>): Promise<T> =>
|
|
63
|
+
tokenStore.withLock ? tokenStore.withLock(userId, run) : run();
|
|
64
|
+
|
|
65
|
+
/** Whether either token of the pair stops working within `withinMs`. */
|
|
66
|
+
const expiresWithin = (tokens: OneConnectTokens, withinMs: number): boolean => {
|
|
67
|
+
const horizon = Date.now() + withinMs;
|
|
68
|
+
const refreshExpiresAt = refreshTokenExpiresAt(tokens.refreshToken);
|
|
69
|
+
return (
|
|
70
|
+
tokens.expiresAt <= horizon ||
|
|
71
|
+
(refreshExpiresAt !== null && refreshExpiresAt <= horizon)
|
|
72
|
+
);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Whether the access token is too close to expiry to make a call with. */
|
|
76
|
+
const accessEnding = (tokens: OneConnectTokens): boolean =>
|
|
77
|
+
tokens.expiresAt <= Date.now() + REFRESH_MARGIN_MS;
|
|
78
|
+
|
|
79
|
+
/** Whether the refresh token has run out. One with no readable expiry
|
|
80
|
+
* counts as live: only One can say otherwise. */
|
|
81
|
+
const refreshSpent = (tokens: OneConnectTokens): boolean => {
|
|
82
|
+
const refreshExpiresAt = refreshTokenExpiresAt(tokens.refreshToken);
|
|
83
|
+
return refreshExpiresAt !== null && refreshExpiresAt <= Date.now();
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const toTokens = (response: TokenResponse): OneConnectTokens => ({
|
|
87
|
+
accessToken: response.access_token,
|
|
88
|
+
refreshToken: response.refresh_token,
|
|
89
|
+
expiresAt: Date.now() + response.expires_in * 1000,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const notConnected = () =>
|
|
93
|
+
new OneConnectError("not_connected", "This user is not connected.");
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The grant behind `failed` is dead. Clears it, unless a newer pair
|
|
97
|
+
* landed while it was failing (a reconnect's callback, another
|
|
98
|
+
* server's refresh): that pair is returned instead, because the user
|
|
99
|
+
* did nothing wrong and deleting it would disconnect them.
|
|
100
|
+
*/
|
|
101
|
+
const retire = async (
|
|
102
|
+
userId: string,
|
|
103
|
+
failed: OneConnectTokens,
|
|
104
|
+
status?: number,
|
|
105
|
+
): Promise<OneConnectTokens> => {
|
|
106
|
+
const latest = await tokenStore.loadTokens(userId);
|
|
107
|
+
if (latest && latest.refreshToken !== failed.refreshToken) return latest;
|
|
108
|
+
await tokenStore.clearTokens(userId, failed);
|
|
109
|
+
throw new OneConnectError(
|
|
110
|
+
"refresh_failed",
|
|
111
|
+
"The connection to One has expired or was revoked. Ask the user to connect again.",
|
|
112
|
+
status,
|
|
113
|
+
);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The one place a refresh token is spent. Under the app's lock it
|
|
118
|
+
* re-reads the store, and refreshes only when `stillNeeded` says the
|
|
119
|
+
* stored pair still needs it: another process may have refreshed while
|
|
120
|
+
* this one waited, and spending the same refresh token twice makes One
|
|
121
|
+
* revoke the grant.
|
|
122
|
+
*/
|
|
123
|
+
const refreshUnderLock = (
|
|
124
|
+
userId: string,
|
|
125
|
+
stillNeeded: (current: OneConnectTokens) => boolean,
|
|
126
|
+
): Promise<OneConnectTokens> =>
|
|
127
|
+
locked(userId, async () => {
|
|
128
|
+
const current = await tokenStore.loadTokens(userId);
|
|
129
|
+
if (!current) throw notConnected();
|
|
130
|
+
if (!stillNeeded(current)) return current;
|
|
131
|
+
|
|
132
|
+
// An expired refresh token cannot work, and One answers one with a
|
|
133
|
+
// server error rather than invalid_grant, so settle it here. The
|
|
134
|
+
// access token may outlive it (a 90-day or 1-year lifetime): that
|
|
135
|
+
// one still works, so the pair is kept until it ends too.
|
|
136
|
+
if (refreshSpent(current))
|
|
137
|
+
return accessEnding(current) ? retire(userId, current) : current;
|
|
138
|
+
|
|
139
|
+
let answer: TokenAnswer;
|
|
140
|
+
try {
|
|
141
|
+
answer = await postToken(
|
|
142
|
+
new URLSearchParams({
|
|
143
|
+
grant_type: "refresh_token",
|
|
144
|
+
refresh_token: current.refreshToken,
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
} catch {
|
|
148
|
+
throw new OneConnectError(
|
|
149
|
+
"request_failed",
|
|
150
|
+
"One could not be reached to refresh the connection. The tokens were kept; try again.",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (answer.ok) {
|
|
155
|
+
// Both tokens: One rotates the pair on every refresh.
|
|
156
|
+
const next = toTokens(answer.body);
|
|
157
|
+
await tokenStore.saveTokens(userId, next);
|
|
158
|
+
return next;
|
|
159
|
+
}
|
|
160
|
+
if (isDeadGrant(answer)) return retire(userId, current, answer.status);
|
|
161
|
+
// A server error, a rate limit, a misconfigured secret: nothing says
|
|
162
|
+
// the grant is gone, so keep the tokens and let the caller retry.
|
|
163
|
+
throw new OneConnectError(
|
|
164
|
+
"request_failed",
|
|
165
|
+
`One could not refresh the connection (HTTP ${answer.status}). The tokens were kept; try again.`,
|
|
166
|
+
answer.status,
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
/** One refresh per user in this process; concurrent callers share it. */
|
|
171
|
+
const singleFlight = (
|
|
172
|
+
userId: string,
|
|
173
|
+
job: () => Promise<OneConnectTokens>,
|
|
174
|
+
): Promise<OneConnectTokens> => {
|
|
175
|
+
const inFlight = refreshing.get(userId);
|
|
176
|
+
if (inFlight) return inFlight;
|
|
177
|
+
const running = job().finally(() => refreshing.delete(userId));
|
|
178
|
+
refreshing.set(userId, running);
|
|
179
|
+
return running;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const refreshTokens = (userId: string): Promise<OneConnectTokens> =>
|
|
183
|
+
singleFlight(userId, async () => {
|
|
184
|
+
const before = await tokenStore.loadTokens(userId);
|
|
185
|
+
if (!before) throw notConnected();
|
|
186
|
+
// Rotate the pair seen now; a pair someone else rotated since is
|
|
187
|
+
// already fresh.
|
|
188
|
+
return refreshUnderLock(
|
|
189
|
+
userId,
|
|
190
|
+
(current) => current.refreshToken === before.refreshToken,
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const refreshIfExpiring = async (
|
|
195
|
+
userId: string,
|
|
196
|
+
options: RefreshIfExpiringOptions = {},
|
|
197
|
+
): Promise<OneConnectTokens> => {
|
|
198
|
+
const withinMs = options.withinMs ?? REFRESH_MARGIN_MS;
|
|
199
|
+
const tokens = await tokenStore.loadTokens(userId);
|
|
200
|
+
if (!tokens) throw notConnected();
|
|
201
|
+
if (!expiresWithin(tokens, withinMs)) return tokens;
|
|
202
|
+
// Nothing left to refresh with, and the access token still works.
|
|
203
|
+
if (refreshSpent(tokens) && !accessEnding(tokens)) return tokens;
|
|
204
|
+
return singleFlight(userId, () =>
|
|
205
|
+
refreshUnderLock(userId, (current) => expiresWithin(current, withinMs)),
|
|
206
|
+
);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const getAccessToken = async (userId: string): Promise<string> =>
|
|
210
|
+
(await refreshIfExpiring(userId)).accessToken;
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
// Under the lock, so a refresh in flight on another server cannot
|
|
214
|
+
// interleave with this save.
|
|
215
|
+
connected: (userId, response) => {
|
|
216
|
+
const tokens = toTokens(response);
|
|
217
|
+
return locked(userId, () => tokenStore.saveTokens(userId, tokens));
|
|
218
|
+
},
|
|
219
|
+
headers: async (userId) => {
|
|
220
|
+
const accessToken = await getAccessToken(userId);
|
|
221
|
+
return {
|
|
222
|
+
Authorization: `Bearer ${accessToken}`,
|
|
223
|
+
...tenancyHeaders(accessToken),
|
|
224
|
+
};
|
|
225
|
+
},
|
|
226
|
+
isConnected: async (userId) => (await tokenStore.loadTokens(userId)) !== null,
|
|
227
|
+
disconnect: (userId) => tokenStore.clearTokens(userId),
|
|
228
|
+
// A dead grant surfaces at the refresh, before any call is made.
|
|
229
|
+
refusal: () => null,
|
|
230
|
+
getAccessToken,
|
|
231
|
+
getTokens: (userId) => tokenStore.loadTokens(userId),
|
|
232
|
+
refreshTokens,
|
|
233
|
+
refreshIfExpiring,
|
|
234
|
+
};
|
|
235
|
+
}
|
package/src/server/types.ts
CHANGED
|
@@ -3,6 +3,42 @@
|
|
|
3
3
|
* the app's server and holds the client secret.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* How the app holds a user's grant.
|
|
8
|
+
*
|
|
9
|
+
* - `"key"`: the app's connect key plus a permanent id per user. Nothing
|
|
10
|
+
* expires, so there is nothing to refresh. The default.
|
|
11
|
+
* - `"token"`: an access token and a refresh token per user, which the
|
|
12
|
+
* SDK keeps fresh.
|
|
13
|
+
*/
|
|
14
|
+
export type OneConnectMode = "key" | "token";
|
|
15
|
+
|
|
16
|
+
/** Key mode: who a stored user is to One, and where their grant lives. */
|
|
17
|
+
export interface OneConnectUserReference {
|
|
18
|
+
/** One's permanent id for this user, for this app: `cu_…`. */
|
|
19
|
+
connectUserId: string;
|
|
20
|
+
/** The organization the user granted from; absent for their personal
|
|
21
|
+
* space. */
|
|
22
|
+
organizationId?: string;
|
|
23
|
+
/** The project the user granted from, when it was one. */
|
|
24
|
+
projectId?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Key mode: where the app keeps the one value the SDK gives it per user.
|
|
29
|
+
* A single string, written when the user connects and the same until
|
|
30
|
+
* they connect again, so one column on the app's user row is enough.
|
|
31
|
+
* `userId` is the app's own id for its user.
|
|
32
|
+
*
|
|
33
|
+
* It is an identifier, not a credential: it does nothing without the
|
|
34
|
+
* app's connect key. No lock is needed, because nothing rotates.
|
|
35
|
+
*/
|
|
36
|
+
export interface OneConnectUserStore {
|
|
37
|
+
saveUser: (userId: string, reference: string) => Promise<void>;
|
|
38
|
+
loadUser: (userId: string) => Promise<string | null>;
|
|
39
|
+
clearUser: (userId: string) => Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
|
|
6
42
|
/** What the app stores per user after the exchange. */
|
|
7
43
|
export interface OneConnectTokens {
|
|
8
44
|
accessToken: string;
|
|
@@ -50,11 +86,14 @@ export interface OneConnectTokenStore {
|
|
|
50
86
|
|
|
51
87
|
export interface RefreshIfExpiringOptions {
|
|
52
88
|
/** Refresh when the access token or the refresh token expires within
|
|
53
|
-
* this many milliseconds. One minute when omitted.
|
|
89
|
+
* this many milliseconds. One minute when omitted. A refresh token
|
|
90
|
+
* that has already run out cannot be refreshed: the pair is returned
|
|
91
|
+
* as it is while its access token still works. */
|
|
54
92
|
withinMs?: number;
|
|
55
93
|
}
|
|
56
94
|
|
|
57
|
-
|
|
95
|
+
/** What every app configures, whichever mode it uses. */
|
|
96
|
+
export interface OneConnectBaseConfig {
|
|
58
97
|
/** The app's client id from the dashboard. */
|
|
59
98
|
clientId: string;
|
|
60
99
|
/** The app's client secret. Server only. */
|
|
@@ -73,9 +112,39 @@ export interface OneConnectServerConfig {
|
|
|
73
112
|
/** OAuth scopes. All three tenancy tiers when omitted, so the user may
|
|
74
113
|
* grant from any space. */
|
|
75
114
|
scopes?: string[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Key mode, the default: pass the app's connect key and a place to keep
|
|
119
|
+
* one value per user.
|
|
120
|
+
*/
|
|
121
|
+
export interface OneConnectKeyConfig extends OneConnectBaseConfig {
|
|
122
|
+
mode?: "key";
|
|
123
|
+
/** The app's connect key, minted on the app's page in the dashboard.
|
|
124
|
+
* Server only. One key per environment. */
|
|
125
|
+
connectKey: string;
|
|
126
|
+
userStore: OneConnectUserStore;
|
|
127
|
+
tokenStore?: never;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Token mode: pass a token store and the SDK keeps each user's tokens
|
|
132
|
+
* fresh.
|
|
133
|
+
*/
|
|
134
|
+
export interface OneConnectTokenConfig extends OneConnectBaseConfig {
|
|
135
|
+
mode?: "token";
|
|
76
136
|
tokenStore: OneConnectTokenStore;
|
|
137
|
+
connectKey?: never;
|
|
138
|
+
userStore?: never;
|
|
77
139
|
}
|
|
78
140
|
|
|
141
|
+
/**
|
|
142
|
+
* The mode is whichever credential is configured: a `connectKey` is key
|
|
143
|
+
* mode, a `tokenStore` alone is token mode. Set `mode` to say so
|
|
144
|
+
* explicitly.
|
|
145
|
+
*/
|
|
146
|
+
export type OneConnectServerConfig = OneConnectKeyConfig | OneConnectTokenConfig;
|
|
147
|
+
|
|
79
148
|
/** The transaction cookie the authorize leg sets and the callback reads. */
|
|
80
149
|
export interface OneConnectCookie {
|
|
81
150
|
name: string;
|
|
@@ -170,20 +239,26 @@ export interface RunActionResult {
|
|
|
170
239
|
status: number;
|
|
171
240
|
ok: boolean;
|
|
172
241
|
/** True when One refused the call because it is outside the grant.
|
|
173
|
-
* The provider was never called. Do not retry.
|
|
242
|
+
* The provider was never called. Do not retry. Key mode sets it; in
|
|
243
|
+
* token mode it stays false today, so treat any 403 there as refused. */
|
|
174
244
|
blockedByGrant: boolean;
|
|
175
245
|
data: unknown;
|
|
176
246
|
}
|
|
177
247
|
|
|
178
248
|
/**
|
|
179
|
-
* - `not_connected`:
|
|
180
|
-
* - `
|
|
181
|
-
*
|
|
249
|
+
* - `not_connected`: nothing is stored for this user.
|
|
250
|
+
* - `reconnect_required` (key mode): One will not act for this user.
|
|
251
|
+
* Their consent was revoked, or the app is deactivated. What the app
|
|
252
|
+
* stored is kept; ask the user to connect again.
|
|
253
|
+
* - `refresh_failed` (token mode): One declared the grant dead (revoked,
|
|
254
|
+
* expired or reused). The tokens were cleared; ask the user to connect
|
|
255
|
+
* again.
|
|
182
256
|
* - `request_failed`: One answered with an error or could not be
|
|
183
|
-
* reached.
|
|
257
|
+
* reached. Nothing stored was changed, so retry later.
|
|
184
258
|
*/
|
|
185
259
|
export type OneConnectErrorCode =
|
|
186
260
|
| "not_connected"
|
|
261
|
+
| "reconnect_required"
|
|
187
262
|
| "refresh_failed"
|
|
188
263
|
| "request_failed";
|
|
189
264
|
|