@zackbart/connecta 0.24.4 → 0.25.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/AGENTS.md +40 -10
- package/CHANGELOG.md +253 -0
- package/dist/advertised-schema.js +53 -0
- package/dist/auth/downstream-oauth.js +490 -106
- package/dist/call-admission.js +139 -113
- package/dist/catalog-service.js +141 -105
- package/dist/catalog.js +258 -33
- package/dist/concurrency.js +24 -23
- package/dist/connector-scope.js +6 -37
- package/dist/connectors/api.d.ts +2 -2
- package/dist/connectors/remote-mcp.d.ts +6 -5
- package/dist/connectors/remote-mcp.js +317 -241
- package/dist/credential-contract.d.ts +9 -0
- package/dist/credential-rules.js +2 -2
- package/dist/credentials.d.ts +6 -1
- package/dist/credentials.js +45 -3
- package/dist/execute.js +363 -257
- package/dist/executor-admission.js +67 -50
- package/dist/executors/quickjs-runtime.js +4 -2
- package/dist/executors/quickjs.js +251 -210
- package/dist/fix-prompt.js +37 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +51 -20
- package/dist/invocation.js +324 -285
- package/dist/meta-tools.js +206 -92
- package/dist/oauth-callback-outcome.js +94 -0
- package/dist/oauth-sealing.js +29 -0
- package/dist/operator-ui/fix-prompts.js +87 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +46 -1
- package/dist/operator-ui/setup-commands.js +53 -0
- package/dist/operator-ui/view.js +38 -2
- package/dist/program-source.js +205 -0
- package/dist/providers/notion.js +2 -2
- package/dist/providers/revenuecat.js +32 -5
- package/dist/providers/stripe.d.ts +3 -2
- package/dist/providers/vercel.js +1 -1
- package/dist/registry.d.ts +1 -1
- package/dist/registry.js +305 -260
- package/dist/routes/activity.js +120 -137
- package/dist/routes/credentials.js +174 -163
- package/dist/routes/mcp.js +129 -70
- package/dist/routes/oauth-management.js +60 -71
- package/dist/routes/oauth.js +44 -16
- package/dist/routes/operator.js +82 -0
- package/dist/routes/shared.d.ts +2 -0
- package/dist/routes/ui-data.js +210 -0
- package/dist/routes/ui.js +64 -32
- package/dist/runtime/admission.js +43 -0
- package/dist/runtime/call-admission.js +67 -0
- package/dist/runtime/connector-scope.js +72 -0
- package/dist/runtime/run.js +174 -0
- package/dist/runtime/services.js +130 -0
- package/dist/runtime/storage.js +66 -0
- package/dist/server.js +111 -89
- package/dist/skills.js +1 -1
- package/dist/storage/file.js +39 -2
- package/dist/storage/memory.js +28 -16
- package/dist/timeout.js +17 -47
- package/dist/types.d.ts +27 -7
- package/dist/ui.d.ts +16 -2
- package/dist/ui.js +50 -166
- package/dist/url-safety.js +157 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +184 -10
- package/documentation/auth.md +80 -0
- package/documentation/code-mode.md +69 -24
- package/documentation/meta-tools.md +164 -27
- package/ethos.md +44 -47
- package/examples/worker/README.md +51 -5
- package/examples/worker/src/d1-storage.ts +116 -0
- package/examples/worker/src/index.ts +6 -6
- package/examples/worker/wrangler.jsonc +16 -4
- package/package.json +11 -3
- package/templates/node/package.json +1 -1
- package/dist/auth/downstream-oauth.d.ts +0 -200
- package/dist/catalog-fingerprint.d.ts +0 -19
- package/dist/catalog-limits.d.ts +0 -9
- package/dist/catalog-service.d.ts +0 -263
- package/dist/catalog.d.ts +0 -87
- package/dist/concurrency.d.ts +0 -3
- package/dist/connectors/guarded-fetch.d.ts +0 -96
- package/dist/credential-rules.d.ts +0 -85
- package/dist/execute.d.ts +0 -154
- package/dist/executor-result.d.ts +0 -10
- package/dist/executors/quickjs-child.d.ts +0 -1
- package/dist/executors/quickjs-protocol.d.ts +0 -67
- package/dist/favicon.d.ts +0 -1
- package/dist/identity.d.ts +0 -4
- package/dist/invocation.d.ts +0 -74
- package/dist/mcp-result.d.ts +0 -8
- package/dist/meta-tools.d.ts +0 -129
- package/dist/operator-ui/generated.d.ts +0 -2
- package/dist/operator-ui/view.d.ts +0 -188
- package/dist/result-shapes.d.ts +0 -13
- package/dist/retired-toolkits.d.ts +0 -2
- package/dist/routes/activity.d.ts +0 -2
- package/dist/routes/credentials.d.ts +0 -2
- package/dist/routes/mcp.d.ts +0 -10
- package/dist/routes/oauth-management.d.ts +0 -2
- package/dist/routes/oauth.d.ts +0 -2
- package/dist/routes/ui.d.ts +0 -2
- package/dist/server.d.ts +0 -9
- package/dist/skills.d.ts +0 -44
- package/dist/timeout.d.ts +0 -21
- package/dist/tool-safety.d.ts +0 -9
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { UnauthorizedError } from "@modelcontextprotocol/client";
|
|
2
|
+
import { Deferred, Effect } from "effect";
|
|
3
|
+
import { retryAfterMs } from "../connectors/guarded-fetch.js";
|
|
4
|
+
import { ConnectorCallError } from "../errors.js";
|
|
5
|
+
import { detach, fromSignal, runEdge } from "../runtime/run.js";
|
|
2
6
|
/** A 256-bit random opaque value, hex-encoded — used for the OAuth `state`. */
|
|
3
7
|
function randomState() {
|
|
4
8
|
const bytes = new Uint8Array(32);
|
|
@@ -27,6 +31,19 @@ const OAUTH_VALUE_KEYS = [
|
|
|
27
31
|
"oauth:state",
|
|
28
32
|
"oauth:discovery",
|
|
29
33
|
];
|
|
34
|
+
/**
|
|
35
|
+
* The values that are credentials: tokens, a registered client (which may
|
|
36
|
+
* carry a secret), and the PKCE verifier. With a sealing vault they are
|
|
37
|
+
* ciphertext at rest. Flow bookkeeping — state, pending URL, discovery, and
|
|
38
|
+
* the generation — stays plaintext: the callback route reads state directly,
|
|
39
|
+
* and none of it authenticates anything on its own.
|
|
40
|
+
*/
|
|
41
|
+
const SEALED_OAUTH_KEYS = new Set([
|
|
42
|
+
"oauth:client",
|
|
43
|
+
"oauth:tokens",
|
|
44
|
+
"oauth:verifier",
|
|
45
|
+
]);
|
|
46
|
+
const SEALED_VALUE_VERSION = 1;
|
|
30
47
|
const MAX_CLEANUP_BACKLOG = 1_000;
|
|
31
48
|
function isRefreshTokenRequest(init) {
|
|
32
49
|
if ((init?.method ?? "GET").toUpperCase() !== "POST")
|
|
@@ -101,15 +118,103 @@ async function readRefreshResponse(response) {
|
|
|
101
118
|
reader.releaseLock();
|
|
102
119
|
}
|
|
103
120
|
}
|
|
121
|
+
/** Client-error statuses that describe the moment, not the grant. */
|
|
122
|
+
const TRANSIENT_CLIENT_STATUSES = new Set([408, 425, 429]);
|
|
123
|
+
/**
|
|
124
|
+
* OAuth `error` codes on which the SDK's own `auth()` drops state and starts
|
|
125
|
+
* over. Handing the SDK one of these is how a dead grant reaches consent on an
|
|
126
|
+
* explicit authorization, and `auth_required` on a passive call.
|
|
127
|
+
*/
|
|
128
|
+
const SDK_RESTARTING_CODES = new Set([
|
|
129
|
+
"invalid_grant",
|
|
130
|
+
"invalid_client",
|
|
131
|
+
"unauthorized_client",
|
|
132
|
+
]);
|
|
133
|
+
function oauthErrorCode(body) {
|
|
134
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
135
|
+
return undefined;
|
|
136
|
+
const code = body.error;
|
|
137
|
+
return typeof code === "string" ? code : undefined;
|
|
138
|
+
}
|
|
139
|
+
function transientFailure(reason, response, cause) {
|
|
140
|
+
const wait = response ? retryAfterMs(response.headers) : undefined;
|
|
141
|
+
return {
|
|
142
|
+
kind: "transient",
|
|
143
|
+
reason,
|
|
144
|
+
...(response ? { status: response.status } : {}),
|
|
145
|
+
...(wait !== undefined ? { retryAfterMs: wait } : {}),
|
|
146
|
+
...(cause !== undefined ? { cause } : {}),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/** What the SDK sees for a refused grant it would otherwise misread. */
|
|
150
|
+
function refusedGrantResponse(original) {
|
|
151
|
+
void original.body?.cancel().catch(() => { });
|
|
152
|
+
return Response.json({
|
|
153
|
+
error: "invalid_grant",
|
|
154
|
+
error_description: "The authorization server refused the refresh token.",
|
|
155
|
+
}, { status: 400 });
|
|
156
|
+
}
|
|
157
|
+
/** What the SDK sees for an outage whose body names a restarting code. */
|
|
158
|
+
function outageResponse(original) {
|
|
159
|
+
void original.body?.cancel().catch(() => { });
|
|
160
|
+
const retryAfter = original.headers.get("retry-after");
|
|
161
|
+
return Response.json({
|
|
162
|
+
error: "server_error",
|
|
163
|
+
error_description: "The authorization server is temporarily unavailable.",
|
|
164
|
+
}, {
|
|
165
|
+
status: original.status,
|
|
166
|
+
...(retryAfter !== null ? { headers: { "retry-after": retryAfter } } : {}),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
104
169
|
/**
|
|
105
170
|
* Classify the token endpoint's answer once, on a clone, and keep the parsed
|
|
106
171
|
* tokens: a valid response has already consumed the rotating refresh token, so
|
|
107
172
|
* the host persists it (`coordinatedFetch`) rather than trusting the SDK's
|
|
108
173
|
* later `saveTokens` to arrive on a request that may already be cancelled.
|
|
174
|
+
*
|
|
175
|
+
* A failure also decides what the SDK gets to parse. Pinned against
|
|
176
|
+
* `@modelcontextprotocol/client` 2.0.0, `dist/index.mjs`: `authInternal()`
|
|
177
|
+
* swallows a refresh failure that is not an `OAuthError`, or is `server_error`,
|
|
178
|
+
* and falls through to `startAuthorization` (`state()`, `saveCodeVerifier()`,
|
|
179
|
+
* `redirectToAuthorization()`); it rethrows every other `OAuthError`, and
|
|
180
|
+
* `auth()` retries once after `invalidateCredentials()` only for
|
|
181
|
+
* `invalid_grant` (tokens) and `invalid_client`/`unauthorized_client` (client
|
|
182
|
+
* and tokens). `executeTokenRequest()` turns a 2xx body carrying `error` into
|
|
183
|
+
* an `OAuthError` with that code. So a dead grant the SDK would rethrow
|
|
184
|
+
* (`bad_refresh_token`, `invalid_scope`, …) reaches it as `invalid_grant`, and
|
|
185
|
+
* an outage whose body names any code but `server_error` reaches it as
|
|
186
|
+
* `server_error` — a 5xx `invalid_grant` must not make the SDK drop a grant
|
|
187
|
+
* nobody refused. The provider hooks below finish the job. If the SDK moves,
|
|
188
|
+
* the tests under "remoteMcp() dead and transient refresh grants" fail first.
|
|
109
189
|
*/
|
|
110
190
|
async function refreshResponseOutcome(response) {
|
|
111
191
|
if (!response.ok) {
|
|
112
|
-
|
|
192
|
+
let code;
|
|
193
|
+
try {
|
|
194
|
+
code = oauthErrorCode(await readRefreshResponse(response));
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// Not JSON, or too large to be an OAuth error: the status decides.
|
|
198
|
+
}
|
|
199
|
+
const failure = new Error(`OAuth refresh failed with HTTP ${response.status}.`);
|
|
200
|
+
if (response.status >= 400 &&
|
|
201
|
+
response.status < 500 &&
|
|
202
|
+
!TRANSIENT_CLIENT_STATUSES.has(response.status)) {
|
|
203
|
+
return {
|
|
204
|
+
failure,
|
|
205
|
+
verdict: { kind: "dead" },
|
|
206
|
+
forSdk: code !== undefined && SDK_RESTARTING_CODES.has(code)
|
|
207
|
+
? response
|
|
208
|
+
: refusedGrantResponse(response),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
failure,
|
|
213
|
+
verdict: transientFailure(`answered HTTP ${response.status}`, response),
|
|
214
|
+
forSdk: code !== undefined && code !== "server_error"
|
|
215
|
+
? outageResponse(response)
|
|
216
|
+
: response,
|
|
217
|
+
};
|
|
113
218
|
}
|
|
114
219
|
let parsed;
|
|
115
220
|
try {
|
|
@@ -118,11 +223,26 @@ async function refreshResponseOutcome(response) {
|
|
|
118
223
|
catch (error) {
|
|
119
224
|
if (error instanceof OversizedRefreshResponse)
|
|
120
225
|
throw error;
|
|
121
|
-
return {
|
|
226
|
+
return {
|
|
227
|
+
failure: new Error("OAuth refresh response did not contain JSON tokens."),
|
|
228
|
+
verdict: transientFailure("answered without a token response", response),
|
|
229
|
+
forSdk: response,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
if (sdkAcceptsOAuthTokens(parsed))
|
|
233
|
+
return { tokens: parsed };
|
|
234
|
+
if (oauthErrorCode(parsed) !== undefined) {
|
|
235
|
+
return {
|
|
236
|
+
failure: new Error("OAuth refresh was refused by the authorization server."),
|
|
237
|
+
verdict: { kind: "dead" },
|
|
238
|
+
forSdk: refusedGrantResponse(response),
|
|
239
|
+
};
|
|
122
240
|
}
|
|
123
|
-
return
|
|
124
|
-
|
|
125
|
-
:
|
|
241
|
+
return {
|
|
242
|
+
failure: new Error("OAuth refresh response did not match the token schema."),
|
|
243
|
+
verdict: transientFailure("answered without a token response", response),
|
|
244
|
+
forSdk: response,
|
|
245
|
+
};
|
|
126
246
|
}
|
|
127
247
|
function refreshMutationPendingResponse() {
|
|
128
248
|
return Response.json({
|
|
@@ -131,42 +251,62 @@ function refreshMutationPendingResponse() {
|
|
|
131
251
|
}, { status: 503 });
|
|
132
252
|
}
|
|
133
253
|
function aborted(signal) {
|
|
134
|
-
return (signal
|
|
254
|
+
return (signal?.reason ?? new DOMException("This operation was aborted", "AbortError"));
|
|
135
255
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
256
|
+
/**
|
|
257
|
+
* Call `onAbort` once when `signal` aborts, at once if it already has, until
|
|
258
|
+
* the returned function stops watching. The owner's abort needs a listener
|
|
259
|
+
* rather than an interrupt once the SDK holds the answer: the SDK saves the
|
|
260
|
+
* tokens after `coordinatedFetch` has returned, when no fiber of the owner's
|
|
261
|
+
* is left to interrupt.
|
|
262
|
+
*/
|
|
263
|
+
function whenAborted(signal, onAbort) {
|
|
264
|
+
let watching = true;
|
|
265
|
+
const stop = () => {
|
|
266
|
+
if (!watching)
|
|
267
|
+
return;
|
|
268
|
+
watching = false;
|
|
269
|
+
signal.removeEventListener("abort", listener);
|
|
270
|
+
};
|
|
271
|
+
const listener = () => {
|
|
272
|
+
stop();
|
|
273
|
+
onAbort();
|
|
274
|
+
};
|
|
275
|
+
signal.addEventListener("abort", listener, { once: true });
|
|
276
|
+
if (signal.aborted)
|
|
277
|
+
listener();
|
|
278
|
+
return stop;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* The outcome of a flight another request owns, failures included. Rejects
|
|
282
|
+
* only with this caller's own abort.
|
|
283
|
+
*
|
|
284
|
+
* This wait is a Promise edge of its own, and the caller's storage reads stay
|
|
285
|
+
* outside it, on purpose. The owner settles the Deferred from its own request,
|
|
286
|
+
* and Deferred resumes a waiting fiber synchronously inside that call — on
|
|
287
|
+
* Workers, inside the owner's I/O context. Resolving this caller's promise
|
|
288
|
+
* instead hands the rest of its work back to its own request, as workerd does
|
|
289
|
+
* for any promise resolved from another one.
|
|
290
|
+
*/
|
|
291
|
+
function waitForRefreshFlight(flight, signal) {
|
|
292
|
+
const settled = Deferred.await(flight.outcome);
|
|
293
|
+
// The abort goes first, so a caller that has already left never joins.
|
|
294
|
+
return runEdge(signal
|
|
295
|
+
? Effect.raceAllFirst([
|
|
296
|
+
fromSignal(signal),
|
|
297
|
+
settled,
|
|
298
|
+
])
|
|
299
|
+
: settled);
|
|
162
300
|
}
|
|
163
301
|
/**
|
|
164
302
|
* Share one rotating-token redemption within one connector runtime and OAuth
|
|
165
|
-
* generation. The first request still owns the real fetch and response
|
|
166
|
-
* abort
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
303
|
+
* generation. The first request still owns the real fetch and response, and
|
|
304
|
+
* its abort ends the flight at any point until the flight settles: first by
|
|
305
|
+
* interrupting the redemption, then, once the SDK holds the answer, through
|
|
306
|
+
* one bounded listener, so a cancellation cannot strand waiters during token
|
|
307
|
+
* storage. Followers wait on the flight's Deferred for that provider to save
|
|
308
|
+
* tokens, then re-read storage. The map never retains a token response or
|
|
309
|
+
* transport.
|
|
170
310
|
*
|
|
171
311
|
* This is intentionally runtime-local. KVStorage has no atomic coordination
|
|
172
312
|
* operation, so a second isolate can still race the same refresh token.
|
|
@@ -203,7 +343,8 @@ export class OAuthRefreshCoordinator {
|
|
|
203
343
|
this.flights.delete(generation);
|
|
204
344
|
this.advanceStateRevision();
|
|
205
345
|
flight.stopObservingOwnerAbort();
|
|
206
|
-
|
|
346
|
+
// Last, because joined fibers resume inside this call.
|
|
347
|
+
Deferred.doneUnsafe(flight.outcome, Effect.succeed(outcome));
|
|
207
348
|
}
|
|
208
349
|
markMutationPending(generation, flight) {
|
|
209
350
|
if (this.flights.get(generation) !== flight)
|
|
@@ -233,6 +374,8 @@ export class OAuthRefreshCoordinator {
|
|
|
233
374
|
// adopted inner promise as an unhandled rejection.
|
|
234
375
|
if (!isRefreshTokenRequest(init))
|
|
235
376
|
return await baseFetch(input, init);
|
|
377
|
+
// A verdict belongs to one refresh; never let an older one decide this.
|
|
378
|
+
provider.recordRefreshFailure(undefined);
|
|
236
379
|
const generation = await provider.flowGeneration();
|
|
237
380
|
const requestedRefreshToken = init?.body instanceof URLSearchParams
|
|
238
381
|
? init.body.get("refresh_token")
|
|
@@ -289,6 +432,12 @@ export class OAuthRefreshCoordinator {
|
|
|
289
432
|
}
|
|
290
433
|
if (existing) {
|
|
291
434
|
const outcome = await waitForRefreshFlight(existing, requestSignal);
|
|
435
|
+
if (outcome.status === "failed") {
|
|
436
|
+
// A joined caller inherits the owner's verdict, so every scope on
|
|
437
|
+
// one flight ends the same way: auth_required or a retryable outage.
|
|
438
|
+
provider.recordRefreshFailure(outcome.verdict);
|
|
439
|
+
throw outcome.error;
|
|
440
|
+
}
|
|
292
441
|
if (outcome.status === "refreshed") {
|
|
293
442
|
const activeGeneration = await provider.generation();
|
|
294
443
|
const refreshedTokens = activeGeneration === generation
|
|
@@ -300,12 +449,12 @@ export class OAuthRefreshCoordinator {
|
|
|
300
449
|
}
|
|
301
450
|
continue;
|
|
302
451
|
}
|
|
303
|
-
|
|
452
|
+
// An owner that has already left never publishes a flight, and never
|
|
453
|
+
// reaches the token endpoint.
|
|
454
|
+
if (requestSignal?.aborted)
|
|
455
|
+
throw aborted(requestSignal);
|
|
304
456
|
const flight = {
|
|
305
|
-
|
|
306
|
-
release = resolve;
|
|
307
|
-
}),
|
|
308
|
-
release: (outcome) => release(outcome),
|
|
457
|
+
outcome: Deferred.makeUnsafe(),
|
|
309
458
|
stopObservingOwnerAbort: () => { },
|
|
310
459
|
mutationId: {},
|
|
311
460
|
writing: false,
|
|
@@ -314,60 +463,89 @@ export class OAuthRefreshCoordinator {
|
|
|
314
463
|
this.flights.set(generation, flight);
|
|
315
464
|
this.advanceStateRevision();
|
|
316
465
|
provider.captureRefreshFlight(generation, flight);
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
};
|
|
322
|
-
flight.stopObservingOwnerAbort = () => {
|
|
323
|
-
if (!observing)
|
|
324
|
-
return;
|
|
325
|
-
observing = false;
|
|
326
|
-
requestSignal.removeEventListener("abort", onOwnerAbort);
|
|
327
|
-
};
|
|
328
|
-
requestSignal.addEventListener("abort", onOwnerAbort, { once: true });
|
|
329
|
-
// Abort may land between flight publication and listener registration.
|
|
330
|
-
if (requestSignal.aborted)
|
|
331
|
-
onOwnerAbort();
|
|
332
|
-
}
|
|
333
|
-
try {
|
|
466
|
+
const currentRefreshToken = currentTokens?.refresh_token;
|
|
467
|
+
// The token request starts now, as a plain promise: an owner that
|
|
468
|
+
// stops waiting for its answer can still hand the answer on.
|
|
469
|
+
const answer = (async () => {
|
|
334
470
|
const response = await baseFetch(input, requestSignal ? { ...init, signal: requestSignal } : init);
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
471
|
+
return { response, outcome: await refreshResponseOutcome(response) };
|
|
472
|
+
})();
|
|
473
|
+
// Record what the token endpoint said, then settle the flight or
|
|
474
|
+
// hand it to the SDK's saveTokens.
|
|
475
|
+
const commit = ({ response, outcome, }) => {
|
|
339
476
|
if (outcome.failure) {
|
|
340
|
-
|
|
341
|
-
|
|
477
|
+
// Failed responses never reach a successful saveTokens callback.
|
|
478
|
+
// Give current waiters a bounded failure now, carrying the
|
|
479
|
+
// verdict, and hand the SDK an answer it classifies the same way.
|
|
480
|
+
provider.recordRefreshFailure(outcome.verdict);
|
|
481
|
+
// Drop a refused grant while the flight still stands: a caller
|
|
482
|
+
// arriving meanwhile joins it instead of redeeming the dead token
|
|
483
|
+
// again, and no later request or isolate can replay it.
|
|
484
|
+
const discard = outcome.verdict.kind === "dead"
|
|
485
|
+
? Effect.promise(() => provider.discardRefusedGrant(requestedRefreshToken, generation))
|
|
486
|
+
: Effect.void;
|
|
487
|
+
return discard.pipe(Effect.map(() => {
|
|
488
|
+
this.fail(generation, flight, outcome.failure, outcome.verdict);
|
|
489
|
+
return outcome.forSdk;
|
|
490
|
+
}));
|
|
342
491
|
}
|
|
343
492
|
// A valid response means the authorization server has consumed the
|
|
344
493
|
// rotating refresh token. The SDK's saveTokens normally persists it;
|
|
345
494
|
// keep this copy so `fail` can persist it instead if that callback
|
|
346
495
|
// never comes (the SDK merges the old refresh token the same way).
|
|
347
|
-
const rotatedRefreshToken = outcome.tokens.refresh_token ??
|
|
348
|
-
|
|
496
|
+
const rotatedRefreshToken = outcome.tokens.refresh_token ?? currentRefreshToken;
|
|
497
|
+
const accepted = {
|
|
349
498
|
...outcome.tokens,
|
|
350
499
|
...(rotatedRefreshToken !== undefined
|
|
351
500
|
? { refresh_token: rotatedRefreshToken }
|
|
352
501
|
: {}),
|
|
353
502
|
};
|
|
503
|
+
flight.acceptedTokens = accepted;
|
|
354
504
|
if (!this.markMutationPending(generation, flight)) {
|
|
355
505
|
// Already settled (the owner was cancelled first). Still write the
|
|
356
506
|
// rotation: losing it would leave a dead credential.
|
|
357
|
-
|
|
358
|
-
|
|
507
|
+
detach(Effect.promise(() => flight.persist(accepted)));
|
|
508
|
+
return Effect.fail(new Error("OAuth refresh ended before tokens could be saved."));
|
|
359
509
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
510
|
+
// The SDK holds the answer from here, after this fiber is gone, so
|
|
511
|
+
// a listener watches for the owner leaving before it saves. Then
|
|
512
|
+
// `fail` persists the rotation instead.
|
|
513
|
+
if (requestSignal) {
|
|
514
|
+
flight.stopObservingOwnerAbort = whenAborted(requestSignal, () => this.fail(generation, flight, aborted(requestSignal)));
|
|
364
515
|
}
|
|
365
|
-
return response;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
516
|
+
return Effect.succeed(response);
|
|
517
|
+
};
|
|
518
|
+
// The owner's redemption. Its abort interrupts the wait for the token
|
|
519
|
+
// endpoint and nothing after it: an answer, once it exists, is
|
|
520
|
+
// committed before anyone is released, so no caller is let go to
|
|
521
|
+
// redeem a token the server has already refused or spent. An answer
|
|
522
|
+
// that arrives after the owner left is committed in the background,
|
|
523
|
+
// as the foreground would have.
|
|
524
|
+
const redemption = Effect.uninterruptibleMask((restore) => restore(Effect.tryPromise({ try: () => answer, catch: (error) => error })).pipe(Effect.onInterrupt(() => Effect.sync(() => {
|
|
525
|
+
this.fail(generation, flight, aborted(requestSignal));
|
|
526
|
+
detach(Effect.promise(() => answer).pipe(Effect.flatMap(commit)));
|
|
527
|
+
})), Effect.catch((error) => {
|
|
528
|
+
// No answer at all — the network, or a body too large to be
|
|
529
|
+
// one — is an outage. A refusal connecta itself raised (a
|
|
530
|
+
// redirect the policy forbids) and this owner's own
|
|
531
|
+
// cancellation are not.
|
|
532
|
+
const verdict = !requestSignal?.aborted &&
|
|
533
|
+
!(error instanceof ConnectorCallError && !error.retryable)
|
|
534
|
+
? transientFailure(error instanceof OversizedRefreshResponse
|
|
535
|
+
? "answered with an oversized response"
|
|
536
|
+
: "could not be reached", undefined, error)
|
|
537
|
+
: undefined;
|
|
538
|
+
if (verdict)
|
|
539
|
+
provider.recordRefreshFailure(verdict);
|
|
540
|
+
this.fail(generation, flight, error, verdict);
|
|
541
|
+
return Effect.fail(error);
|
|
542
|
+
}), Effect.flatMap(commit)));
|
|
543
|
+
return await runEdge(requestSignal
|
|
544
|
+
? Effect.raceAllFirst([
|
|
545
|
+
redemption,
|
|
546
|
+
fromSignal(requestSignal),
|
|
547
|
+
])
|
|
548
|
+
: redemption);
|
|
371
549
|
}
|
|
372
550
|
return refreshMutationPendingResponse();
|
|
373
551
|
};
|
|
@@ -387,8 +565,12 @@ export class OAuthRefreshCoordinator {
|
|
|
387
565
|
}
|
|
388
566
|
this.settle(generation, flight, { status: "refreshed" });
|
|
389
567
|
}
|
|
390
|
-
/**
|
|
391
|
-
|
|
568
|
+
/**
|
|
569
|
+
* Give joined callers a fetch/flow failure, without rejecting the gate. A
|
|
570
|
+
* `verdict` travels with it only when the token endpoint decided the
|
|
571
|
+
* failure; an abort or a write failure carries none.
|
|
572
|
+
*/
|
|
573
|
+
fail(generation, flight, error, verdict) {
|
|
392
574
|
// Only saveTokens owns a live credential write. If it has started, its
|
|
393
575
|
// success/failure callback clears the marker even after owner cancellation.
|
|
394
576
|
if (flight.writing) {
|
|
@@ -404,23 +586,29 @@ export class OAuthRefreshCoordinator {
|
|
|
404
586
|
// provider still holds it; otherwise settle here once the write ends.
|
|
405
587
|
const tokens = flight.acceptedTokens;
|
|
406
588
|
if (tokens !== undefined &&
|
|
407
|
-
flight.persist !== undefined &&
|
|
408
589
|
this.pendingMutations.get(generation) === flight.mutationId) {
|
|
409
590
|
flight.writing = true;
|
|
410
|
-
|
|
411
|
-
if (this.finishMutation(generation, flight)) {
|
|
412
|
-
this.settle(generation, flight, { status: "refreshed" });
|
|
413
|
-
}
|
|
414
|
-
}, (writeError) => {
|
|
591
|
+
const written = (outcome) => {
|
|
415
592
|
if (this.finishMutation(generation, flight)) {
|
|
416
|
-
this.settle(generation, flight,
|
|
593
|
+
this.settle(generation, flight, outcome);
|
|
417
594
|
}
|
|
418
|
-
}
|
|
595
|
+
};
|
|
596
|
+
detach(Effect.tryPromise({
|
|
597
|
+
try: () => flight.persist(tokens),
|
|
598
|
+
catch: (writeError) => writeError,
|
|
599
|
+
}).pipe(Effect.match({
|
|
600
|
+
onSuccess: () => written({ status: "refreshed" }),
|
|
601
|
+
onFailure: (error) => written({ status: "failed", error }),
|
|
602
|
+
})));
|
|
419
603
|
return;
|
|
420
604
|
}
|
|
421
605
|
// A response alone is not a write and must never strand the generation.
|
|
422
606
|
this.finishMutation(generation, flight);
|
|
423
|
-
this.settle(generation, flight, {
|
|
607
|
+
this.settle(generation, flight, {
|
|
608
|
+
status: "failed",
|
|
609
|
+
error,
|
|
610
|
+
...(verdict !== undefined ? { verdict } : {}),
|
|
611
|
+
});
|
|
424
612
|
}
|
|
425
613
|
/** Finish an exact failed credential write, then publish its failure. */
|
|
426
614
|
failMutation(generation, flight, error) {
|
|
@@ -459,6 +647,13 @@ function legacyStoredOAuthValue(value) {
|
|
|
459
647
|
typeof candidate.generation === "string" &&
|
|
460
648
|
"value" in candidate);
|
|
461
649
|
}
|
|
650
|
+
function sealedOAuthValue(value) {
|
|
651
|
+
if (!value || typeof value !== "object")
|
|
652
|
+
return false;
|
|
653
|
+
const candidate = value;
|
|
654
|
+
return (candidate.connectaOAuthSealed === SEALED_VALUE_VERSION &&
|
|
655
|
+
typeof candidate.sealed === "string");
|
|
656
|
+
}
|
|
462
657
|
function isModernGeneration(generation) {
|
|
463
658
|
return (generation.startsWith(ACTIVE_GENERATION_PREFIX) ||
|
|
464
659
|
generation.startsWith(RESETTING_GENERATION_PREFIX) ||
|
|
@@ -493,6 +688,7 @@ export class KvOAuthProvider {
|
|
|
493
688
|
redirectUri;
|
|
494
689
|
refreshCoordinator;
|
|
495
690
|
allowAuthorization;
|
|
691
|
+
sealer;
|
|
496
692
|
/**
|
|
497
693
|
* The reset generation this provider's flow started under. Every OAuth value
|
|
498
694
|
* it writes carries this epoch, so a late write can land after a reset without
|
|
@@ -500,14 +696,19 @@ export class KvOAuthProvider {
|
|
|
500
696
|
*/
|
|
501
697
|
capturedGeneration = null;
|
|
502
698
|
refreshFlight;
|
|
699
|
+
/** How the last refresh this provider took part in failed, if it did. */
|
|
700
|
+
refreshFailure;
|
|
503
701
|
/** Tokens this request's issuer-aware auth flow decided to refresh. */
|
|
504
702
|
refreshBasis;
|
|
505
|
-
constructor(connectorId, storage, redirectUri, refreshCoordinator, allowAuthorization = true
|
|
703
|
+
constructor(connectorId, storage, redirectUri, refreshCoordinator, allowAuthorization = true,
|
|
704
|
+
/** Present when the deployment's vault can seal; credentials are then ciphertext. */
|
|
705
|
+
sealer) {
|
|
506
706
|
this.connectorId = connectorId;
|
|
507
707
|
this.storage = storage;
|
|
508
708
|
this.redirectUri = redirectUri;
|
|
509
709
|
this.refreshCoordinator = refreshCoordinator;
|
|
510
710
|
this.allowAuthorization = allowAuthorization;
|
|
711
|
+
this.sealer = sealer;
|
|
511
712
|
}
|
|
512
713
|
/**
|
|
513
714
|
* Stamp the force-reauth generation the current connect flow started under.
|
|
@@ -525,6 +726,72 @@ export class KvOAuthProvider {
|
|
|
525
726
|
captureRefreshFlight(generation, flight) {
|
|
526
727
|
this.refreshFlight = { generation, flight };
|
|
527
728
|
}
|
|
729
|
+
/**
|
|
730
|
+
* @internal How the refresh this provider owned or joined failed, recorded by
|
|
731
|
+
* the coordinator; `undefined` clears it. The SDK learns nothing from this
|
|
732
|
+
* directly — the hooks it calls next (`state`, `saveCodeVerifier`,
|
|
733
|
+
* `redirectToAuthorization`, `invalidateCredentials`) read it.
|
|
734
|
+
*/
|
|
735
|
+
recordRefreshFailure(failure) {
|
|
736
|
+
this.refreshFailure = failure;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* @internal Delete the grant the authorization server just refused, but only
|
|
740
|
+
* while storage still holds exactly that refresh token in this generation: a
|
|
741
|
+
* consent that completed meanwhile must survive. Where the store has
|
|
742
|
+
* `compareAndSet`, the delete is conditional on the exact raw string read
|
|
743
|
+
* (ciphertext, when sealed), so a write landing in between wins. A store
|
|
744
|
+
* without it (Workers KV) gets the read followed by a plain delete, and a
|
|
745
|
+
* write landing between the two can still be lost — the same window the
|
|
746
|
+
* SDK's own invalidation has. Best effort: if storage fails, the next
|
|
747
|
+
* refresh is refused again and ends the same way.
|
|
748
|
+
*/
|
|
749
|
+
async discardRefusedGrant(refreshToken, generation) {
|
|
750
|
+
try {
|
|
751
|
+
const read = await this.readStoredValue("oauth:tokens", (raw) => JSON.parse(raw));
|
|
752
|
+
if (!read ||
|
|
753
|
+
read.stored.generation !== generation ||
|
|
754
|
+
read.stored.value.refresh_token !== refreshToken) {
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
const physicalKey = oauthValueStorageKey("oauth:tokens", generation);
|
|
758
|
+
if (this.storage.compareAndSet) {
|
|
759
|
+
await this.storage.compareAndSet(physicalKey, read.raw, null);
|
|
760
|
+
}
|
|
761
|
+
else {
|
|
762
|
+
await this.storage.delete(physicalKey);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
catch {
|
|
766
|
+
// See above: a refusal that could not be recorded recurs, it does not hide.
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Why a passive request cannot start consent. After a transient refresh
|
|
771
|
+
* failure that is a retryable outage, not an authorization problem: the SDK
|
|
772
|
+
* falls through to authorization on any refresh it could not parse, and
|
|
773
|
+
* answering that with `UnauthorizedError` would tell the agent a working
|
|
774
|
+
* grant needs consent.
|
|
775
|
+
*/
|
|
776
|
+
authorizationRefused() {
|
|
777
|
+
const failure = this.refreshFailure;
|
|
778
|
+
return failure?.kind === "transient"
|
|
779
|
+
? this.refreshOutage(failure)
|
|
780
|
+
: new UnauthorizedError("Authorization required. Use authorize_connector or Connect to start consent.");
|
|
781
|
+
}
|
|
782
|
+
refreshOutage(failure) {
|
|
783
|
+
const { cause } = failure;
|
|
784
|
+
return new ConnectorCallError(failure.status === 429 ? "rate_limited" : "unavailable", `Connector "${this.connectorId}" could not refresh its OAuth grant: the ` +
|
|
785
|
+
`authorization server ${failure.reason}. The grant is kept; retry later.`, {
|
|
786
|
+
...(cause !== undefined ? { cause } : {}),
|
|
787
|
+
...(failure.retryAfterMs !== undefined
|
|
788
|
+
? { retryAfterMs: failure.retryAfterMs }
|
|
789
|
+
: {}),
|
|
790
|
+
...(cause instanceof ConnectorCallError && cause.details
|
|
791
|
+
? { details: cause.details }
|
|
792
|
+
: {}),
|
|
793
|
+
});
|
|
794
|
+
}
|
|
528
795
|
failRefreshFlight(error) {
|
|
529
796
|
const owned = this.refreshFlight;
|
|
530
797
|
this.refreshFlight = undefined;
|
|
@@ -559,21 +826,46 @@ export class KvOAuthProvider {
|
|
|
559
826
|
*/
|
|
560
827
|
async writeValue(key, value, serializeLegacy, issuer) {
|
|
561
828
|
const generation = await this.writeGeneration();
|
|
829
|
+
await this.storeInGeneration(key, generation, () => {
|
|
830
|
+
const stored = {
|
|
831
|
+
connectaOAuthVersion: STORED_VALUE_VERSION,
|
|
832
|
+
generation,
|
|
833
|
+
...(issuer !== undefined ? { issuer } : {}),
|
|
834
|
+
value,
|
|
835
|
+
};
|
|
836
|
+
return isModernGeneration(generation) || issuer !== undefined
|
|
837
|
+
? JSON.stringify(stored)
|
|
838
|
+
: serializeLegacy(value);
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* The generation fence every write goes through: refuse unless `generation`
|
|
843
|
+
* is still the live, writable epoch, write (sealed when the key holds a
|
|
844
|
+
* credential and a sealer exists), then clean up if the epoch moved under
|
|
845
|
+
* the write. With `expected`, the write also requires the physical key to
|
|
846
|
+
* still hold exactly that raw value, so re-encoding cannot resurrect a value
|
|
847
|
+
* another request replaced in the meantime.
|
|
848
|
+
*/
|
|
849
|
+
async storeInGeneration(key, generation, serialize, expected) {
|
|
562
850
|
if (generation.startsWith(RESETTING_GENERATION_PREFIX) ||
|
|
563
851
|
generation.startsWith(DISCONNECTED_GENERATION_PREFIX) ||
|
|
564
852
|
(await this.generation()) !== generation) {
|
|
565
853
|
return;
|
|
566
854
|
}
|
|
567
|
-
const stored = {
|
|
568
|
-
connectaOAuthVersion: STORED_VALUE_VERSION,
|
|
569
|
-
generation,
|
|
570
|
-
...(issuer !== undefined ? { issuer } : {}),
|
|
571
|
-
value,
|
|
572
|
-
};
|
|
573
855
|
const physicalKey = oauthValueStorageKey(key, generation);
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
856
|
+
const plaintext = serialize();
|
|
857
|
+
const serialized = this.sealer && SEALED_OAUTH_KEYS.has(key)
|
|
858
|
+
? JSON.stringify({
|
|
859
|
+
connectaOAuthSealed: SEALED_VALUE_VERSION,
|
|
860
|
+
sealed: await this.sealer.seal(physicalKey, plaintext),
|
|
861
|
+
})
|
|
862
|
+
: plaintext;
|
|
863
|
+
if (expected === undefined) {
|
|
864
|
+
await this.storage.set(physicalKey, serialized);
|
|
865
|
+
}
|
|
866
|
+
else if (!(await this.replaceExactly(physicalKey, expected, serialized))) {
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
577
869
|
// If reset landed after the pre-write check and completed its cleanup
|
|
578
870
|
// before this set, remove the now-unreachable residue ourselves. The epoch
|
|
579
871
|
// key already provides correctness; this second check is physical hygiene.
|
|
@@ -595,21 +887,85 @@ export class KvOAuthProvider {
|
|
|
595
887
|
}
|
|
596
888
|
}
|
|
597
889
|
}
|
|
890
|
+
/**
|
|
891
|
+
* Compare-and-set where the store has it. A store without it gets a re-read
|
|
892
|
+
* immediately before the write: it narrows the race to one round trip, the
|
|
893
|
+
* same exposure that store already has for every other OAuth write.
|
|
894
|
+
*/
|
|
895
|
+
async replaceExactly(physicalKey, expected, next) {
|
|
896
|
+
if (this.storage.compareAndSet) {
|
|
897
|
+
return this.storage.compareAndSet(physicalKey, expected, next);
|
|
898
|
+
}
|
|
899
|
+
if ((await this.storage.get(physicalKey)) !== expected)
|
|
900
|
+
return false;
|
|
901
|
+
await this.storage.set(physicalKey, next);
|
|
902
|
+
return true;
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* Open a sealed credential. Anything that does not open — a tampered value,
|
|
906
|
+
* a rotated vault key, ciphertext copied from another connector or owner, or
|
|
907
|
+
* sealed state with no sealer configured — reads as absent, which fails
|
|
908
|
+
* closed to reauthorization.
|
|
909
|
+
*/
|
|
910
|
+
async openValue(key, physicalKey, sealed) {
|
|
911
|
+
if (!this.sealer || !SEALED_OAUTH_KEYS.has(key))
|
|
912
|
+
return undefined;
|
|
913
|
+
try {
|
|
914
|
+
return await this.sealer.open(physicalKey, sealed);
|
|
915
|
+
}
|
|
916
|
+
catch {
|
|
917
|
+
this.sealer.warn(`[connecta] connector "${this.connectorId}" has sealed OAuth state ` +
|
|
918
|
+
`(${key}) the configured vault cannot open; treating it as absent, ` +
|
|
919
|
+
"so the connector needs authorization again. A changed vault key " +
|
|
920
|
+
"or tampered storage causes this.");
|
|
921
|
+
return undefined;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Re-encode plaintext an older release (or a vault-less deployment) wrote,
|
|
926
|
+
* in place and byte-for-byte, through the same generation fence as every
|
|
927
|
+
* write. A failure leaves the plaintext readable; the next write seals it.
|
|
928
|
+
*/
|
|
929
|
+
async sealInPlace(key, generation, raw) {
|
|
930
|
+
try {
|
|
931
|
+
await this.storeInGeneration(key, generation, () => raw, raw);
|
|
932
|
+
}
|
|
933
|
+
catch {
|
|
934
|
+
this.sealer?.warn(`[connecta] connector "${this.connectorId}" could not seal plaintext ` +
|
|
935
|
+
`OAuth state (${key}); it stays readable and is sealed on its next write.`);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
598
938
|
/**
|
|
599
939
|
* Read a value only when it belongs to the active generation. Plain legacy
|
|
600
940
|
* values remain readable until the first v2 reset, so upgrades do not discard
|
|
601
941
|
* an existing grant; once a modern epoch exists, untagged residue fails
|
|
602
|
-
* closed.
|
|
942
|
+
* closed. With a sealer, a credential found in plaintext is returned and
|
|
943
|
+
* then sealed where it lies.
|
|
603
944
|
*/
|
|
604
945
|
async readValue(key, parseLegacy) {
|
|
946
|
+
const read = await this.readStoredValue(key, parseLegacy);
|
|
947
|
+
if (!read)
|
|
948
|
+
return undefined;
|
|
949
|
+
if (read.plaintextCredential) {
|
|
950
|
+
await this.sealInPlace(key, read.stored.generation, read.raw);
|
|
951
|
+
}
|
|
952
|
+
return read.stored;
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* `readValue` without the in-place sealing, keeping the exact raw string
|
|
956
|
+
* (ciphertext, when sealed) the value came from, for a compare-and-set.
|
|
957
|
+
*/
|
|
958
|
+
async readStoredValue(key, parseLegacy) {
|
|
605
959
|
const generation = await this.generation();
|
|
606
|
-
const
|
|
960
|
+
const physicalKey = oauthValueStorageKey(key, generation);
|
|
961
|
+
const raw = await this.storage.get(physicalKey);
|
|
607
962
|
if (raw === null)
|
|
608
963
|
return undefined;
|
|
609
964
|
if (generation.startsWith(RESETTING_GENERATION_PREFIX) ||
|
|
610
965
|
generation.startsWith(DISCONNECTED_GENERATION_PREFIX)) {
|
|
611
966
|
return undefined;
|
|
612
967
|
}
|
|
968
|
+
let text = raw;
|
|
613
969
|
let parsed;
|
|
614
970
|
try {
|
|
615
971
|
parsed = JSON.parse(raw);
|
|
@@ -617,6 +973,27 @@ export class KvOAuthProvider {
|
|
|
617
973
|
catch {
|
|
618
974
|
// Raw string state from a pre-envelope deployment is handled below.
|
|
619
975
|
}
|
|
976
|
+
let plaintextCredential = false;
|
|
977
|
+
if (sealedOAuthValue(parsed)) {
|
|
978
|
+
const opened = await this.openValue(key, physicalKey, parsed.sealed);
|
|
979
|
+
if (opened === undefined)
|
|
980
|
+
return undefined;
|
|
981
|
+
text = opened;
|
|
982
|
+
parsed = undefined;
|
|
983
|
+
try {
|
|
984
|
+
parsed = JSON.parse(text);
|
|
985
|
+
}
|
|
986
|
+
catch {
|
|
987
|
+
// A sealed raw legacy string, handled below like its plaintext form.
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
else {
|
|
991
|
+
plaintextCredential = this.sealer !== undefined && SEALED_OAUTH_KEYS.has(key);
|
|
992
|
+
}
|
|
993
|
+
const stored = this.interpretValue(text, parsed, generation, parseLegacy);
|
|
994
|
+
return stored ? { stored, raw, plaintextCredential } : undefined;
|
|
995
|
+
}
|
|
996
|
+
interpretValue(raw, parsed, generation, parseLegacy) {
|
|
620
997
|
if (storedOAuthValue(parsed)) {
|
|
621
998
|
return parsed.generation === generation
|
|
622
999
|
? {
|
|
@@ -758,6 +1135,7 @@ export class KvOAuthProvider {
|
|
|
758
1135
|
await this.writeValue("oauth:tokens", tokens, (value) => JSON.stringify(value), ctx?.issuer);
|
|
759
1136
|
if (coordinated)
|
|
760
1137
|
this.refreshCoordinator?.succeedMutation(owned.generation, owned.flight);
|
|
1138
|
+
this.refreshFailure = undefined;
|
|
761
1139
|
}
|
|
762
1140
|
catch (error) {
|
|
763
1141
|
if (coordinated)
|
|
@@ -782,7 +1160,7 @@ export class KvOAuthProvider {
|
|
|
782
1160
|
*/
|
|
783
1161
|
async state() {
|
|
784
1162
|
if (!this.allowAuthorization)
|
|
785
|
-
throw
|
|
1163
|
+
throw this.authorizationRefused();
|
|
786
1164
|
const value = randomState();
|
|
787
1165
|
await this.writeValue("oauth:state", value, (raw) => raw);
|
|
788
1166
|
return value;
|
|
@@ -802,7 +1180,7 @@ export class KvOAuthProvider {
|
|
|
802
1180
|
}
|
|
803
1181
|
async saveCodeVerifier(verifier) {
|
|
804
1182
|
if (!this.allowAuthorization)
|
|
805
|
-
throw
|
|
1183
|
+
throw this.authorizationRefused();
|
|
806
1184
|
await this.writeValue("oauth:verifier", verifier, (raw) => raw);
|
|
807
1185
|
}
|
|
808
1186
|
async codeVerifier() {
|
|
@@ -815,7 +1193,7 @@ export class KvOAuthProvider {
|
|
|
815
1193
|
async redirectToAuthorization(authorizationUrl) {
|
|
816
1194
|
try {
|
|
817
1195
|
if (!this.allowAuthorization)
|
|
818
|
-
throw
|
|
1196
|
+
throw this.authorizationRefused();
|
|
819
1197
|
await this.writeValue("oauth:pending", authorizationUrl.toString(), (raw) => raw);
|
|
820
1198
|
this.failRefreshFlight(new Error("OAuth refresh required reauthorization before tokens were saved."));
|
|
821
1199
|
}
|
|
@@ -952,5 +1330,11 @@ export class KvOAuthProvider {
|
|
|
952
1330
|
this.failRefreshFlight(error);
|
|
953
1331
|
throw error;
|
|
954
1332
|
}
|
|
1333
|
+
// The SDK invalidates after a refused refresh and then starts over, which
|
|
1334
|
+
// on `invalid_client` means registering a new client. A passive request
|
|
1335
|
+
// must not begin authorization, so it ends here, as auth_required.
|
|
1336
|
+
if (!this.allowAuthorization && this.refreshFailure?.kind === "dead") {
|
|
1337
|
+
throw this.authorizationRefused();
|
|
1338
|
+
}
|
|
955
1339
|
}
|
|
956
1340
|
}
|