@zackbart/connecta 0.21.0 → 0.21.2
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/CHANGELOG.md +74 -0
- package/dist/auth/downstream-oauth.d.ts +65 -2
- package/dist/auth/downstream-oauth.js +408 -20
- package/dist/connectors/remote-mcp.js +8 -4
- package/dist/executors/quickjs.js +4 -0
- package/dist/providers/cloudflare.js +233 -61
- package/dist/providers/linear.js +7 -0
- package/dist/providers/mixpanel.js +6 -4
- package/dist/providers/revenuecat.js +2 -1
- package/dist/providers/stripe.js +7 -6
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +17 -0
- package/documentation/auth.md +1 -1
- package/documentation/cloudflare.md +19 -1
- package/documentation/code-mode.md +2 -2
- package/documentation/linear.md +6 -4
- package/documentation/mixpanel.md +5 -3
- package/documentation/operations.md +7 -7
- package/documentation/provider-conventions.md +4 -2
- package/documentation/revenuecat.md +21 -21
- package/documentation/storage-and-credentials.md +35 -0
- package/documentation/stripe.md +14 -13
- package/documentation/upgrading.md +8 -7
- package/package.json +1 -1
- package/templates/node/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,80 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this package are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.21.2 — 2026-08-31
|
|
6
|
+
|
|
7
|
+
This patch closes two runtime isolation gaps: concurrent request scopes now
|
|
8
|
+
share one downstream OAuth refresh inside a connector runtime, and QuickJS
|
|
9
|
+
children no longer inherit the deployment process environment. Existing
|
|
10
|
+
deployments need no configuration or storage migration. OAuth deployments get
|
|
11
|
+
the refresh fix automatically; Node deployments using QuickJS get the tighter
|
|
12
|
+
child boundary automatically. Deployments using neither path can ignore this
|
|
13
|
+
release.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- **Empty QuickJS child environments.** The Node process hosting the QuickJS
|
|
18
|
+
guest runtime starts with an explicit empty environment, so deployment
|
|
19
|
+
credentials and Node startup configuration such as `NODE_OPTIONS` never
|
|
20
|
+
cross the child-process boundary (#515).
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **One rotating-token redemption per runtime.** Concurrent request scopes for
|
|
25
|
+
one OAuth connector generation share the owner's refresh result or bounded
|
|
26
|
+
failure instead of independently redeeming the same refresh token. The gate
|
|
27
|
+
remains request-safe across follower and owner cancellation, storage
|
|
28
|
+
failures, force reauthorization, and issuer-generation changes (#514).
|
|
29
|
+
- **Late and byte-identical refresh races.** Generation-scoped mutation and
|
|
30
|
+
success identities close token-save TOCTOU and complete-flight ABA windows,
|
|
31
|
+
including authorization servers that preserve the refresh token or return
|
|
32
|
+
byte-identical credentials. The guarantee is deliberately runtime-local;
|
|
33
|
+
`KVStorage` still has no cross-isolate compare-and-set primitive (#514).
|
|
34
|
+
|
|
35
|
+
## 0.21.1 — 2026-08-30
|
|
36
|
+
|
|
37
|
+
This patch aligns the handwritten Cloudflare and Notion contracts and the four
|
|
38
|
+
hosted-provider manifests with their current catalogs. Existing deployments
|
|
39
|
+
need no configuration or storage migration. Cloudflare programs that depend on
|
|
40
|
+
provider fields outside the maintained `get_zone_setting` or
|
|
41
|
+
`get_worker_settings` projections should pass `raw: true`. Stripe's server has
|
|
42
|
+
retired three dedicated tool names, so programs that hardcoded them must search
|
|
43
|
+
the current catalog and use the generic API tools or `stripe_analytics`.
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
|
|
47
|
+
- **Current Cloudflare jurisdictions.** All eight R2 bucket tools accept the
|
|
48
|
+
`us` jurisdiction alongside `default`, `eu`, and `fedramp`. KV namespace
|
|
49
|
+
creation accepts its separate `eu`, `fedramp`, and `us` set and preserves
|
|
50
|
+
jurisdiction on namespace reads (#498, #499).
|
|
51
|
+
- **Current hosted-provider classifications.** Linear's workspace, template,
|
|
52
|
+
and issue-sharing tools join its plan-aware reviewed superset. RevenueCat's
|
|
53
|
+
refund-request preferences are read-only, and Mixpanel's metadata fill is a
|
|
54
|
+
destructive Lexicon update (#512).
|
|
55
|
+
|
|
56
|
+
### Changed
|
|
57
|
+
|
|
58
|
+
- **Useful Cloudflare result schemas.** All 48 named tools now declare their
|
|
59
|
+
maintained output keys. Zone settings and Worker settings project stable
|
|
60
|
+
camel-case fields by default and expose the provider response through
|
|
61
|
+
`raw: true`; rulesets, deployments, KV bulk operations, R2 CORS, and Pages
|
|
62
|
+
reuse named schemas instead of an empty object contract (#488).
|
|
63
|
+
- **Stripe's current eleven tools.** Account listing, account-management links,
|
|
64
|
+
and analytics replace the retired account-info, refund, and report tools.
|
|
65
|
+
The integration planner and analytics are additive writes because their tool
|
|
66
|
+
boundaries can create provider-side guide or query-run state (#512).
|
|
67
|
+
|
|
68
|
+
### Fixed
|
|
69
|
+
|
|
70
|
+
- Worker settings retain VPC binding identity and observability fields,
|
|
71
|
+
rulesets retain origin-range metadata, and zone setting ids include
|
|
72
|
+
Cloudflare's current WebMCP settings (#500, #501, #502, #503).
|
|
73
|
+
- Notion preserves every documented `verification.does_not_equal` value, and
|
|
74
|
+
Cloudflare's recorded KV bulk and zone-setting specifications match the
|
|
75
|
+
current published documents (#504, #509).
|
|
76
|
+
- Mixpanel records the current schemas for all 64 classified tools, including
|
|
77
|
+
the three experiment contracts that changed since the prior review (#512).
|
|
78
|
+
|
|
5
79
|
## 0.21.0 — 2026-08-28
|
|
6
80
|
|
|
7
81
|
Cloudflare Access becomes the canonical interactive-auth path for Worker
|
|
@@ -1,5 +1,55 @@
|
|
|
1
|
-
import type { OAuthClientInformationContext, OAuthClientInformationMixed, OAuthClientMetadata, OAuthClientProvider, OAuthTokens } from "@modelcontextprotocol/client";
|
|
1
|
+
import type { FetchLike, OAuthClientInformationContext, OAuthClientInformationMixed, OAuthClientMetadata, OAuthClientProvider, OAuthTokens } from "@modelcontextprotocol/client";
|
|
2
2
|
import type { KVStorage } from "../types.js";
|
|
3
|
+
type OAuthRefreshFlightOutcome = {
|
|
4
|
+
status: "refreshed";
|
|
5
|
+
} | {
|
|
6
|
+
status: "retired";
|
|
7
|
+
} | {
|
|
8
|
+
status: "failed";
|
|
9
|
+
error: unknown;
|
|
10
|
+
};
|
|
11
|
+
interface OAuthRefreshFlight {
|
|
12
|
+
done: Promise<OAuthRefreshFlightOutcome>;
|
|
13
|
+
release: (outcome: OAuthRefreshFlightOutcome) => void;
|
|
14
|
+
stopObservingOwnerAbort: () => void;
|
|
15
|
+
mutationId: object;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Share one rotating-token redemption within one connector runtime and OAuth
|
|
19
|
+
* generation. The first request still owns the real fetch and response. Its
|
|
20
|
+
* abort signal has one bounded listener until the exact flight settles, so a
|
|
21
|
+
* cancellation after the response cannot strand waiters during token storage.
|
|
22
|
+
* Followers wait for that provider to save tokens, then re-read storage. The
|
|
23
|
+
* map never retains a token response or transport.
|
|
24
|
+
*
|
|
25
|
+
* This is intentionally runtime-local. KVStorage has no atomic coordination
|
|
26
|
+
* operation, so a second isolate can still race the same refresh token.
|
|
27
|
+
*/
|
|
28
|
+
export declare class OAuthRefreshCoordinator {
|
|
29
|
+
private readonly flights;
|
|
30
|
+
/** Opaque identities only: no request promise, signal, callback, or response. */
|
|
31
|
+
private readonly pendingMutations;
|
|
32
|
+
/** One bounded latest-success slot, containing only generation + identity. */
|
|
33
|
+
private successfulRefresh;
|
|
34
|
+
/** Replaced on every map mutation, closing flight/pending ABA across awaits. */
|
|
35
|
+
private stateRevision;
|
|
36
|
+
private advanceStateRevision;
|
|
37
|
+
private observeAuthoritativeGeneration;
|
|
38
|
+
private settle;
|
|
39
|
+
private markMutationPending;
|
|
40
|
+
private finishMutation;
|
|
41
|
+
/** @internal Opaque basis for issuer-aware provider token reads. */
|
|
42
|
+
successfulRefreshIdentity(generation: string): object | undefined;
|
|
43
|
+
coordinatedFetch(provider: KvOAuthProvider, baseFetch: FetchLike, requestSignal?: AbortSignal): FetchLike;
|
|
44
|
+
/** Publish one exact owner's successful save without disturbing a newer try. */
|
|
45
|
+
succeedMutation(generation: string, flight: OAuthRefreshFlight): void;
|
|
46
|
+
/** Give joined callers a fetch/flow failure, without rejecting the gate. */
|
|
47
|
+
fail(generation: string, flight: OAuthRefreshFlight, error: unknown): void;
|
|
48
|
+
/** Finish an exact failed credential write, then publish its failure. */
|
|
49
|
+
failMutation(generation: string, flight: OAuthRefreshFlight, error: unknown): void;
|
|
50
|
+
/** Force reauthorization fences and wakes every waiter on the retired epoch. */
|
|
51
|
+
retire(generation: string): void;
|
|
52
|
+
}
|
|
3
53
|
/**
|
|
4
54
|
* Physical key for an OAuth value in one authorization epoch. Legacy values
|
|
5
55
|
* keep their historical names so upgrades can read an existing grant. Modern
|
|
@@ -20,19 +70,31 @@ export declare class KvOAuthProvider implements OAuthClientProvider {
|
|
|
20
70
|
private readonly connectorId;
|
|
21
71
|
private readonly storage;
|
|
22
72
|
private readonly redirectUri;
|
|
73
|
+
private readonly refreshCoordinator?;
|
|
23
74
|
/**
|
|
24
75
|
* The reset generation this provider's flow started under. Every OAuth value
|
|
25
76
|
* it writes carries this epoch, so a late write can land after a reset without
|
|
26
77
|
* becoming readable under the new generation.
|
|
27
78
|
*/
|
|
28
79
|
private capturedGeneration;
|
|
29
|
-
|
|
80
|
+
private refreshFlight;
|
|
81
|
+
/** Tokens this request's issuer-aware auth flow decided to refresh. */
|
|
82
|
+
private refreshBasis;
|
|
83
|
+
constructor(connectorId: string, storage: KVStorage, redirectUri: string, refreshCoordinator?: OAuthRefreshCoordinator | undefined);
|
|
30
84
|
/**
|
|
31
85
|
* Stamp the force-reauth generation the current connect flow started under.
|
|
32
86
|
* Called by the connector once per connect, before c.connect(). The callback
|
|
33
87
|
* path captures the generation stored beside its verified state instead.
|
|
34
88
|
*/
|
|
35
89
|
captureGeneration(gen: string): void;
|
|
90
|
+
/** The generation captured for this flow, before a concurrent reset. */
|
|
91
|
+
flowGeneration(): Promise<string>;
|
|
92
|
+
/** @internal Record the refresh attempt this provider owns. */
|
|
93
|
+
captureRefreshFlight(generation: string, flight: OAuthRefreshFlight): void;
|
|
94
|
+
private succeedRefreshFlight;
|
|
95
|
+
private failRefreshFlight;
|
|
96
|
+
/** True when another request saved a refresh result after this flow's read. */
|
|
97
|
+
refreshBasisChanged(current: OAuthTokens, generation: string): boolean;
|
|
36
98
|
/**
|
|
37
99
|
* The epoch this provider writes under. Direct unit/custom use lazily captures
|
|
38
100
|
* the current generation; connector-driven connect and callback paths stamp it
|
|
@@ -121,3 +183,4 @@ export declare class KvOAuthProvider implements OAuthClientProvider {
|
|
|
121
183
|
resetAuthorization(operatorDisconnected?: boolean): Promise<void>;
|
|
122
184
|
invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier" | "discovery"): Promise<void>;
|
|
123
185
|
}
|
|
186
|
+
export {};
|
|
@@ -26,6 +26,312 @@ const OAUTH_VALUE_KEYS = [
|
|
|
26
26
|
"oauth:state",
|
|
27
27
|
];
|
|
28
28
|
const MAX_CLEANUP_BACKLOG = 1_000;
|
|
29
|
+
function isRefreshTokenRequest(init) {
|
|
30
|
+
if ((init?.method ?? "GET").toUpperCase() !== "POST")
|
|
31
|
+
return false;
|
|
32
|
+
const body = init?.body;
|
|
33
|
+
if (body instanceof URLSearchParams) {
|
|
34
|
+
return body.get("grant_type") === "refresh_token";
|
|
35
|
+
}
|
|
36
|
+
if (typeof body !== "string")
|
|
37
|
+
return false;
|
|
38
|
+
return new URLSearchParams(body).get("grant_type") === "refresh_token";
|
|
39
|
+
}
|
|
40
|
+
function sdkAcceptsOAuthTokens(value) {
|
|
41
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
42
|
+
return false;
|
|
43
|
+
const candidate = value;
|
|
44
|
+
if (typeof candidate.access_token !== "string" ||
|
|
45
|
+
typeof candidate.token_type !== "string") {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
for (const key of ["id_token", "scope", "refresh_token"]) {
|
|
49
|
+
if (candidate[key] !== undefined && typeof candidate[key] !== "string") {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (candidate.expires_in !== undefined) {
|
|
54
|
+
try {
|
|
55
|
+
if (!Number.isFinite(Number(candidate.expires_in)))
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
async function refreshResponseFailure(response) {
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
return new Error(`OAuth refresh failed with HTTP ${response.status}.`);
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
return sdkAcceptsOAuthTokens(await response.clone().json())
|
|
70
|
+
? undefined
|
|
71
|
+
: new Error("OAuth refresh response did not match the token schema.");
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return new Error("OAuth refresh response did not contain JSON tokens.");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function refreshMutationPendingResponse() {
|
|
78
|
+
return Response.json({
|
|
79
|
+
error: "temporarily_unavailable",
|
|
80
|
+
error_description: "OAuth refresh is temporarily unavailable while previous credentials commit.",
|
|
81
|
+
}, { status: 503 });
|
|
82
|
+
}
|
|
83
|
+
function aborted(signal) {
|
|
84
|
+
return (signal.reason ?? new DOMException("This operation was aborted", "AbortError"));
|
|
85
|
+
}
|
|
86
|
+
async function waitForRefreshFlight(flight, signal) {
|
|
87
|
+
let outcome;
|
|
88
|
+
if (!signal) {
|
|
89
|
+
outcome = await flight.done;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
outcome = await new Promise((resolve, reject) => {
|
|
93
|
+
let settled = false;
|
|
94
|
+
const finish = (settle) => {
|
|
95
|
+
if (settled)
|
|
96
|
+
return;
|
|
97
|
+
settled = true;
|
|
98
|
+
signal.removeEventListener("abort", onAbort);
|
|
99
|
+
settle();
|
|
100
|
+
};
|
|
101
|
+
const onAbort = () => finish(() => reject(aborted(signal)));
|
|
102
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
103
|
+
void flight.done.then((result) => finish(() => resolve(result)));
|
|
104
|
+
// Abort may land between the caller's check and listener registration.
|
|
105
|
+
if (signal.aborted)
|
|
106
|
+
onAbort();
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
if (outcome.status === "failed")
|
|
110
|
+
throw outcome.error;
|
|
111
|
+
return outcome;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Share one rotating-token redemption within one connector runtime and OAuth
|
|
115
|
+
* generation. The first request still owns the real fetch and response. Its
|
|
116
|
+
* abort signal has one bounded listener until the exact flight settles, so a
|
|
117
|
+
* cancellation after the response cannot strand waiters during token storage.
|
|
118
|
+
* Followers wait for that provider to save tokens, then re-read storage. The
|
|
119
|
+
* map never retains a token response or transport.
|
|
120
|
+
*
|
|
121
|
+
* This is intentionally runtime-local. KVStorage has no atomic coordination
|
|
122
|
+
* operation, so a second isolate can still race the same refresh token.
|
|
123
|
+
*/
|
|
124
|
+
export class OAuthRefreshCoordinator {
|
|
125
|
+
flights = new Map();
|
|
126
|
+
/** Opaque identities only: no request promise, signal, callback, or response. */
|
|
127
|
+
pendingMutations = new Map();
|
|
128
|
+
/** One bounded latest-success slot, containing only generation + identity. */
|
|
129
|
+
successfulRefresh;
|
|
130
|
+
/** Replaced on every map mutation, closing flight/pending ABA across awaits. */
|
|
131
|
+
stateRevision = {};
|
|
132
|
+
advanceStateRevision() {
|
|
133
|
+
this.stateRevision = {};
|
|
134
|
+
}
|
|
135
|
+
observeAuthoritativeGeneration(generation) {
|
|
136
|
+
const staleGenerations = new Set([
|
|
137
|
+
...this.flights.keys(),
|
|
138
|
+
...this.pendingMutations.keys(),
|
|
139
|
+
]);
|
|
140
|
+
for (const stale of staleGenerations) {
|
|
141
|
+
if (stale !== generation)
|
|
142
|
+
this.retire(stale);
|
|
143
|
+
}
|
|
144
|
+
if (this.successfulRefresh &&
|
|
145
|
+
this.successfulRefresh.generation !== generation) {
|
|
146
|
+
this.successfulRefresh = undefined;
|
|
147
|
+
this.advanceStateRevision();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
settle(generation, flight, outcome) {
|
|
151
|
+
if (this.flights.get(generation) !== flight)
|
|
152
|
+
return;
|
|
153
|
+
this.flights.delete(generation);
|
|
154
|
+
this.advanceStateRevision();
|
|
155
|
+
flight.stopObservingOwnerAbort();
|
|
156
|
+
flight.release(outcome);
|
|
157
|
+
}
|
|
158
|
+
markMutationPending(generation, flight) {
|
|
159
|
+
if (this.flights.get(generation) !== flight)
|
|
160
|
+
return false;
|
|
161
|
+
this.pendingMutations.set(generation, flight.mutationId);
|
|
162
|
+
this.advanceStateRevision();
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
finishMutation(generation, flight) {
|
|
166
|
+
if (this.pendingMutations.get(generation) === flight.mutationId) {
|
|
167
|
+
this.pendingMutations.delete(generation);
|
|
168
|
+
this.advanceStateRevision();
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
/** @internal Opaque basis for issuer-aware provider token reads. */
|
|
174
|
+
successfulRefreshIdentity(generation) {
|
|
175
|
+
return this.successfulRefresh?.generation === generation
|
|
176
|
+
? this.successfulRefresh.identity
|
|
177
|
+
: undefined;
|
|
178
|
+
}
|
|
179
|
+
coordinatedFetch(provider, baseFetch, requestSignal) {
|
|
180
|
+
return async (input, init) => {
|
|
181
|
+
// Await passthrough failures here so workerd associates the rejection
|
|
182
|
+
// with the fetch the SDK is already awaiting, rather than reporting the
|
|
183
|
+
// adopted inner promise as an unhandled rejection.
|
|
184
|
+
if (!isRefreshTokenRequest(init))
|
|
185
|
+
return await baseFetch(input, init);
|
|
186
|
+
const generation = await provider.flowGeneration();
|
|
187
|
+
const requestedRefreshToken = init?.body instanceof URLSearchParams
|
|
188
|
+
? init.body.get("refresh_token")
|
|
189
|
+
: new URLSearchParams(String(init?.body ?? "")).get("refresh_token");
|
|
190
|
+
while (true) {
|
|
191
|
+
const revisionBeforeReads = this.stateRevision;
|
|
192
|
+
const activeGeneration = await provider.generation();
|
|
193
|
+
this.observeAuthoritativeGeneration(activeGeneration);
|
|
194
|
+
if (activeGeneration !== generation)
|
|
195
|
+
this.retire(generation);
|
|
196
|
+
const activeFlight = this.flights.get(generation);
|
|
197
|
+
const pendingMutation = this.pendingMutations.get(generation);
|
|
198
|
+
if (activeGeneration === generation &&
|
|
199
|
+
pendingMutation &&
|
|
200
|
+
activeFlight?.mutationId !== pendingMutation) {
|
|
201
|
+
return refreshMutationPendingResponse();
|
|
202
|
+
}
|
|
203
|
+
let currentTokens = activeGeneration === generation ? await provider.tokens() : undefined;
|
|
204
|
+
const latestGeneration = await provider.generation();
|
|
205
|
+
this.observeAuthoritativeGeneration(latestGeneration);
|
|
206
|
+
if (latestGeneration !== generation) {
|
|
207
|
+
this.retire(generation);
|
|
208
|
+
currentTokens = undefined;
|
|
209
|
+
}
|
|
210
|
+
if (this.stateRevision !== revisionBeforeReads)
|
|
211
|
+
continue;
|
|
212
|
+
// Re-check both identities after every storage await. An owner can
|
|
213
|
+
// abort while this caller reads tokens, leaving only the mutation
|
|
214
|
+
// marker; a reset can likewise retire this caller's captured epoch.
|
|
215
|
+
const existing = this.flights.get(generation);
|
|
216
|
+
const latestPendingMutation = this.pendingMutations.get(generation);
|
|
217
|
+
if (latestGeneration === generation &&
|
|
218
|
+
latestPendingMutation &&
|
|
219
|
+
existing?.mutationId !== latestPendingMutation) {
|
|
220
|
+
return refreshMutationPendingResponse();
|
|
221
|
+
}
|
|
222
|
+
// This flow may have read a token just before another flow saved its
|
|
223
|
+
// rotation. Replaying the retired token would recreate the race after
|
|
224
|
+
// the first network response. Give the SDK the already-saved rotating
|
|
225
|
+
// credential instead. A tokenless current value cannot do that: the
|
|
226
|
+
// SDK would merge the requested old refresh token back into it.
|
|
227
|
+
if (currentTokens?.refresh_token &&
|
|
228
|
+
(currentTokens.refresh_token !== requestedRefreshToken ||
|
|
229
|
+
provider.refreshBasisChanged(currentTokens, generation))) {
|
|
230
|
+
return Response.json(currentTokens);
|
|
231
|
+
}
|
|
232
|
+
if (currentTokens?.refresh_token !== requestedRefreshToken) {
|
|
233
|
+
return Response.json({
|
|
234
|
+
error: "invalid_grant",
|
|
235
|
+
error_description: "Refresh token is no longer active.",
|
|
236
|
+
}, { status: 400 });
|
|
237
|
+
}
|
|
238
|
+
if (existing) {
|
|
239
|
+
const outcome = await waitForRefreshFlight(existing, requestSignal);
|
|
240
|
+
if (outcome.status === "refreshed") {
|
|
241
|
+
const activeGeneration = await provider.generation();
|
|
242
|
+
const refreshedTokens = activeGeneration === generation
|
|
243
|
+
? await provider.tokens()
|
|
244
|
+
: undefined;
|
|
245
|
+
if (refreshedTokens?.refresh_token) {
|
|
246
|
+
return Response.json(refreshedTokens);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
let release;
|
|
252
|
+
const flight = {
|
|
253
|
+
done: new Promise((resolve) => {
|
|
254
|
+
release = resolve;
|
|
255
|
+
}),
|
|
256
|
+
release: (outcome) => release(outcome),
|
|
257
|
+
stopObservingOwnerAbort: () => { },
|
|
258
|
+
mutationId: {},
|
|
259
|
+
};
|
|
260
|
+
this.flights.set(generation, flight);
|
|
261
|
+
this.advanceStateRevision();
|
|
262
|
+
provider.captureRefreshFlight(generation, flight);
|
|
263
|
+
if (requestSignal) {
|
|
264
|
+
let observing = true;
|
|
265
|
+
const onOwnerAbort = () => {
|
|
266
|
+
this.fail(generation, flight, aborted(requestSignal));
|
|
267
|
+
};
|
|
268
|
+
flight.stopObservingOwnerAbort = () => {
|
|
269
|
+
if (!observing)
|
|
270
|
+
return;
|
|
271
|
+
observing = false;
|
|
272
|
+
requestSignal.removeEventListener("abort", onOwnerAbort);
|
|
273
|
+
};
|
|
274
|
+
requestSignal.addEventListener("abort", onOwnerAbort, { once: true });
|
|
275
|
+
// Abort may land between flight publication and listener registration.
|
|
276
|
+
if (requestSignal.aborted)
|
|
277
|
+
onOwnerAbort();
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
const response = await baseFetch(input, requestSignal ? { ...init, signal: requestSignal } : init);
|
|
281
|
+
// These responses never reach a successful saveTokens callback. Give
|
|
282
|
+
// current waiters a bounded failure now while leaving the owner's
|
|
283
|
+
// response untouched for the SDK to parse and classify itself.
|
|
284
|
+
const failure = await refreshResponseFailure(response);
|
|
285
|
+
if (failure) {
|
|
286
|
+
this.fail(generation, flight, failure);
|
|
287
|
+
}
|
|
288
|
+
else if (requestSignal?.aborted ||
|
|
289
|
+
!this.markMutationPending(generation, flight)) {
|
|
290
|
+
throw requestSignal?.aborted
|
|
291
|
+
? aborted(requestSignal)
|
|
292
|
+
: new Error("OAuth refresh ended before tokens could be saved.");
|
|
293
|
+
}
|
|
294
|
+
return response;
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
this.fail(generation, flight, error);
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
/** Publish one exact owner's successful save without disturbing a newer try. */
|
|
304
|
+
succeedMutation(generation, flight) {
|
|
305
|
+
if (this.finishMutation(generation, flight)) {
|
|
306
|
+
this.successfulRefresh = { generation, identity: {} };
|
|
307
|
+
this.advanceStateRevision();
|
|
308
|
+
}
|
|
309
|
+
this.settle(generation, flight, { status: "refreshed" });
|
|
310
|
+
}
|
|
311
|
+
/** Give joined callers a fetch/flow failure, without rejecting the gate. */
|
|
312
|
+
fail(generation, flight, error) {
|
|
313
|
+
this.settle(generation, flight, { status: "failed", error });
|
|
314
|
+
}
|
|
315
|
+
/** Finish an exact failed credential write, then publish its failure. */
|
|
316
|
+
failMutation(generation, flight, error) {
|
|
317
|
+
this.finishMutation(generation, flight);
|
|
318
|
+
this.settle(generation, flight, { status: "failed", error });
|
|
319
|
+
}
|
|
320
|
+
/** Force reauthorization fences and wakes every waiter on the retired epoch. */
|
|
321
|
+
retire(generation) {
|
|
322
|
+
if (this.pendingMutations.delete(generation)) {
|
|
323
|
+
this.advanceStateRevision();
|
|
324
|
+
}
|
|
325
|
+
if (this.successfulRefresh?.generation === generation) {
|
|
326
|
+
this.successfulRefresh = undefined;
|
|
327
|
+
this.advanceStateRevision();
|
|
328
|
+
}
|
|
329
|
+
const flight = this.flights.get(generation);
|
|
330
|
+
if (!flight)
|
|
331
|
+
return;
|
|
332
|
+
this.settle(generation, flight, { status: "retired" });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
29
335
|
function storedOAuthValue(value) {
|
|
30
336
|
if (!value || typeof value !== "object")
|
|
31
337
|
return false;
|
|
@@ -75,16 +381,21 @@ export class KvOAuthProvider {
|
|
|
75
381
|
connectorId;
|
|
76
382
|
storage;
|
|
77
383
|
redirectUri;
|
|
384
|
+
refreshCoordinator;
|
|
78
385
|
/**
|
|
79
386
|
* The reset generation this provider's flow started under. Every OAuth value
|
|
80
387
|
* it writes carries this epoch, so a late write can land after a reset without
|
|
81
388
|
* becoming readable under the new generation.
|
|
82
389
|
*/
|
|
83
390
|
capturedGeneration = null;
|
|
84
|
-
|
|
391
|
+
refreshFlight;
|
|
392
|
+
/** Tokens this request's issuer-aware auth flow decided to refresh. */
|
|
393
|
+
refreshBasis;
|
|
394
|
+
constructor(connectorId, storage, redirectUri, refreshCoordinator) {
|
|
85
395
|
this.connectorId = connectorId;
|
|
86
396
|
this.storage = storage;
|
|
87
397
|
this.redirectUri = redirectUri;
|
|
398
|
+
this.refreshCoordinator = refreshCoordinator;
|
|
88
399
|
}
|
|
89
400
|
/**
|
|
90
401
|
* Stamp the force-reauth generation the current connect flow started under.
|
|
@@ -94,6 +405,43 @@ export class KvOAuthProvider {
|
|
|
94
405
|
captureGeneration(gen) {
|
|
95
406
|
this.capturedGeneration = gen;
|
|
96
407
|
}
|
|
408
|
+
/** The generation captured for this flow, before a concurrent reset. */
|
|
409
|
+
async flowGeneration() {
|
|
410
|
+
return this.capturedGeneration ?? this.generation();
|
|
411
|
+
}
|
|
412
|
+
/** @internal Record the refresh attempt this provider owns. */
|
|
413
|
+
captureRefreshFlight(generation, flight) {
|
|
414
|
+
this.refreshFlight = { generation, flight };
|
|
415
|
+
}
|
|
416
|
+
succeedRefreshFlight() {
|
|
417
|
+
const owned = this.refreshFlight;
|
|
418
|
+
this.refreshFlight = undefined;
|
|
419
|
+
if (owned) {
|
|
420
|
+
this.refreshCoordinator?.succeedMutation(owned.generation, owned.flight);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
failRefreshFlight(error, mutationFinished = false) {
|
|
424
|
+
const owned = this.refreshFlight;
|
|
425
|
+
this.refreshFlight = undefined;
|
|
426
|
+
if (owned) {
|
|
427
|
+
if (mutationFinished) {
|
|
428
|
+
this.refreshCoordinator?.failMutation(owned.generation, owned.flight, error);
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
this.refreshCoordinator?.fail(owned.generation, owned.flight, error);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
/** True when another request saved a refresh result after this flow's read. */
|
|
436
|
+
refreshBasisChanged(current, generation) {
|
|
437
|
+
const basis = this.refreshBasis;
|
|
438
|
+
return Boolean(basis &&
|
|
439
|
+
basis.generation === generation &&
|
|
440
|
+
(basis.accessToken !== current.access_token ||
|
|
441
|
+
basis.refreshToken !== current.refresh_token ||
|
|
442
|
+
basis.successIdentity !==
|
|
443
|
+
this.refreshCoordinator?.successfulRefreshIdentity(generation)));
|
|
444
|
+
}
|
|
97
445
|
/**
|
|
98
446
|
* The epoch this provider writes under. Direct unit/custom use lazily captures
|
|
99
447
|
* the current generation; connector-driven connect and callback paths stamp it
|
|
@@ -274,10 +622,32 @@ export class KvOAuthProvider {
|
|
|
274
622
|
await this.writeValue("oauth:client", info, (value) => JSON.stringify(value), ctx?.issuer);
|
|
275
623
|
}
|
|
276
624
|
async tokens(ctx) {
|
|
277
|
-
|
|
625
|
+
const refreshGeneration = ctx ? await this.flowGeneration() : undefined;
|
|
626
|
+
const successIdentity = refreshGeneration !== undefined
|
|
627
|
+
? this.refreshCoordinator?.successfulRefreshIdentity(refreshGeneration)
|
|
628
|
+
: undefined;
|
|
629
|
+
const tokens = await this.readIssuerBoundValue("oauth:tokens", (raw) => JSON.parse(raw), (value) => JSON.stringify(value), ctx);
|
|
630
|
+
if (ctx && tokens && refreshGeneration !== undefined) {
|
|
631
|
+
this.refreshBasis = {
|
|
632
|
+
accessToken: tokens.access_token,
|
|
633
|
+
generation: refreshGeneration,
|
|
634
|
+
...(tokens.refresh_token !== undefined
|
|
635
|
+
? { refreshToken: tokens.refresh_token }
|
|
636
|
+
: {}),
|
|
637
|
+
...(successIdentity !== undefined ? { successIdentity } : {}),
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
return tokens;
|
|
278
641
|
}
|
|
279
642
|
async saveTokens(tokens, ctx) {
|
|
280
|
-
|
|
643
|
+
try {
|
|
644
|
+
await this.writeValue("oauth:tokens", tokens, (value) => JSON.stringify(value), ctx?.issuer);
|
|
645
|
+
this.succeedRefreshFlight();
|
|
646
|
+
}
|
|
647
|
+
catch (error) {
|
|
648
|
+
this.failRefreshFlight(error, true);
|
|
649
|
+
throw error;
|
|
650
|
+
}
|
|
281
651
|
}
|
|
282
652
|
/**
|
|
283
653
|
* OAuth `state`. The SDK calls this (when present) and appends the value to
|
|
@@ -317,7 +687,14 @@ export class KvOAuthProvider {
|
|
|
317
687
|
return stored.value;
|
|
318
688
|
}
|
|
319
689
|
async redirectToAuthorization(authorizationUrl) {
|
|
320
|
-
|
|
690
|
+
try {
|
|
691
|
+
await this.writeValue("oauth:pending", authorizationUrl.toString(), (raw) => raw);
|
|
692
|
+
this.failRefreshFlight(new Error("OAuth refresh required reauthorization before tokens were saved."));
|
|
693
|
+
}
|
|
694
|
+
catch (error) {
|
|
695
|
+
this.failRefreshFlight(error);
|
|
696
|
+
throw error;
|
|
697
|
+
}
|
|
321
698
|
}
|
|
322
699
|
/** The stored authorization URL, if a flow is pending. */
|
|
323
700
|
async pendingAuthorizationUrl() {
|
|
@@ -385,6 +762,7 @@ export class KvOAuthProvider {
|
|
|
385
762
|
// reset's epoch after their cleanup finishes out of order.
|
|
386
763
|
try {
|
|
387
764
|
await this.storage.set("oauth:generation", active);
|
|
765
|
+
this.refreshCoordinator?.retire(previous);
|
|
388
766
|
}
|
|
389
767
|
catch (error) {
|
|
390
768
|
try {
|
|
@@ -414,23 +792,33 @@ export class KvOAuthProvider {
|
|
|
414
792
|
throw firstError;
|
|
415
793
|
}
|
|
416
794
|
async invalidateCredentials(scope) {
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
await this.
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
795
|
+
const endsRefresh = scope === "all" || scope === "tokens";
|
|
796
|
+
try {
|
|
797
|
+
const generation = await this.writeGeneration();
|
|
798
|
+
if (scope === "all") {
|
|
799
|
+
await this.deleteAll([
|
|
800
|
+
oauthValueStorageKey("oauth:client", generation),
|
|
801
|
+
oauthValueStorageKey("oauth:tokens", generation),
|
|
802
|
+
oauthValueStorageKey("oauth:verifier", generation),
|
|
803
|
+
]);
|
|
804
|
+
}
|
|
805
|
+
else if (scope === "client") {
|
|
806
|
+
await this.storage.delete(oauthValueStorageKey("oauth:client", generation));
|
|
807
|
+
}
|
|
808
|
+
else if (scope === "tokens") {
|
|
809
|
+
await this.storage.delete(oauthValueStorageKey("oauth:tokens", generation));
|
|
810
|
+
}
|
|
811
|
+
else if (scope === "verifier") {
|
|
812
|
+
await this.storage.delete(oauthValueStorageKey("oauth:verifier", generation));
|
|
813
|
+
}
|
|
814
|
+
if (endsRefresh) {
|
|
815
|
+
this.failRefreshFlight(new Error("OAuth refresh invalidated credentials before tokens were saved."));
|
|
816
|
+
}
|
|
431
817
|
}
|
|
432
|
-
|
|
433
|
-
|
|
818
|
+
catch (error) {
|
|
819
|
+
if (endsRefresh)
|
|
820
|
+
this.failRefreshFlight(error);
|
|
821
|
+
throw error;
|
|
434
822
|
}
|
|
435
823
|
}
|
|
436
824
|
}
|