@withone/connect 0.10.0 → 0.12.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 +144 -26
- package/dist/button.d.ts +20 -14
- package/dist/constants.d.ts +5 -1
- package/dist/flow.d.ts +18 -0
- package/dist/index.cjs.js +1 -1
- package/dist/index.d.ts +7 -4
- package/dist/index.esm.js +1 -1
- package/dist/platforms.d.ts +1 -1
- package/dist/react.cjs.js +2 -1
- package/dist/react.d.ts +20 -1
- package/dist/react.esm.js +2 -1
- package/dist/return.d.ts +5 -2
- package/dist/server/index.cjs.js +157 -45
- package/dist/server/index.d.ts +11 -3
- package/dist/server/index.esm.js +157 -46
- package/dist/server/oauth.d.ts +4 -0
- package/dist/server/types.d.ts +47 -4
- package/dist/svelte.cjs.js +1 -1
- package/dist/svelte.d.ts +1 -1
- package/dist/svelte.esm.js +1 -1
- package/dist/types.d.ts +71 -29
- package/dist/vue.cjs.js +1 -1
- package/dist/vue.d.ts +71 -22
- package/dist/vue.esm.js +1 -1
- package/package.json +4 -1
- package/skills/one-connect/SKILL.md +55 -19
- package/src/button.ts +450 -295
- package/src/constants.ts +5 -1
- package/src/flow.ts +171 -0
- package/src/index.ts +17 -19
- package/src/platforms.ts +41 -6
- package/src/react.ts +119 -21
- package/src/return.ts +31 -9
- package/src/server/index.ts +188 -51
- package/src/server/oauth.ts +16 -0
- package/src/server/types.ts +49 -4
- package/src/svelte.ts +5 -13
- package/src/types.ts +73 -29
- package/src/vue.ts +42 -26
- package/dist/useOneConnect.d.ts +0 -13
- package/dist/wrapper-options.d.ts +0 -28
- package/src/useOneConnect.ts +0 -72
- package/src/wrapper-options.ts +0 -68
package/dist/react.d.ts
CHANGED
|
@@ -1,7 +1,26 @@
|
|
|
1
1
|
import type { CSSProperties, ReactElement } from "react";
|
|
2
|
-
import { type ConnectButtonProps as ConnectButtonCoreProps } from "@withone/connect";
|
|
2
|
+
import { type ConnectButtonProps as ConnectButtonCoreProps, type ConnectFailureCode, type OneConnectFlowOptions } from "@withone/connect";
|
|
3
3
|
export interface ConnectButtonProps extends ConnectButtonCoreProps {
|
|
4
|
+
/** On the wrapper element around the button. */
|
|
4
5
|
className?: string;
|
|
5
6
|
style?: CSSProperties;
|
|
6
7
|
}
|
|
7
8
|
export declare function ConnectButton(props: ConnectButtonProps): ReactElement;
|
|
9
|
+
export type OneConnectStatus = "idle" | "connecting" | "connected" | "error";
|
|
10
|
+
export interface UseOneConnectResult {
|
|
11
|
+
/** Sends the tab to One's hosted connect flow. */
|
|
12
|
+
open: () => void;
|
|
13
|
+
/** "connected" and "error" reflect how this page load ended a flow. */
|
|
14
|
+
status: OneConnectStatus;
|
|
15
|
+
error: {
|
|
16
|
+
message: string;
|
|
17
|
+
code: ConnectFailureCode;
|
|
18
|
+
} | null;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The flow for your own button:
|
|
22
|
+
*
|
|
23
|
+
* const { open, status, error } = useOneConnect({ authorizeUrl: "/api/one/authorize" });
|
|
24
|
+
* <button onClick={open} disabled={status === "connecting"}>Connect</button>
|
|
25
|
+
*/
|
|
26
|
+
export declare function useOneConnect(options: OneConnectFlowOptions): UseOneConnectResult;
|
package/dist/react.esm.js
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
"use client";
|
|
2
|
+
import{useRef as r,useEffect as n,createElement as e,useState as t,useCallback as c}from"react";import{mountConnectButton as l,createConnectFlow as o,readConnectReturn as u}from"@withone/connect";const s=r=>{var n,e;return JSON.stringify([r.authorizeUrl,null!==(n=r.platforms)&&void 0!==n?n:[],r.connected,r.disabled,r.variant,r.size,r.fullWidth,r.theme,null!==(e=r.connectTheme)&&void 0!==e?e:r.appTheme,r.accentColor,r.label,r.connectedLabel,r.description])};function a(t){const c=r(null),o=r(null),u=r(""),a=r(t);a.current=t;const i=()=>({...a.current,onSuccess:()=>{var r,n;return null===(r=(n=a.current).onSuccess)||void 0===r?void 0:r.call(n)},onError:(r,n)=>{var e,t;return null===(e=(t=a.current).onError)||void 0===e?void 0:e.call(t,r,n)},onCancel:()=>{var r,n;return null===(r=(n=a.current).onCancel)||void 0===r?void 0:r.call(n)}});n(()=>{if(c.current)return o.current=l(c.current,i()),u.current=s(a.current),()=>{var r;null===(r=o.current)||void 0===r||r.destroy(),o.current=null}},[]);const d=s(t);return n(()=>{o.current&&d!==u.current&&(u.current=d,o.current.update(i()))},[d]),e("div",{ref:c,className:t.className,style:t.style})}function i(e){const[l,s]=t("idle"),[a,i]=t(null),d=r(e);d.current=e;const v=r(null),f=()=>({...d.current,onSuccess:()=>{var r,n;return null===(r=(n=d.current).onSuccess)||void 0===r?void 0:r.call(n)},onError:(r,n)=>{var e,t;return null===(e=(t=d.current).onError)||void 0===e?void 0:e.call(t,r,n)},onCancel:()=>{var r,n;s("idle"),null===(r=(n=d.current).onCancel)||void 0===r||r.call(n)}});n(()=>{const r=o(f());v.current=r;const n=u();if("success"===(null==n?void 0:n.status))s("connected");else if("error"===(null==n?void 0:n.status)){var e,t;s("error"),i({message:null!==(e=n.message)&&void 0!==e?e:"",code:null!==(t=n.code)&&void 0!==t?t:"failed"})}return()=>{r.destroy(),v.current=null}},[]),n(()=>{var r;null===(r=v.current)||void 0===r||r.update(f())});return{open:c(()=>{v.current&&(i(null),s("connecting"),v.current.open())},[]),status:l,error:a}}export{a as ConnectButton,i as useOneConnect};
|
package/dist/return.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import type { OneConnectReturn } from "./types";
|
|
2
|
-
/**
|
|
1
|
+
import type { ConnectFailureCode, OneConnectReturn } from "./types";
|
|
2
|
+
/** The only text the SDK ever shows for a failed flow. */
|
|
3
|
+
export declare const ERROR_MESSAGES: Record<ConnectFailureCode, string>;
|
|
4
|
+
/** Reads the outcome the callback route put on the return URL. The
|
|
5
|
+
* message always comes from ERROR_MESSAGES, never from the URL. */
|
|
3
6
|
export declare function parseReturn(search: string): OneConnectReturn | null;
|
|
4
7
|
/** The same URL without the return params, so a refresh does not
|
|
5
8
|
* re-fire the callbacks. */
|
package/dist/server/index.cjs.js
CHANGED
|
@@ -6,7 +6,8 @@ var node_crypto = require('node:crypto');
|
|
|
6
6
|
* The SDK reads them off the page URL when the tab comes home, so
|
|
7
7
|
* there is no completion page to build. */
|
|
8
8
|
const RETURN_STATUS_PARAM = "one_connect";
|
|
9
|
-
|
|
9
|
+
/** Why the flow failed, as a code (see ConnectFailureCode). */
|
|
10
|
+
const RETURN_ERROR_PARAM = "one_connect_error";
|
|
10
11
|
|
|
11
12
|
/** One's production API. Point `oneApiUrl` elsewhere for development. */
|
|
12
13
|
const DEFAULT_ONE_API_URL = "https://api.withone.ai";
|
|
@@ -72,6 +73,18 @@ function tenancyHeaders(accessToken) {
|
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
/** When a refresh token stops working, in epoch milliseconds, from its
|
|
77
|
+
* `exp` claim. Null when the token carries no readable expiry, in which
|
|
78
|
+
* case only One can say whether it still works. */
|
|
79
|
+
function refreshTokenExpiresAt(refreshToken) {
|
|
80
|
+
try {
|
|
81
|
+
const payload = JSON.parse(Buffer.from(refreshToken.split(".")[1], "base64url").toString());
|
|
82
|
+
return typeof payload.exp === "number" && Number.isFinite(payload.exp) ? payload.exp * 1000 : null;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
75
88
|
/** The scopes the token was granted, from its claims. Display only. */
|
|
76
89
|
function tokenScopes(accessToken) {
|
|
77
90
|
try {
|
|
@@ -91,8 +104,8 @@ function tokenScopes(accessToken) {
|
|
|
91
104
|
|
|
92
105
|
/**
|
|
93
106
|
* Where the app keeps each user's tokens: its database, a cache, an
|
|
94
|
-
* encrypted cookie. The SDK never sees a token outside these
|
|
95
|
-
*
|
|
107
|
+
* encrypted cookie. The SDK never sees a token outside these calls.
|
|
108
|
+
* `userId` is the app's own id for its user.
|
|
96
109
|
*/
|
|
97
110
|
|
|
98
111
|
/** The transaction cookie the authorize leg sets and the callback reads. */
|
|
@@ -101,6 +114,14 @@ function tokenScopes(accessToken) {
|
|
|
101
114
|
|
|
102
115
|
/** One catalog action for a platform. */
|
|
103
116
|
|
|
117
|
+
/**
|
|
118
|
+
* - `not_connected`: no tokens are stored for this user.
|
|
119
|
+
* - `refresh_failed`: One declared the grant dead (revoked, expired or
|
|
120
|
+
* reused). The tokens were cleared; ask the user to connect again.
|
|
121
|
+
* - `request_failed`: One answered with an error or could not be
|
|
122
|
+
* reached. During a refresh the tokens are kept, so retry later.
|
|
123
|
+
*/
|
|
124
|
+
|
|
104
125
|
class OneConnectError extends Error {
|
|
105
126
|
constructor(code, message, status) {
|
|
106
127
|
super(message);
|
|
@@ -128,12 +149,26 @@ class OneConnectError extends Error {
|
|
|
128
149
|
* const reply = await oneConnect.runAction(userId, { connectionKey, actionId, method, path });
|
|
129
150
|
*
|
|
130
151
|
* The Next.js and Node adapters turn the first two into route handlers.
|
|
152
|
+
*
|
|
153
|
+
* Refresh. One's access token lives an hour by default, its refresh token 30
|
|
154
|
+
* days; every refresh rotates both, and One treats a second use of a
|
|
155
|
+
* rotated refresh token as theft and revokes the whole grant. So the
|
|
156
|
+
* client refreshes one user at a time (in this process always, across
|
|
157
|
+
* processes through `tokenStore.withLock`), re-reads the store before
|
|
158
|
+
* spending a refresh token, clears tokens only when One declares the
|
|
159
|
+
* grant dead, and never lets a failing old pair delete a newer one.
|
|
131
160
|
*/
|
|
132
161
|
|
|
133
162
|
/** Refresh this long before expiry, so a call never races the clock. */
|
|
134
163
|
const REFRESH_MARGIN_MS = 60_000;
|
|
135
164
|
const CATALOG_PAGE_SIZE = 100;
|
|
136
165
|
const CATALOG_MAX_PAGES = 20;
|
|
166
|
+
|
|
167
|
+
/** What One's token endpoint answered. A network failure throws instead. */
|
|
168
|
+
|
|
169
|
+
/** The one refusal that means the grant is gone for good: revoked by the
|
|
170
|
+
* user, expired, or burned by a reused refresh token (RFC 6749 §5.2). */
|
|
171
|
+
const isDeadGrant = answer => !answer.ok && answer.status === 400 && answer.error === "invalid_grant";
|
|
137
172
|
function createOneConnect(config) {
|
|
138
173
|
const oneApiUrl = (config.oneApiUrl ?? DEFAULT_ONE_API_URL).replace(/\/+$/, "");
|
|
139
174
|
const authorizeUrl = `${oneApiUrl}/oauth/authorize`;
|
|
@@ -148,13 +183,13 @@ function createOneConnect(config) {
|
|
|
148
183
|
/** One refresh in flight per user: two concurrent refreshes with the
|
|
149
184
|
* same refresh token trip One's reuse detection. */
|
|
150
185
|
const refreshing = new Map();
|
|
151
|
-
const returnUrl = (status,
|
|
186
|
+
const returnUrl = (status, code) => {
|
|
152
187
|
const url = new URL(returnTo, config.redirectUri);
|
|
153
188
|
url.searchParams.set(RETURN_STATUS_PARAM, status);
|
|
154
|
-
if (
|
|
189
|
+
if (code) url.searchParams.set(RETURN_ERROR_PARAM, code);
|
|
155
190
|
return url.toString();
|
|
156
191
|
};
|
|
157
|
-
const
|
|
192
|
+
const postToken = async body => {
|
|
158
193
|
const response = await fetch(tokenUrl, {
|
|
159
194
|
method: "POST",
|
|
160
195
|
headers: {
|
|
@@ -163,10 +198,40 @@ function createOneConnect(config) {
|
|
|
163
198
|
},
|
|
164
199
|
body
|
|
165
200
|
});
|
|
166
|
-
if (
|
|
167
|
-
|
|
201
|
+
if (response.ok) return {
|
|
202
|
+
ok: true,
|
|
203
|
+
body: await response.json()
|
|
204
|
+
};
|
|
205
|
+
let error;
|
|
206
|
+
try {
|
|
207
|
+
const refusal = await response.json();
|
|
208
|
+
if (typeof refusal.error === "string") error = refusal.error;
|
|
209
|
+
} catch {
|
|
210
|
+
/* not an OAuth error body: a proxy page or a server error */
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
ok: false,
|
|
214
|
+
status: response.status,
|
|
215
|
+
error
|
|
216
|
+
};
|
|
217
|
+
};
|
|
218
|
+
const exchange = async body => {
|
|
219
|
+
const answer = await postToken(body);
|
|
220
|
+
if (!answer.ok) {
|
|
221
|
+
throw new OneConnectError("request_failed", `One refused the token request (HTTP ${answer.status}).`, answer.status);
|
|
168
222
|
}
|
|
169
|
-
return
|
|
223
|
+
return answer.body;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/** Runs `run` under the app's cross-process lock for this user, when
|
|
227
|
+
* the store has one. */
|
|
228
|
+
const locked = (userId, run) => tokenStore.withLock ? tokenStore.withLock(userId, run) : run();
|
|
229
|
+
|
|
230
|
+
/** Whether either token of the pair stops working within `withinMs`. */
|
|
231
|
+
const expiresWithin = (tokens, withinMs) => {
|
|
232
|
+
const horizon = Date.now() + withinMs;
|
|
233
|
+
const refreshExpiresAt = refreshTokenExpiresAt(tokens.refreshToken);
|
|
234
|
+
return tokens.expiresAt <= horizon || refreshExpiresAt !== null && refreshExpiresAt <= horizon;
|
|
170
235
|
};
|
|
171
236
|
const toTokens = response => ({
|
|
172
237
|
accessToken: response.access_token,
|
|
@@ -198,17 +263,18 @@ function createOneConnect(config) {
|
|
|
198
263
|
const oauthError = params.get("error");
|
|
199
264
|
const cookieName = state ? txCookieName(state) : undefined;
|
|
200
265
|
const verifier = cookieName ? input.getCookie(cookieName) : undefined;
|
|
201
|
-
const fail = (
|
|
202
|
-
outcome,
|
|
266
|
+
const fail = (failure, message) => ({
|
|
267
|
+
outcome: failure === "declined" ? "declined" : "failed",
|
|
268
|
+
code: failure,
|
|
203
269
|
message,
|
|
204
|
-
redirectUrl: returnUrl("error",
|
|
270
|
+
redirectUrl: returnUrl("error", failure),
|
|
205
271
|
clearCookieName: cookieName
|
|
206
272
|
});
|
|
207
|
-
if (oauthError === "access_denied") return fail("declined", "
|
|
273
|
+
if (oauthError === "access_denied") return fail("declined", "The user cancelled on One's page.");
|
|
208
274
|
if (oauthError) return fail("failed", `One reported an error: ${oauthError}.`);
|
|
209
|
-
// The returned state names its own cookie. No cookie means a
|
|
210
|
-
// or
|
|
211
|
-
if (!code || !state || !verifier) return fail("
|
|
275
|
+
// The returned state names its own cookie. No cookie means a stale,
|
|
276
|
+
// foreign or forged state; the code is never exchanged in that case.
|
|
277
|
+
if (!code || !state || !verifier) return fail("expired", "The attempt expired, or its state cookie was missing.");
|
|
212
278
|
try {
|
|
213
279
|
const tokens = toTokens(await exchange(new URLSearchParams({
|
|
214
280
|
grant_type: "authorization_code",
|
|
@@ -216,7 +282,9 @@ function createOneConnect(config) {
|
|
|
216
282
|
redirect_uri: config.redirectUri,
|
|
217
283
|
code_verifier: verifier
|
|
218
284
|
})));
|
|
219
|
-
|
|
285
|
+
// Under the lock, so a refresh in flight on another server cannot
|
|
286
|
+
// interleave with this save.
|
|
287
|
+
await locked(input.userId, () => tokenStore.saveTokens(input.userId, tokens));
|
|
220
288
|
} catch (error) {
|
|
221
289
|
const status = error instanceof OneConnectError ? error.status : undefined;
|
|
222
290
|
return fail("failed", status ? `One rejected the code exchange (HTTP ${status}).` : "One could not be reached to complete the connection.");
|
|
@@ -227,39 +295,81 @@ function createOneConnect(config) {
|
|
|
227
295
|
clearCookieName: cookieName
|
|
228
296
|
};
|
|
229
297
|
};
|
|
230
|
-
const
|
|
298
|
+
const notConnected = () => new OneConnectError("not_connected", "This user is not connected.");
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The grant behind `failed` is dead. Clears it, unless a newer pair
|
|
302
|
+
* landed while it was failing (a reconnect's callback, another
|
|
303
|
+
* server's refresh): that pair is returned instead, because the user
|
|
304
|
+
* did nothing wrong and deleting it would disconnect them.
|
|
305
|
+
*/
|
|
306
|
+
const retire = async (userId, failed, status) => {
|
|
307
|
+
const latest = await tokenStore.loadTokens(userId);
|
|
308
|
+
if (latest && latest.refreshToken !== failed.refreshToken) return latest;
|
|
309
|
+
await tokenStore.clearTokens(userId, failed);
|
|
310
|
+
throw new OneConnectError("refresh_failed", "The connection to One has expired or was revoked. Ask the user to connect again.", status);
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The one place a refresh token is spent. Under the app's lock it
|
|
315
|
+
* re-reads the store, and refreshes only when `stillNeeded` says the
|
|
316
|
+
* stored pair still needs it: another process may have refreshed while
|
|
317
|
+
* this one waited, and spending the same refresh token twice makes One
|
|
318
|
+
* revoke the grant.
|
|
319
|
+
*/
|
|
320
|
+
const refreshUnderLock = (userId, stillNeeded) => locked(userId, async () => {
|
|
321
|
+
const current = await tokenStore.loadTokens(userId);
|
|
322
|
+
if (!current) throw notConnected();
|
|
323
|
+
if (!stillNeeded(current)) return current;
|
|
324
|
+
|
|
325
|
+
// An expired refresh token cannot work, and One answers one with a
|
|
326
|
+
// server error rather than invalid_grant, so settle it here.
|
|
327
|
+
const refreshExpiresAt = refreshTokenExpiresAt(current.refreshToken);
|
|
328
|
+
if (refreshExpiresAt !== null && refreshExpiresAt <= Date.now()) return retire(userId, current);
|
|
329
|
+
let answer;
|
|
330
|
+
try {
|
|
331
|
+
answer = await postToken(new URLSearchParams({
|
|
332
|
+
grant_type: "refresh_token",
|
|
333
|
+
refresh_token: current.refreshToken
|
|
334
|
+
}));
|
|
335
|
+
} catch {
|
|
336
|
+
throw new OneConnectError("request_failed", "One could not be reached to refresh the connection. The tokens were kept; try again.");
|
|
337
|
+
}
|
|
338
|
+
if (answer.ok) {
|
|
339
|
+
// Both tokens: One rotates the pair on every refresh.
|
|
340
|
+
const next = toTokens(answer.body);
|
|
341
|
+
await tokenStore.saveTokens(userId, next);
|
|
342
|
+
return next;
|
|
343
|
+
}
|
|
344
|
+
if (isDeadGrant(answer)) return retire(userId, current, answer.status);
|
|
345
|
+
// A server error, a rate limit, a misconfigured secret: nothing says
|
|
346
|
+
// the grant is gone, so keep the tokens and let the caller retry.
|
|
347
|
+
throw new OneConnectError("request_failed", `One could not refresh the connection (HTTP ${answer.status}). The tokens were kept; try again.`, answer.status);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
/** One refresh per user in this process; concurrent callers share it. */
|
|
351
|
+
const singleFlight = (userId, job) => {
|
|
231
352
|
const inFlight = refreshing.get(userId);
|
|
232
353
|
if (inFlight) return inFlight;
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
try {
|
|
237
|
-
const next = toTokens(await exchange(new URLSearchParams({
|
|
238
|
-
grant_type: "refresh_token",
|
|
239
|
-
refresh_token: current.refreshToken
|
|
240
|
-
})));
|
|
241
|
-
// Both tokens: One rotates the pair on every refresh.
|
|
242
|
-
await tokenStore.saveTokens(userId, next);
|
|
243
|
-
return next;
|
|
244
|
-
} catch (error) {
|
|
245
|
-
// The family is dead: revoked, expired or reused. Keeping the
|
|
246
|
-
// pair would only fail again; the user has to reconnect.
|
|
247
|
-
await tokenStore.clearTokens(userId);
|
|
248
|
-
const status = error instanceof OneConnectError ? error.status : undefined;
|
|
249
|
-
throw new OneConnectError("refresh_failed", "The connection to One has expired or was revoked. Ask the user to connect again.", status);
|
|
250
|
-
} finally {
|
|
251
|
-
refreshing.delete(userId);
|
|
252
|
-
}
|
|
253
|
-
})();
|
|
254
|
-
refreshing.set(userId, job);
|
|
255
|
-
return job;
|
|
354
|
+
const running = job().finally(() => refreshing.delete(userId));
|
|
355
|
+
refreshing.set(userId, running);
|
|
356
|
+
return running;
|
|
256
357
|
};
|
|
257
|
-
const
|
|
358
|
+
const refreshTokens = userId => singleFlight(userId, async () => {
|
|
359
|
+
const before = await tokenStore.loadTokens(userId);
|
|
360
|
+
if (!before) throw notConnected();
|
|
361
|
+
// Rotate the pair seen now; a pair someone else rotated since is
|
|
362
|
+
// already fresh.
|
|
363
|
+
return refreshUnderLock(userId, current => current.refreshToken === before.refreshToken);
|
|
364
|
+
});
|
|
365
|
+
const refreshIfExpiring = async (userId, options = {}) => {
|
|
366
|
+
const withinMs = options.withinMs ?? REFRESH_MARGIN_MS;
|
|
258
367
|
const tokens = await tokenStore.loadTokens(userId);
|
|
259
|
-
if (!tokens) throw
|
|
260
|
-
if (
|
|
261
|
-
return (
|
|
368
|
+
if (!tokens) throw notConnected();
|
|
369
|
+
if (!expiresWithin(tokens, withinMs)) return tokens;
|
|
370
|
+
return singleFlight(userId, () => refreshUnderLock(userId, current => expiresWithin(current, withinMs)));
|
|
262
371
|
};
|
|
372
|
+
const getAccessToken = async userId => (await refreshIfExpiring(userId)).accessToken;
|
|
263
373
|
const oneFetch = async (userId, path, init = {}) => {
|
|
264
374
|
const accessToken = await getAccessToken(userId);
|
|
265
375
|
const headers = new Headers(init.headers);
|
|
@@ -335,6 +445,7 @@ function createOneConnect(config) {
|
|
|
335
445
|
getAccessToken,
|
|
336
446
|
getTokens: userId => tokenStore.loadTokens(userId),
|
|
337
447
|
refreshTokens,
|
|
448
|
+
refreshIfExpiring,
|
|
338
449
|
disconnect: userId => tokenStore.clearTokens(userId),
|
|
339
450
|
listConnections,
|
|
340
451
|
listActions,
|
|
@@ -345,5 +456,6 @@ function createOneConnect(config) {
|
|
|
345
456
|
|
|
346
457
|
exports.OneConnectError = OneConnectError;
|
|
347
458
|
exports.createOneConnect = createOneConnect;
|
|
459
|
+
exports.refreshTokenExpiresAt = refreshTokenExpiresAt;
|
|
348
460
|
exports.tenancyHeaders = tenancyHeaders;
|
|
349
461
|
exports.tokenScopes = tokenScopes;
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { type CompleteAuthorizationInput, type CompleteAuthorizationResult, type OneConnectServerConfig, type OneConnectTokens, type PlatformAction, type ReachableConnection, type RunActionInput, type RunActionResult, type StartAuthorizationInput, type StartAuthorizationResult } from "./types";
|
|
1
|
+
import { type CompleteAuthorizationInput, type CompleteAuthorizationResult, type OneConnectServerConfig, type OneConnectTokens, type PlatformAction, type ReachableConnection, type RefreshIfExpiringOptions, type RunActionInput, type RunActionResult, type StartAuthorizationInput, type StartAuthorizationResult } from "./types";
|
|
2
2
|
export * from "./types";
|
|
3
|
-
export { tenancyHeaders, tokenScopes } from "./oauth";
|
|
3
|
+
export { refreshTokenExpiresAt, tenancyHeaders, tokenScopes } from "./oauth";
|
|
4
4
|
export interface OneConnect {
|
|
5
5
|
/** The authorize leg: where to send the browser and the cookie to set. */
|
|
6
6
|
startAuthorization: (input?: StartAuthorizationInput) => StartAuthorizationResult;
|
|
@@ -14,8 +14,16 @@ export interface OneConnect {
|
|
|
14
14
|
getAccessToken: (userId: string) => Promise<string>;
|
|
15
15
|
/** The stored tokens, for display. Null when not connected. */
|
|
16
16
|
getTokens: (userId: string) => Promise<OneConnectTokens | null>;
|
|
17
|
-
/**
|
|
17
|
+
/** Refreshes now and returns the new pair. When another caller rotated
|
|
18
|
+
* the pair a moment earlier, returns that pair instead of rotating it
|
|
19
|
+
* a second time. */
|
|
18
20
|
refreshTokens: (userId: string) => Promise<OneConnectTokens>;
|
|
21
|
+
/** Refreshes only when the access token or the refresh token expires
|
|
22
|
+
* within `withinMs`, and returns the pair that is current afterwards.
|
|
23
|
+
* For background jobs: a frequent run keeps access tokens warm, and a
|
|
24
|
+
* daily run with a window of days keeps idle users' 30-day refresh
|
|
25
|
+
* tokens from running out. */
|
|
26
|
+
refreshIfExpiring: (userId: string, options?: RefreshIfExpiringOptions) => Promise<OneConnectTokens>;
|
|
19
27
|
/** Drops the app's copy of the tokens. The user revokes the grant
|
|
20
28
|
* itself from their One dashboard. */
|
|
21
29
|
disconnect: (userId: string) => Promise<void>;
|