@zackbart/connecta 0.28.0 → 0.29.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/CHANGELOG.md +961 -5
- package/README.md +62 -39
- package/bin/connecta.mjs +110 -139
- package/bin/meta-tool-names.d.mts +2 -0
- package/bin/meta-tool-names.mjs +9 -0
- package/bin/version.mjs +3 -0
- package/dist/access-tokens.js +53 -63
- package/dist/activity-request.js +8 -0
- package/dist/activity.d.ts +76 -42
- package/dist/activity.js +110 -42
- package/dist/auth/clerk-sdk/client.d.ts +65 -0
- package/dist/auth/clerk-sdk/client.js +5 -0
- package/dist/auth/clerk-sdk/generated.js +1845 -0
- package/dist/auth/clerk-transport.js +19 -0
- package/dist/auth/clerk.d.ts +17 -7
- package/dist/auth/clerk.js +189 -107
- package/dist/auth/cloudflare-access.js +25 -26
- package/dist/auth/downstream-client-metadata.d.ts +29 -0
- package/dist/auth/downstream-client-metadata.js +133 -0
- package/dist/auth/downstream-oauth.js +1632 -1509
- package/dist/auth/downstream-revocation.js +106 -0
- package/dist/auth/oauth-token-response.js +231 -0
- package/dist/auth/oauth-v2-migration.js +116 -0
- package/dist/auth/static-oauth.js +526 -0
- package/dist/auth-elicitation.js +283 -0
- package/dist/branding.d.ts +8 -16
- package/dist/branding.js +26 -59
- package/dist/byte-read-response.js +39 -0
- package/dist/call-admission.js +12 -16
- package/dist/catalog-cache.js +739 -0
- package/dist/catalog-drift.d.ts +50 -15
- package/dist/catalog-drift.js +365 -128
- package/dist/catalog-fingerprint.js +2 -4
- package/dist/catalog-freshness.js +13 -0
- package/dist/catalog-limits.js +2 -2
- package/dist/catalog-service.js +368 -224
- package/dist/catalog.js +43 -84
- package/dist/concurrency.js +3 -4
- package/dist/config-defaults.d.ts +39 -0
- package/dist/config-defaults.js +46 -0
- package/dist/config-schema.d.ts +181 -0
- package/dist/config-schema.js +435 -0
- package/dist/config-value-sources.js +102 -0
- package/dist/config.d.ts +631 -0
- package/dist/config.js +503 -0
- package/dist/connector-access.d.ts +1 -0
- package/dist/connector-access.js +13 -15
- package/dist/connector-caller.d.ts +8 -0
- package/dist/connector-caller.js +15 -6
- package/dist/connector-scope.js +1 -1
- package/dist/connectors/api-connector.d.ts +220 -0
- package/dist/connectors/api-connector.js +214 -0
- package/dist/connectors/api.d.ts +8 -71
- package/dist/connectors/api.js +13 -99
- package/dist/connectors/guarded-fetch.d.ts +144 -0
- package/dist/connectors/guarded-fetch.js +108 -25
- package/dist/connectors/negotiation-cache.js +46 -0
- package/dist/connectors/option-shapes.js +27 -0
- package/dist/connectors/remote-mcp.d.ts +30 -4
- package/dist/connectors/remote-mcp.js +1780 -351
- package/dist/connectors/resource-uri.js +335 -0
- package/dist/credential-contract.d.ts +10 -0
- package/dist/credential-rules.js +1 -3
- package/dist/credential-url.js +108 -0
- package/dist/credentials.d.ts +6 -1
- package/dist/credentials.js +27 -18
- package/dist/d1.d.ts +130 -0
- package/dist/d1.js +290 -0
- package/dist/describe-config.d.ts +165 -0
- package/dist/describe-config.js +315 -0
- package/dist/described.js +106 -0
- package/dist/downstream-input-context.js +119 -0
- package/dist/downstream-input.js +323 -0
- package/dist/errors.d.ts +97 -20
- package/dist/errors.js +158 -80
- package/dist/execute.js +453 -305
- package/dist/executor-admission.d.ts +1 -0
- package/dist/executor-admission.js +11 -15
- package/dist/executor-contract.d.ts +20 -0
- package/dist/executor-contract.js +48 -0
- package/dist/executor-result.js +24 -9
- package/dist/executors/quickjs-child.js +3 -4
- package/dist/executors/quickjs-runtime.js +147 -59
- package/dist/executors/quickjs.js +29 -35
- package/dist/favicon.js +1 -2
- package/dist/fix-prompt.js +1 -3
- package/dist/guest-runtime.js +188 -0
- package/dist/guest-types.d.ts +176 -0
- package/dist/guest-types.js +1 -0
- package/dist/html-security.js +24 -0
- package/dist/identity.js +1 -3
- package/dist/inbound-credential.js +52 -0
- package/dist/index.d.ts +25 -257
- package/dist/index.js +65 -478
- package/dist/invocation-auth.js +69 -0
- package/dist/invocation.js +230 -73
- package/dist/json-schema.d.ts +1 -1
- package/dist/mcp-client-context.js +35 -0
- package/dist/mcp-result.js +11 -7
- package/dist/meta-output.js +76 -0
- package/dist/meta-tool-names.d.ts +2 -0
- package/dist/meta-tool-names.js +3 -0
- package/dist/meta-tools.js +285 -209
- package/dist/module-contracts.d.ts +8 -23
- package/dist/node.d.ts +0 -2
- package/dist/node.js +2 -2
- package/dist/oauth-callback-outcome.js +1 -3
- package/dist/oauth-handoff.js +117 -0
- package/dist/oauth-partition.js +17 -0
- package/dist/oauth-sealing.js +20 -0
- package/dist/operator-record.d.ts +137 -0
- package/dist/operator-record.js +420 -0
- package/dist/operator-ui/assets.js +31 -0
- package/dist/operator-ui/contract.d.ts +72 -0
- package/dist/operator-ui/contract.js +1 -0
- package/dist/operator-ui/fix-prompts.d.ts +18 -0
- package/dist/operator-ui/generated.js +5 -4
- package/dist/operator-ui/model.d.ts +12 -87
- package/dist/operator-ui/model.js +2 -9
- package/dist/operator-ui/setup-commands.js +1 -3
- package/dist/operator-ui/view.d.ts +323 -0
- package/dist/operator-ui/view.js +62 -189
- package/dist/page-styles.js +2 -3
- package/dist/program-source.js +14 -2
- package/dist/{exempt-writes.js → program-writes.js} +55 -18
- package/dist/provider.d.ts +142 -0
- package/dist/provider.js +187 -0
- package/dist/providers/_shared/google/result-size.js +57 -0
- package/dist/providers/_shared/google/service-account.d.ts +58 -0
- package/dist/providers/_shared/google/service-account.js +500 -0
- package/dist/providers/_shared/google/workspace.d.ts +276 -0
- package/dist/providers/_shared/google/workspace.js +518 -0
- package/dist/providers/basecamp/index.d.ts +43 -0
- package/dist/providers/basecamp/index.js +997 -0
- package/dist/providers/basecamp/skill.generated.js +9 -0
- package/dist/providers/breeze/index.d.ts +34 -0
- package/dist/providers/breeze/index.js +1446 -0
- package/dist/providers/breeze/skill.generated.js +9 -0
- package/dist/providers/ccb/index.d.ts +103 -0
- package/dist/providers/ccb/index.js +1725 -0
- package/dist/providers/ccb/skill.generated.js +9 -0
- package/dist/providers/{cloudflare.d.ts → cloudflare/index.d.ts} +8 -7
- package/dist/providers/{cloudflare.js → cloudflare/index.js} +171 -738
- package/dist/providers/cloudflare/skill.generated.js +10 -0
- package/dist/providers/docs/index.d.ts +54 -0
- package/dist/providers/docs/index.js +1237 -0
- package/dist/providers/docs/skill.generated.js +9 -0
- package/dist/providers/drive/index.d.ts +53 -0
- package/dist/providers/drive/index.js +1523 -0
- package/dist/providers/drive/skill.generated.js +9 -0
- package/dist/providers/forms/index.d.ts +54 -0
- package/dist/providers/forms/index.js +1217 -0
- package/dist/providers/forms/skill.generated.js +11 -0
- package/dist/providers/github/auth.d.ts +32 -0
- package/dist/providers/github/auth.js +361 -0
- package/dist/providers/github/hosted.js +218 -0
- package/dist/providers/github/index.d.ts +13 -0
- package/dist/providers/github/index.js +140 -0
- package/dist/providers/github/key.js +100 -0
- package/dist/providers/github/rest.js +321 -0
- package/dist/providers/github/scope.d.ts +29 -0
- package/dist/providers/github/scope.js +124 -0
- package/dist/providers/github/skill.generated.js +8 -0
- package/dist/providers/gmail/index.d.ts +51 -0
- package/dist/providers/gmail/index.js +1436 -0
- package/dist/providers/gmail/skill.generated.js +9 -0
- package/dist/providers/{linear.d.ts → linear/index.d.ts} +11 -14
- package/dist/providers/linear/index.js +352 -0
- package/dist/providers/linear/skill.generated.js +8 -0
- package/dist/providers/{mixpanel.d.ts → mixpanel/index.d.ts} +5 -10
- package/dist/providers/mixpanel/index.js +416 -0
- package/dist/providers/mixpanel/skill.generated.js +9 -0
- package/dist/providers/{notion.d.ts → notion/index.d.ts} +8 -8
- package/dist/providers/{notion.js → notion/index.js} +226 -239
- package/dist/providers/notion/skill.generated.js +10 -0
- package/dist/providers/overflow/index.d.ts +60 -0
- package/dist/providers/overflow/index.js +1535 -0
- package/dist/providers/overflow/skill.generated.js +8 -0
- package/dist/providers/planning-center/index.d.ts +59 -0
- package/dist/providers/planning-center/index.js +2199 -0
- package/dist/providers/planning-center/skill.generated.js +10 -0
- package/dist/providers/{revenuecat.d.ts → revenuecat/index.d.ts} +5 -10
- package/dist/providers/revenuecat/index.js +557 -0
- package/dist/providers/revenuecat/skill.generated.js +11 -0
- package/dist/providers/sheets/index.d.ts +54 -0
- package/dist/providers/sheets/index.js +1237 -0
- package/dist/providers/sheets/skill.generated.js +10 -0
- package/dist/providers/slides/index.d.ts +53 -0
- package/dist/providers/slides/index.js +2645 -0
- package/dist/providers/slides/skill.generated.js +9 -0
- package/dist/providers/{stripe.d.ts → stripe/index.d.ts} +5 -12
- package/dist/providers/stripe/index.js +266 -0
- package/dist/providers/stripe/skill.generated.js +11 -0
- package/dist/providers/tithely/index.d.ts +49 -0
- package/dist/providers/tithely/index.js +1003 -0
- package/dist/providers/tithely/skill.generated.js +8 -0
- package/dist/providers/{vercel.d.ts → vercel/index.d.ts} +8 -8
- package/dist/providers/vercel/index.js +1038 -0
- package/dist/providers/vercel/mcp-ownership.js +212 -0
- package/dist/providers/vercel/skill.generated.js +11 -0
- package/dist/registry.d.ts +108 -171
- package/dist/registry.js +513 -925
- package/dist/request-state.js +21 -0
- package/dist/resource-read.js +100 -0
- package/dist/result-shapes.js +17 -49
- package/dist/retired-toolkits.js +1 -1
- package/dist/routes/access-tokens.js +3 -6
- package/dist/routes/activity-context.js +21 -0
- package/dist/routes/activity-disclosure.js +40 -0
- package/dist/routes/activity.js +59 -21
- package/dist/routes/actor-labels.js +3 -50
- package/dist/routes/connect.js +221 -0
- package/dist/routes/credentials.js +15 -27
- package/dist/routes/mcp.js +356 -130
- package/dist/routes/oauth-client-metadata.js +15 -0
- package/dist/routes/oauth-management.js +45 -107
- package/dist/routes/oauth.js +136 -57
- package/dist/routes/operator.js +3 -3
- package/dist/routes/shared.d.ts +25 -64
- package/dist/routes/shared.js +160 -80
- package/dist/routes/skills.js +30 -0
- package/dist/routes/ui-config.js +225 -0
- package/dist/routes/ui-data.js +40 -35
- package/dist/routes/ui.js +66 -56
- package/dist/runtime/admission.js +4 -2
- package/dist/runtime/call-admission.js +3 -1
- package/dist/runtime/connector-scope.js +1 -4
- package/dist/runtime/run.js +10 -39
- package/dist/runtime/services.js +12 -117
- package/dist/runtime/shared-read.js +1 -3
- package/dist/runtime/storage.js +8 -1
- package/dist/sent-secrets.d.ts +52 -0
- package/dist/sent-secrets.js +474 -0
- package/dist/server.js +28 -71
- package/dist/skills.js +406 -174
- package/dist/sqlite.d.ts +48 -0
- package/dist/sqlite.js +166 -0
- package/dist/storage/keys.js +428 -0
- package/dist/storage/memory.js +7 -0
- package/dist/storage/sql.d.ts +66 -0
- package/dist/storage/sql.js +534 -0
- package/dist/timeout.js +4 -2
- package/dist/tool-name.js +17 -0
- package/dist/tool-safety.d.ts +9 -35
- package/dist/tool-safety.js +20 -40
- package/dist/types.d.ts +320 -54
- package/dist/types.js +0 -1
- package/dist/ui.d.ts +7 -19
- package/dist/ui.js +76 -90
- package/dist/url-safety.js +3 -6
- package/dist/usage-guide.js +137 -0
- package/dist/validate.d.ts +7 -0
- package/dist/validate.js +309 -47
- package/dist/version.d.ts +1 -7
- package/dist/version.js +3 -7
- package/dist/worker.d.ts +11 -0
- package/dist/worker.js +434 -0
- package/documentation/architecture.md +665 -230
- package/documentation/auth.md +962 -458
- package/documentation/code-mode.md +501 -312
- package/documentation/meta-tools.md +449 -200
- package/documentation/operator-tests.md +93 -0
- package/documentation/provider-migration-0.29.md +60 -0
- package/examples/worker/AGENTS.md +34 -12
- package/examples/worker/README.md +286 -255
- package/examples/worker/kv-to-d1.wrangler.jsonc +26 -0
- package/examples/worker/scripts/copy-kv-to-d1.mjs +162 -0
- package/examples/worker/src/connecta.config.ts +151 -0
- package/examples/worker/src/index.ts +14 -197
- package/examples/worker/wrangler.jsonc +15 -35
- package/package.json +125 -35
- package/templates/node/.dockerignore +1 -2
- package/templates/node/.env.example +20 -21
- package/templates/node/AGENTS.md +21 -17
- package/templates/node/Dockerfile +3 -5
- package/templates/node/README.md +133 -58
- package/templates/node/docker-compose.yml +11 -12
- package/templates/node/package.json +4 -2
- package/templates/node/src/connecta.config.ts +119 -0
- package/templates/node/src/index.ts +9 -154
- package/templates/node/src/provision-token.ts +17 -0
- package/AGENTS.md +0 -170
- package/dist/artifacts/connector.d.ts +0 -58
- package/dist/artifacts/connector.js +0 -730
- package/dist/artifacts/document.js +0 -86
- package/dist/artifacts/guide.js +0 -140
- package/dist/artifacts/html-scan.js +0 -329
- package/dist/artifacts/json.js +0 -78
- package/dist/artifacts/kv-store.d.ts +0 -20
- package/dist/artifacts/kv-store.js +0 -140
- package/dist/artifacts/markdown.js +0 -478
- package/dist/artifacts/module.d.ts +0 -37
- package/dist/artifacts/module.js +0 -66
- package/dist/artifacts/operations.d.ts +0 -197
- package/dist/artifacts/operations.js +0 -1002
- package/dist/artifacts/refresh.d.ts +0 -55
- package/dist/artifacts/refresh.js +0 -236
- package/dist/artifacts/routes.js +0 -206
- package/dist/artifacts/types.d.ts +0 -214
- package/dist/artifacts/types.js +0 -22
- package/dist/artifacts/validate.d.ts +0 -62
- package/dist/artifacts/validate.js +0 -651
- package/dist/artifacts.d.ts +0 -6
- package/dist/artifacts.js +0 -9
- package/dist/auth/bearer.d.ts +0 -11
- package/dist/auth/bearer.js +0 -50
- package/dist/providers/linear.js +0 -217
- package/dist/providers/mixpanel.js +0 -247
- package/dist/providers/revenuecat.js +0 -317
- package/dist/providers/stripe.js +0 -253
- package/dist/providers/vercel.js +0 -1253
- package/dist/storage/file.d.ts +0 -15
- package/dist/storage/file.js +0 -455
- package/ethos.md +0 -93
- package/examples/worker/src/cloudflare-kv.ts +0 -43
- package/examples/worker/src/d1-activity-row.ts +0 -155
- package/examples/worker/src/d1-activity.ts +0 -147
- package/examples/worker/src/d1-storage.ts +0 -138
- package/examples/worker/src/r2-artifact-blobs.ts +0 -29
- package/templates/node/src/file-activity.ts +0 -182
|
@@ -1,397 +1,101 @@
|
|
|
1
1
|
import { UnauthorizedError } from "@modelcontextprotocol/client";
|
|
2
2
|
import { Deferred, Effect } from "effect";
|
|
3
|
-
import {
|
|
3
|
+
import { revokeDownstreamGrant } from "./downstream-revocation.js";
|
|
4
|
+
import { authenticateRemoteClient, downstreamClientMetadata, remoteClientAuthMethod, } from "./downstream-client-metadata.js";
|
|
5
|
+
import { SentSecrets } from "../sent-secrets.js";
|
|
4
6
|
import { ConnectorCallError } from "../errors.js";
|
|
7
|
+
import { attachOAuthPartition, oauthPartitionFor, retainOAuthPartition } from "../oauth-partition.js";
|
|
8
|
+
import { inheritOAuthSealer } from "../oauth-sealing.js";
|
|
5
9
|
import { detach, fromSignal, runEdge } from "../runtime/run.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
return false;
|
|
16
|
-
let diff = 0;
|
|
17
|
-
for (let i = 0; i < a.length; i++)
|
|
18
|
-
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
19
|
-
return diff === 0;
|
|
20
|
-
}
|
|
21
|
-
const LEGACY_GENERATION = "legacy";
|
|
22
|
-
const ACTIVE_GENERATION_PREFIX = "v2:";
|
|
23
|
-
const RESETTING_GENERATION_PREFIX = "reset:";
|
|
24
|
-
const DISCONNECTED_GENERATION_PREFIX = "disconnected:";
|
|
25
|
-
const STORED_VALUE_VERSION = 2;
|
|
26
|
-
const OAUTH_VALUE_KEYS = [
|
|
27
|
-
"oauth:client",
|
|
28
|
-
"oauth:tokens",
|
|
29
|
-
"oauth:pending",
|
|
30
|
-
"oauth:verifier",
|
|
31
|
-
"oauth:state",
|
|
32
|
-
"oauth:discovery",
|
|
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;
|
|
47
|
-
/**
|
|
48
|
-
* The longest cleanup lineage a reset publishes. A reset's storage work no
|
|
49
|
-
* longer depends on the lineage's length — it deletes one retired generation
|
|
50
|
-
* after the fence and sweeps at most MAX_EXPIRED_SWEEP before it — so this
|
|
51
|
-
* guards only the size of the two records every reset reads. A generation
|
|
52
|
-
* name is 39 characters, so 5,000 entries is about 210 KB of manifest and
|
|
53
|
-
* 280 KB of times: far under Workers KV's 25 MiB value limit, and a few
|
|
54
|
-
* milliseconds to parse. Reaching it takes more than 4,000 resets of one
|
|
55
|
-
* connector in a day on top of the 1,000 an earlier release allowed, which
|
|
56
|
-
* is a loop, not an operator. An earlier release refuses a lineage longer
|
|
57
|
-
* than 1,000, so rolling back past this one wedges only a connector that
|
|
58
|
-
* went beyond that — which that release would have wedged anyway.
|
|
59
|
-
*/
|
|
60
|
-
const MAX_CLEANUP_BACKLOG = 5_000;
|
|
61
|
-
/**
|
|
62
|
-
* How long a retired generation stays in the cleanup lineage. A late write
|
|
63
|
-
* lands in a retired namespace only from a request that captured that
|
|
64
|
-
* generation before the reset and is still running — an OAuth flow, a token
|
|
65
|
-
* refresh, or a reader whose eventually consistent store (Workers KV serves a
|
|
66
|
-
* stale generation for a minute or more) has not yet seen the fence. None of
|
|
67
|
-
* those outlive a day. A late writer's own cleanup deletes what it wrote, and
|
|
68
|
-
* if that delete fails it records the generation as retired again from that
|
|
69
|
-
* moment; residue it leaves is unreadable behind the fence either way. Once
|
|
70
|
-
* the grace has passed, a reset sweeps the generation, and one whose keys
|
|
71
|
-
* are confirmed deleted cannot be written again, so it leaves the lineage.
|
|
72
|
-
* That is an assumption, not a proof: a request that holds a retired
|
|
73
|
-
* generation for longer than this and dies between its write and its own
|
|
74
|
-
* cleanup leaves residue no reset tracks. It is still unreadable — the epoch
|
|
75
|
-
* fence, not this cleanup, is what keeps an old namespace out of use.
|
|
76
|
-
*/
|
|
77
|
-
const CLEANUP_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
78
|
-
/**
|
|
79
|
-
* The most past-grace generations one reset sweeps before publishing. The
|
|
80
|
-
* rest stay in the lineage for a later reset, so a backlog built by a burst
|
|
81
|
-
* of resets drains over several resets instead of in one request.
|
|
82
|
-
*/
|
|
83
|
-
const MAX_EXPIRED_SWEEP = 16;
|
|
84
|
-
/**
|
|
85
|
-
* Independent storage deletes one cleanup keeps in flight: Workers allows six
|
|
86
|
-
* simultaneous open connections per invocation, and more would only queue.
|
|
87
|
-
*/
|
|
88
|
-
const DELETE_CONCURRENCY = 6;
|
|
89
|
-
/**
|
|
90
|
-
* How many of the most recently retired generations a reset checks for an
|
|
91
|
-
* unfinished cleanup. The case that matters is the one just before: an
|
|
92
|
-
* operator whose Disconnect or Restart reported a failed cleanup retries, and
|
|
93
|
-
* that retry must delete the grant the failure left behind. Eight covers that
|
|
94
|
-
* with room for a burst of failing restarts, costs eight small reads per
|
|
95
|
-
* reset, and bounds a retry to eight generations' deletes; anything older is
|
|
96
|
-
* reclaimed by the past-grace sweep instead.
|
|
97
|
-
*/
|
|
98
|
-
const RETRY_PROBES = 8;
|
|
99
|
-
/**
|
|
100
|
-
* How long a pending authorization URL may be handed out again instead of
|
|
101
|
-
* starting a fresh flow. The URL itself never expires on connecta's side, but
|
|
102
|
-
* the authorization server's half of it does: a pushed request URI lives
|
|
103
|
-
* seconds to minutes (RFC 9126), and login transactions are commonly held
|
|
104
|
-
* for minutes, not hours. Past this age a reissued URL is more likely to land
|
|
105
|
-
* the operator on an expired-session page than on consent, and a fresh start
|
|
106
|
-
* costs one authorization request, not a client registration. It also sits
|
|
107
|
-
* inside the registry's 15-minute personal-auth handoff.
|
|
108
|
-
*/
|
|
109
|
-
const PENDING_AUTHORIZATION_MAX_AGE_MS = 10 * 60 * 1000;
|
|
110
|
-
/**
|
|
111
|
-
* Union two lineages by generation, keeping the later retirement time, so a
|
|
112
|
-
* merge can only ever lengthen a generation's grace. Order is first-seen.
|
|
113
|
-
*/
|
|
114
|
-
function mergeLineage(...lineages) {
|
|
115
|
-
const merged = new Map();
|
|
116
|
-
for (const lineage of lineages) {
|
|
117
|
-
for (const { generation, retiredAt } of lineage) {
|
|
118
|
-
const known = merged.get(generation);
|
|
119
|
-
merged.set(generation, known === undefined ? retiredAt : Math.max(known, retiredAt));
|
|
120
|
-
}
|
|
10
|
+
import { OAUTH_FLOW_TTL_SECONDS, OAUTH_REFRESH_LEASE_SECONDS, oauthFlowKeys, oauthGrantKeys, oauthRefreshKeys, oauthRefreshSpentKeys, oauthRefreshActiveKeys, } from "../storage/keys.js";
|
|
11
|
+
import { isRefreshTokenRequest, isOAuthCredentialRequest, refreshResponseOutcome, sdkSafeTokenResponse, tokenGrantType, } from "./oauth-token-response.js";
|
|
12
|
+
import { deleteV2Keys, discoveryIssuer, readV2Grant } from "./oauth-v2-migration.js";
|
|
13
|
+
/** RFC 6749 section 3.3: one or more scope tokens, separated by single spaces. */
|
|
14
|
+
const OAUTH_SCOPE = /^[\x21\x23-\x5B\x5D-\x7E]+(?: [\x21\x23-\x5B\x5D-\x7E]+)*$/;
|
|
15
|
+
/** Refuse, at construction, a configured scope no authorization server could parse. */
|
|
16
|
+
export function assertOAuthScope(connectorId, scope) {
|
|
17
|
+
if (scope !== undefined && !OAUTH_SCOPE.test(scope)) {
|
|
18
|
+
throw new Error(`[connecta] connector "${connectorId}" OAuth scope must contain space-separated scope tokens.`);
|
|
121
19
|
}
|
|
122
|
-
return [...merged].map(([generation, retiredAt]) => ({
|
|
123
|
-
generation,
|
|
124
|
-
retiredAt,
|
|
125
|
-
}));
|
|
126
20
|
}
|
|
127
21
|
/**
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
22
|
+
* The context an explicit authorization start runs under: allowed to begin
|
|
23
|
+
* consent, with a request scope of its own, carrying the registry's sealer and
|
|
24
|
+
* owner partition across the copy. Only `startAuth` builds one; status reads
|
|
25
|
+
* and calls never do, which is what keeps them from starting authorization.
|
|
131
26
|
*/
|
|
132
|
-
function
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
else
|
|
139
|
-
await new Promise((resume) => waiting.push(resume));
|
|
140
|
-
try {
|
|
141
|
-
return await run();
|
|
142
|
-
}
|
|
143
|
-
finally {
|
|
144
|
-
const next = waiting.shift();
|
|
145
|
-
if (next)
|
|
146
|
-
next();
|
|
147
|
-
else
|
|
148
|
-
active--;
|
|
149
|
-
}
|
|
150
|
-
};
|
|
27
|
+
export function authorizingContext(ctx) {
|
|
28
|
+
return attachOAuthPartition(inheritOAuthSealer(ctx, {
|
|
29
|
+
...ctx,
|
|
30
|
+
requestScope: ctx.requestScope ?? ctx,
|
|
31
|
+
allowAuthorization: true,
|
|
32
|
+
}), oauthPartitionFor(ctx));
|
|
151
33
|
}
|
|
152
34
|
/**
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
35
|
+
* A callback whose consent another callback already claimed: fixed text, and
|
|
36
|
+
* nothing was sent. Its own class so the callback route can answer it as the
|
|
37
|
+
* already-used link it is, not as an exchange the provider refused.
|
|
156
38
|
*/
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
return undefined;
|
|
163
|
-
}
|
|
164
|
-
function isRefreshTokenRequest(init) {
|
|
165
|
-
if ((init?.method ?? "GET").toUpperCase() !== "POST")
|
|
166
|
-
return false;
|
|
167
|
-
const body = init?.body;
|
|
168
|
-
if (body instanceof URLSearchParams) {
|
|
169
|
-
return body.get("grant_type") === "refresh_token";
|
|
170
|
-
}
|
|
171
|
-
if (typeof body !== "string")
|
|
172
|
-
return false;
|
|
173
|
-
return new URLSearchParams(body).get("grant_type") === "refresh_token";
|
|
174
|
-
}
|
|
175
|
-
function sdkAcceptsOAuthTokens(value) {
|
|
176
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
177
|
-
return false;
|
|
178
|
-
const candidate = value;
|
|
179
|
-
if (typeof candidate.access_token !== "string" ||
|
|
180
|
-
typeof candidate.token_type !== "string") {
|
|
181
|
-
return false;
|
|
182
|
-
}
|
|
183
|
-
for (const key of ["id_token", "scope", "refresh_token"]) {
|
|
184
|
-
if (candidate[key] !== undefined && typeof candidate[key] !== "string") {
|
|
185
|
-
return false;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
if (candidate.expires_in !== undefined) {
|
|
189
|
-
try {
|
|
190
|
-
if (!Number.isFinite(Number(candidate.expires_in)))
|
|
191
|
-
return false;
|
|
192
|
-
}
|
|
193
|
-
catch {
|
|
194
|
-
return false;
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
return true;
|
|
198
|
-
}
|
|
199
|
-
const MAX_REFRESH_RESPONSE_BYTES = 65_536;
|
|
200
|
-
class OversizedRefreshResponse extends Error {
|
|
201
|
-
constructor() {
|
|
202
|
-
super(`OAuth refresh response exceeded ${MAX_REFRESH_RESPONSE_BYTES} bytes.`);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
async function readRefreshResponse(response) {
|
|
206
|
-
if (Number(response.headers.get("content-length")) > MAX_REFRESH_RESPONSE_BYTES) {
|
|
207
|
-
void response.body?.cancel().catch(() => { });
|
|
208
|
-
throw new OversizedRefreshResponse();
|
|
209
|
-
}
|
|
210
|
-
const reader = response.clone().body?.getReader();
|
|
211
|
-
if (!reader)
|
|
212
|
-
return undefined;
|
|
213
|
-
const decoder = new TextDecoder();
|
|
214
|
-
let size = 0;
|
|
215
|
-
let text = "";
|
|
216
|
-
try {
|
|
217
|
-
while (true) {
|
|
218
|
-
const { done, value } = await reader.read();
|
|
219
|
-
if (done)
|
|
220
|
-
break;
|
|
221
|
-
size += value.byteLength;
|
|
222
|
-
if (size > MAX_REFRESH_RESPONSE_BYTES) {
|
|
223
|
-
// A cloned body's cancellation can await its sibling. Cancel both,
|
|
224
|
-
// without making the refusal wait for the provider to finish sending.
|
|
225
|
-
void reader.cancel().catch(() => { });
|
|
226
|
-
void response.body?.cancel().catch(() => { });
|
|
227
|
-
throw new OversizedRefreshResponse();
|
|
228
|
-
}
|
|
229
|
-
text += decoder.decode(value, { stream: true });
|
|
230
|
-
}
|
|
231
|
-
return JSON.parse(text + decoder.decode());
|
|
232
|
-
}
|
|
233
|
-
finally {
|
|
234
|
-
reader.releaseLock();
|
|
39
|
+
export class OAuthCallbackClaimedError extends ConnectorCallError {
|
|
40
|
+
constructor(connectorId) {
|
|
41
|
+
super("connector_call_failed", `Connector "${connectorId}" authorization callback was already used by another request; nothing was exchanged.`);
|
|
42
|
+
this.name = "OAuthCallbackClaimedError";
|
|
235
43
|
}
|
|
236
44
|
}
|
|
237
|
-
/**
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* OAuth `error` codes on which the SDK's own `auth()` drops state and starts
|
|
241
|
-
* over. Handing the SDK one of these is how a dead grant reaches consent on an
|
|
242
|
-
* explicit authorization, and `auth_required` on a passive call.
|
|
243
|
-
*/
|
|
244
|
-
const SDK_RESTARTING_CODES = new Set([
|
|
245
|
-
"invalid_grant",
|
|
246
|
-
"invalid_client",
|
|
247
|
-
"unauthorized_client",
|
|
248
|
-
]);
|
|
249
|
-
function oauthErrorCode(body) {
|
|
250
|
-
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
251
|
-
return undefined;
|
|
252
|
-
const code = body.error;
|
|
253
|
-
return typeof code === "string" ? code : undefined;
|
|
45
|
+
/** A local destination guard proved that no OAuth HTTP request left. */
|
|
46
|
+
export class OAuthRequestNotSentError extends ConnectorCallError {
|
|
254
47
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
...(response ? { status: response.status } : {}),
|
|
261
|
-
...(wait !== undefined ? { retryAfterMs: wait } : {}),
|
|
262
|
-
...(cause !== undefined ? { cause } : {}),
|
|
263
|
-
};
|
|
48
|
+
/** A 256-bit random opaque value, hex-encoded — used for the OAuth `state`. */
|
|
49
|
+
function randomState() {
|
|
50
|
+
const bytes = new Uint8Array(32);
|
|
51
|
+
crypto.getRandomValues(bytes);
|
|
52
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
264
53
|
}
|
|
265
|
-
/**
|
|
266
|
-
function
|
|
267
|
-
|
|
268
|
-
return
|
|
269
|
-
error: "invalid_grant",
|
|
270
|
-
error_description: "The authorization server refused the refresh token.",
|
|
271
|
-
}, { status: 400 });
|
|
54
|
+
/** Lowercase hex SHA-256 of a consent's `state`: the name of its flow record. */
|
|
55
|
+
export async function oauthStateDigest(state) {
|
|
56
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(state));
|
|
57
|
+
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
272
58
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
59
|
+
function storedRefreshSpent(raw) {
|
|
60
|
+
const value = raw === null ? undefined : parsed(raw);
|
|
61
|
+
return plainObject(value) &&
|
|
62
|
+
value.connectaOAuthRefreshSpent === 1 &&
|
|
63
|
+
typeof value.epoch === "string" &&
|
|
64
|
+
typeof value.holder === "string" &&
|
|
65
|
+
typeof value.activeKey === "string" &&
|
|
66
|
+
(value.state === "outstanding" ||
|
|
67
|
+
value.state === "ambiguous" ||
|
|
68
|
+
(value.state === "resolved" && typeof value.resolvedAt === "number" && Number.isFinite(value.resolvedAt)))
|
|
69
|
+
? value
|
|
70
|
+
: undefined;
|
|
284
71
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
* `redirectToAuthorization()`); it rethrows every other `OAuthError`, and
|
|
296
|
-
* `auth()` retries once after `invalidateCredentials()` only for
|
|
297
|
-
* `invalid_grant` (tokens) and `invalid_client`/`unauthorized_client` (client
|
|
298
|
-
* and tokens). `executeTokenRequest()` turns a 2xx body carrying `error` into
|
|
299
|
-
* an `OAuthError` with that code. So a dead grant the SDK would rethrow
|
|
300
|
-
* (`bad_refresh_token`, `invalid_scope`, …) reaches it as `invalid_grant`, and
|
|
301
|
-
* an outage whose body names any code but `server_error` reaches it as
|
|
302
|
-
* `server_error` — a 5xx `invalid_grant` must not make the SDK drop a grant
|
|
303
|
-
* nobody refused. The provider hooks below finish the job. If the SDK moves,
|
|
304
|
-
* the tests under "remoteMcp() dead and transient refresh grants" fail first.
|
|
305
|
-
*/
|
|
306
|
-
async function refreshResponseOutcome(response) {
|
|
307
|
-
if (!response.ok) {
|
|
308
|
-
let code;
|
|
309
|
-
try {
|
|
310
|
-
code = oauthErrorCode(await readRefreshResponse(response));
|
|
311
|
-
}
|
|
312
|
-
catch {
|
|
313
|
-
// Not JSON, or too large to be an OAuth error: the status decides.
|
|
314
|
-
}
|
|
315
|
-
const failure = new Error(`OAuth refresh failed with HTTP ${response.status}.`);
|
|
316
|
-
if (response.status >= 400 &&
|
|
317
|
-
response.status < 500 &&
|
|
318
|
-
!TRANSIENT_CLIENT_STATUSES.has(response.status)) {
|
|
319
|
-
return {
|
|
320
|
-
failure,
|
|
321
|
-
verdict: { kind: "dead" },
|
|
322
|
-
forSdk: code !== undefined && SDK_RESTARTING_CODES.has(code)
|
|
323
|
-
? response
|
|
324
|
-
: refusedGrantResponse(response),
|
|
325
|
-
};
|
|
326
|
-
}
|
|
327
|
-
return {
|
|
328
|
-
failure,
|
|
329
|
-
verdict: transientFailure(`answered HTTP ${response.status}`, response),
|
|
330
|
-
forSdk: code !== undefined && code !== "server_error"
|
|
331
|
-
? outageResponse(response)
|
|
332
|
-
: response,
|
|
333
|
-
};
|
|
334
|
-
}
|
|
335
|
-
let parsed;
|
|
336
|
-
try {
|
|
337
|
-
parsed = await readRefreshResponse(response);
|
|
338
|
-
}
|
|
339
|
-
catch (error) {
|
|
340
|
-
if (error instanceof OversizedRefreshResponse)
|
|
341
|
-
throw error;
|
|
342
|
-
return {
|
|
343
|
-
failure: new Error("OAuth refresh response did not contain JSON tokens."),
|
|
344
|
-
verdict: transientFailure("answered without a token response", response),
|
|
345
|
-
forSdk: response,
|
|
346
|
-
};
|
|
347
|
-
}
|
|
348
|
-
if (sdkAcceptsOAuthTokens(parsed))
|
|
349
|
-
return { tokens: parsed };
|
|
350
|
-
if (oauthErrorCode(parsed) !== undefined) {
|
|
351
|
-
return {
|
|
352
|
-
failure: new Error("OAuth refresh was refused by the authorization server."),
|
|
353
|
-
verdict: { kind: "dead" },
|
|
354
|
-
forSdk: refusedGrantResponse(response),
|
|
355
|
-
};
|
|
356
|
-
}
|
|
72
|
+
const REFRESH_REQUEST_DEADLINE_MS = 20_000;
|
|
73
|
+
const REFRESH_WAIT_DEADLINE_MS = 35_000;
|
|
74
|
+
function refreshWait(signal) {
|
|
75
|
+
const deadline = new AbortController();
|
|
76
|
+
const timer = setTimeout(() => deadline.abort(), REFRESH_WAIT_DEADLINE_MS);
|
|
77
|
+
let closed = false;
|
|
78
|
+
const abort = () => deadline.abort(aborted(signal));
|
|
79
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
80
|
+
if (signal?.aborted)
|
|
81
|
+
abort();
|
|
357
82
|
return {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
83
|
+
signal: deadline.signal,
|
|
84
|
+
run: (operation) => runEdge(Effect.raceAllFirst([
|
|
85
|
+
fromSignal(deadline.signal),
|
|
86
|
+
Effect.tryPromise({ try: operation, catch: (error) => error }),
|
|
87
|
+
])),
|
|
88
|
+
close: () => {
|
|
89
|
+
if (closed)
|
|
90
|
+
return;
|
|
91
|
+
closed = true;
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
signal?.removeEventListener("abort", abort);
|
|
94
|
+
},
|
|
361
95
|
};
|
|
362
96
|
}
|
|
363
|
-
function refreshMutationPendingResponse() {
|
|
364
|
-
return Response.json({
|
|
365
|
-
error: "temporarily_unavailable",
|
|
366
|
-
error_description: "OAuth refresh is temporarily unavailable while previous credentials commit.",
|
|
367
|
-
}, { status: 503 });
|
|
368
|
-
}
|
|
369
97
|
function aborted(signal) {
|
|
370
|
-
return
|
|
371
|
-
}
|
|
372
|
-
/**
|
|
373
|
-
* Call `onAbort` once when `signal` aborts, at once if it already has, until
|
|
374
|
-
* the returned function stops watching. The owner's abort needs a listener
|
|
375
|
-
* rather than an interrupt once the SDK holds the answer: the SDK saves the
|
|
376
|
-
* tokens after `coordinatedFetch` has returned, when no fiber of the owner's
|
|
377
|
-
* is left to interrupt.
|
|
378
|
-
*/
|
|
379
|
-
function whenAborted(signal, onAbort) {
|
|
380
|
-
let watching = true;
|
|
381
|
-
const stop = () => {
|
|
382
|
-
if (!watching)
|
|
383
|
-
return;
|
|
384
|
-
watching = false;
|
|
385
|
-
signal.removeEventListener("abort", listener);
|
|
386
|
-
};
|
|
387
|
-
const listener = () => {
|
|
388
|
-
stop();
|
|
389
|
-
onAbort();
|
|
390
|
-
};
|
|
391
|
-
signal.addEventListener("abort", listener, { once: true });
|
|
392
|
-
if (signal.aborted)
|
|
393
|
-
listener();
|
|
394
|
-
return stop;
|
|
98
|
+
return signal?.reason ?? new DOMException("This operation was aborted", "AbortError");
|
|
395
99
|
}
|
|
396
100
|
/**
|
|
397
101
|
* The outcome of a flight another request owns, failures included. Rejects
|
|
@@ -404,106 +108,74 @@ function whenAborted(signal, onAbort) {
|
|
|
404
108
|
* instead hands the rest of its work back to its own request, as workerd does
|
|
405
109
|
* for any promise resolved from another one.
|
|
406
110
|
*/
|
|
407
|
-
function waitForRefreshFlight(flight, signal) {
|
|
111
|
+
function waitForRefreshFlight(flight, signal, deadline) {
|
|
408
112
|
const settled = Deferred.await(flight.outcome);
|
|
409
113
|
// The abort goes first, so a caller that has already left never joins.
|
|
410
|
-
return runEdge(
|
|
411
|
-
?
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
114
|
+
return runEdge(Effect.raceAllFirst([
|
|
115
|
+
...(signal ? [fromSignal(signal)] : []),
|
|
116
|
+
...(deadline ? [fromSignal(deadline)] : []),
|
|
117
|
+
settled,
|
|
118
|
+
]));
|
|
119
|
+
}
|
|
120
|
+
/** Tokens as a token response the SDK parses: only the client stamps an issuer. */
|
|
121
|
+
function tokenResponse(tokens) {
|
|
122
|
+
const { issuer: _issuer, ...answer } = tokens;
|
|
123
|
+
return Response.json(answer);
|
|
124
|
+
}
|
|
125
|
+
function refreshTokensDigest(tokens) {
|
|
126
|
+
const { issuer: _issuer, ...answer } = tokens;
|
|
127
|
+
return oauthStateDigest(JSON.stringify(Object.fromEntries(Object.entries(answer).sort(([a], [b]) => a.localeCompare(b)))));
|
|
128
|
+
}
|
|
129
|
+
function requestedRefreshToken(init) {
|
|
130
|
+
return init?.body instanceof URLSearchParams
|
|
131
|
+
? init.body.get("refresh_token")
|
|
132
|
+
: new URLSearchParams(String(init?.body ?? "")).get("refresh_token");
|
|
416
133
|
}
|
|
417
134
|
/**
|
|
418
|
-
*
|
|
419
|
-
* generation. The first request still owns the real fetch and response, and
|
|
420
|
-
* its abort ends the flight at any point until the flight settles: first by
|
|
421
|
-
* interrupting the redemption, then, once the SDK holds the answer, through
|
|
422
|
-
* one bounded listener, so a cancellation cannot strand waiters during token
|
|
423
|
-
* storage. Followers wait on the flight's Deferred for that provider to save
|
|
424
|
-
* tokens, then re-read storage. The map never retains a token response or
|
|
425
|
-
* transport.
|
|
135
|
+
* The token endpoint as one connector runtime's OAuth flows reach it.
|
|
426
136
|
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
137
|
+
* A refresh is redeemed once per owner partition and epoch: the first request
|
|
138
|
+
* owns the fetch, classifies the answer, and stores an accepted rotation, by
|
|
139
|
+
* compare-and-set, before anyone is released, so a cancelled owner or an SDK
|
|
140
|
+
* that redirects instead of saving cannot strand a spent refresh token.
|
|
141
|
+
* Requests that arrive meanwhile join its outcome; a request whose token read
|
|
142
|
+
* predates a stored refresh is handed that refresh instead of redeeming again.
|
|
143
|
+
* A shared-storage lease prevents concurrent redemption across isolates;
|
|
144
|
+
* the grant CAS also fences stale rotations.
|
|
145
|
+
*
|
|
146
|
+
* A code exchange claims its consent's flow record and re-checks the epoch in
|
|
147
|
+
* the turn that dispatches it (`KvOAuthProvider.claimCodeExchange`,
|
|
148
|
+
* `dispatchCodeExchange`). Every token answer the SDK parses is rebuilt from
|
|
149
|
+
* the OAuth `error` code alone unless it is a token response.
|
|
429
150
|
*/
|
|
430
151
|
export class OAuthRefreshCoordinator {
|
|
431
152
|
retainPartition;
|
|
153
|
+
flights = new Map();
|
|
432
154
|
constructor(retainPartition = () => () => { }) {
|
|
433
155
|
this.retainPartition = retainPartition;
|
|
434
156
|
}
|
|
435
|
-
|
|
436
|
-
retainWork() {
|
|
437
|
-
return this.retainPartition();
|
|
438
|
-
}
|
|
439
|
-
releaseFlightPartition(flight) {
|
|
440
|
-
if (flight.settled && !flight.answerPending && !flight.recoveryPending) {
|
|
441
|
-
flight.releasePartition();
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
flights = new Map();
|
|
445
|
-
/** Opaque identities only: no request promise, signal, callback, or response. */
|
|
446
|
-
pendingMutations = new Map();
|
|
447
|
-
/** One bounded latest-success slot, containing only generation + identity. */
|
|
448
|
-
successfulRefresh;
|
|
449
|
-
/** Replaced on every map mutation, closing flight/pending ABA across awaits. */
|
|
450
|
-
stateRevision = {};
|
|
451
|
-
advanceStateRevision() {
|
|
452
|
-
this.stateRevision = {};
|
|
453
|
-
}
|
|
454
|
-
observeAuthoritativeGeneration(generation) {
|
|
455
|
-
const staleGenerations = new Set([
|
|
456
|
-
...this.flights.keys(),
|
|
457
|
-
...this.pendingMutations.keys(),
|
|
458
|
-
]);
|
|
459
|
-
for (const stale of staleGenerations) {
|
|
460
|
-
if (stale !== generation)
|
|
461
|
-
this.retire(stale);
|
|
462
|
-
}
|
|
463
|
-
if (this.successfulRefresh &&
|
|
464
|
-
this.successfulRefresh.generation !== generation) {
|
|
465
|
-
this.successfulRefresh = undefined;
|
|
466
|
-
this.advanceStateRevision();
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
settle(generation, flight, outcome) {
|
|
470
|
-
if (this.flights.get(generation) !== flight)
|
|
471
|
-
return;
|
|
472
|
-
this.flights.delete(generation);
|
|
473
|
-
flight.settled = true;
|
|
474
|
-
this.advanceStateRevision();
|
|
475
|
-
flight.stopObservingOwnerAbort();
|
|
476
|
-
this.releaseFlightPartition(flight);
|
|
477
|
-
// Last, because joined fibers resume inside this call.
|
|
478
|
-
Deferred.doneUnsafe(flight.outcome, Effect.succeed(outcome));
|
|
479
|
-
}
|
|
480
|
-
markMutationPending(generation, flight) {
|
|
481
|
-
if (this.flights.get(generation) !== flight)
|
|
482
|
-
return false;
|
|
483
|
-
this.pendingMutations.set(generation, flight.mutationId);
|
|
484
|
-
this.advanceStateRevision();
|
|
485
|
-
return true;
|
|
486
|
-
}
|
|
487
|
-
finishMutation(generation, flight) {
|
|
488
|
-
if (this.pendingMutations.get(generation) === flight.mutationId) {
|
|
489
|
-
this.pendingMutations.delete(generation);
|
|
490
|
-
this.advanceStateRevision();
|
|
491
|
-
return true;
|
|
492
|
-
}
|
|
493
|
-
return false;
|
|
494
|
-
}
|
|
495
|
-
/** @internal Opaque basis for issuer-aware provider token reads. */
|
|
496
|
-
successfulRefreshIdentity(generation) {
|
|
497
|
-
return this.successfulRefresh?.generation === generation
|
|
498
|
-
? this.successfulRefresh.identity
|
|
499
|
-
: undefined;
|
|
500
|
-
}
|
|
501
|
-
coordinatedFetch(provider, baseFetch, requestSignal) {
|
|
157
|
+
coordinatedFetch(provider, baseFetch, requestSignal, defer, resourceFetch = baseFetch) {
|
|
502
158
|
return async (input, init) => {
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
|
|
506
|
-
|
|
159
|
+
// Freeze the form before any await: classification and the spent digest
|
|
160
|
+
// must describe the exact token sent, even if its caller mutates a form.
|
|
161
|
+
init = init
|
|
162
|
+
? { ...init, ...(init.body instanceof URLSearchParams ? { body: new URLSearchParams(init.body) } : {}) }
|
|
163
|
+
: undefined;
|
|
164
|
+
if (isRefreshTokenRequest(init)) {
|
|
165
|
+
const signal = requestSignal && init?.signal
|
|
166
|
+
? AbortSignal.any([requestSignal, init.signal])
|
|
167
|
+
: (requestSignal ?? init?.signal ?? undefined);
|
|
168
|
+
return this.refresh(provider, input, init, baseFetch, signal, defer);
|
|
169
|
+
}
|
|
170
|
+
const grantType = tokenGrantType(init);
|
|
171
|
+
const tokenRequest = isOAuthCredentialRequest(init);
|
|
172
|
+
const exchange = grantType === "authorization_code";
|
|
173
|
+
if (exchange) {
|
|
174
|
+
const answered = await provider.claimCodeExchange();
|
|
175
|
+
if (answered)
|
|
176
|
+
return answered;
|
|
177
|
+
}
|
|
178
|
+
const dispatch = () => {
|
|
507
179
|
if (requestSignal?.aborted)
|
|
508
180
|
throw aborted(requestSignal);
|
|
509
181
|
const signal = requestSignal
|
|
@@ -511,370 +183,365 @@ export class OAuthRefreshCoordinator {
|
|
|
511
183
|
? AbortSignal.any([requestSignal, init.signal])
|
|
512
184
|
: requestSignal
|
|
513
185
|
: init?.signal;
|
|
514
|
-
|
|
515
|
-
|
|
186
|
+
// Credential-bearing OAuth requests use the raw send path. A redirect
|
|
187
|
+
// is a dispatched failure, never authority to send the form again.
|
|
188
|
+
return tokenRequest
|
|
189
|
+
? baseFetch(input, { ...init, redirect: "manual", ...(signal ? { signal } : {}) })
|
|
190
|
+
: resourceFetch(input, { ...init, ...(signal ? { signal } : {}) });
|
|
191
|
+
};
|
|
192
|
+
// Awaited here so workerd associates a rejection with the fetch the SDK
|
|
193
|
+
// is already awaiting, not with an adopted inner promise.
|
|
194
|
+
const response = await (exchange ? provider.dispatchCodeExchange(dispatch, input) : dispatch());
|
|
195
|
+
if (!tokenRequest)
|
|
196
|
+
return response;
|
|
197
|
+
// A code exchange's failure is the SDK's to log as well.
|
|
198
|
+
const forSdk = await sdkSafeTokenResponse(response, exchange ? (tokens) => provider.recordCodeExchangeTokens(tokens) : undefined);
|
|
199
|
+
if (exchange && forSdk !== response)
|
|
200
|
+
provider.recordCodeExchangeRefusal(forSdk);
|
|
201
|
+
return forSdk;
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
async refresh(provider, input, init, baseFetch, signal, defer) {
|
|
205
|
+
const waiting = refreshWait(signal);
|
|
206
|
+
try {
|
|
516
207
|
// A verdict belongs to one refresh; never let an older one decide this.
|
|
517
208
|
provider.recordRefreshFailure(undefined);
|
|
518
|
-
const
|
|
519
|
-
const
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
const activeGeneration = await provider.generation();
|
|
527
|
-
this.observeAuthoritativeGeneration(activeGeneration);
|
|
528
|
-
if (activeGeneration !== generation)
|
|
529
|
-
this.retire(generation);
|
|
530
|
-
const activeFlight = this.flights.get(generation);
|
|
531
|
-
const pendingMutation = this.pendingMutations.get(generation);
|
|
532
|
-
if (activeGeneration === generation &&
|
|
533
|
-
pendingMutation &&
|
|
534
|
-
activeFlight?.mutationId !== pendingMutation) {
|
|
535
|
-
return refreshMutationPendingResponse();
|
|
536
|
-
}
|
|
537
|
-
let currentTokens = activeGeneration === generation ? await provider.tokens() : undefined;
|
|
538
|
-
const latestGeneration = await provider.generation();
|
|
539
|
-
this.observeAuthoritativeGeneration(latestGeneration);
|
|
540
|
-
if (latestGeneration !== generation) {
|
|
541
|
-
this.retire(generation);
|
|
542
|
-
currentTokens = undefined;
|
|
543
|
-
}
|
|
544
|
-
if (this.stateRevision !== revisionBeforeReads)
|
|
545
|
-
continue;
|
|
546
|
-
// Re-check both identities after every storage await. An owner can
|
|
547
|
-
// abort while this caller reads tokens, leaving only the mutation
|
|
548
|
-
// marker; a reset can likewise retire this caller's captured epoch.
|
|
549
|
-
const existing = this.flights.get(generation);
|
|
550
|
-
const latestPendingMutation = this.pendingMutations.get(generation);
|
|
551
|
-
if (latestGeneration === generation &&
|
|
552
|
-
latestPendingMutation &&
|
|
553
|
-
existing?.mutationId !== latestPendingMutation) {
|
|
554
|
-
return refreshMutationPendingResponse();
|
|
555
|
-
}
|
|
556
|
-
// This flow may have read a token just before another flow saved its
|
|
557
|
-
// rotation. Replaying the retired token would recreate the race after
|
|
558
|
-
// the first network response. Give the SDK the already-saved rotating
|
|
559
|
-
// credential instead. A tokenless current value cannot do that: the
|
|
560
|
-
// SDK would merge the requested old refresh token back into it.
|
|
561
|
-
if (currentTokens?.refresh_token &&
|
|
562
|
-
(currentTokens.refresh_token !== requestedRefreshToken ||
|
|
563
|
-
provider.refreshBasisChanged(currentTokens, generation))) {
|
|
564
|
-
return Response.json(currentTokens);
|
|
565
|
-
}
|
|
566
|
-
if (currentTokens?.refresh_token !== requestedRefreshToken) {
|
|
567
|
-
return Response.json({
|
|
568
|
-
error: "invalid_grant",
|
|
569
|
-
error_description: "Refresh token is no longer active.",
|
|
570
|
-
}, { status: 400 });
|
|
571
|
-
}
|
|
572
|
-
if (existing) {
|
|
573
|
-
const outcome = await waitForRefreshFlight(existing, requestSignal);
|
|
209
|
+
const epoch = await waiting.run(() => provider.flowEpoch());
|
|
210
|
+
const requested = requestedRefreshToken(init);
|
|
211
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
212
|
+
if (signal?.aborted)
|
|
213
|
+
throw aborted(signal);
|
|
214
|
+
const joined = this.flights.get(epoch);
|
|
215
|
+
if (joined) {
|
|
216
|
+
const outcome = await waitForRefreshFlight(joined, signal, waiting.signal);
|
|
574
217
|
if (outcome.status === "failed") {
|
|
575
|
-
// A joined caller inherits the owner's verdict, so every scope on
|
|
576
|
-
// one flight ends the same way: auth_required or a retryable outage.
|
|
577
218
|
provider.recordRefreshFailure(outcome.verdict);
|
|
578
219
|
throw outcome.error;
|
|
579
220
|
}
|
|
580
|
-
if (outcome.status === "refreshed")
|
|
581
|
-
|
|
582
|
-
const refreshedTokens = activeGeneration === generation
|
|
583
|
-
? await provider.tokens()
|
|
584
|
-
: undefined;
|
|
585
|
-
if (refreshedTokens?.refresh_token) {
|
|
586
|
-
return Response.json(refreshedTokens);
|
|
587
|
-
}
|
|
588
|
-
}
|
|
221
|
+
if (outcome.status === "refreshed")
|
|
222
|
+
return provider.adoptRefresh(outcome.tokens);
|
|
589
223
|
continue;
|
|
590
224
|
}
|
|
591
|
-
//
|
|
592
|
-
|
|
593
|
-
if (
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
const commit = ({ response, outcome, }) => {
|
|
619
|
-
if (outcome.failure) {
|
|
620
|
-
// Failed responses never reach a successful saveTokens callback.
|
|
621
|
-
// Give current waiters a bounded failure now, carrying the
|
|
622
|
-
// verdict, and hand the SDK an answer it classifies the same way.
|
|
623
|
-
provider.recordRefreshFailure(outcome.verdict);
|
|
624
|
-
// Drop a refused grant while the flight still stands: a caller
|
|
625
|
-
// arriving meanwhile joins it instead of redeeming the dead token
|
|
626
|
-
// again, and no later request or isolate can replay it.
|
|
627
|
-
const discard = outcome.verdict.kind === "dead"
|
|
628
|
-
? Effect.promise(() => provider.discardRefusedGrant(requestedRefreshToken, generation))
|
|
629
|
-
: Effect.void;
|
|
630
|
-
return discard.pipe(Effect.map(() => {
|
|
631
|
-
this.fail(generation, flight, outcome.failure, outcome.verdict);
|
|
632
|
-
return outcome.forSdk;
|
|
633
|
-
}));
|
|
634
|
-
}
|
|
635
|
-
// A valid response means the authorization server has consumed the
|
|
636
|
-
// rotating refresh token. The SDK's saveTokens normally persists it;
|
|
637
|
-
// keep this copy so `fail` can persist it instead if that callback
|
|
638
|
-
// never comes (the SDK merges the old refresh token the same way).
|
|
639
|
-
const rotatedRefreshToken = outcome.tokens.refresh_token ?? currentRefreshToken;
|
|
640
|
-
const accepted = {
|
|
641
|
-
...outcome.tokens,
|
|
642
|
-
...(rotatedRefreshToken !== undefined
|
|
643
|
-
? { refresh_token: rotatedRefreshToken }
|
|
644
|
-
: {}),
|
|
645
|
-
};
|
|
646
|
-
flight.acceptedTokens = accepted;
|
|
647
|
-
if (!this.markMutationPending(generation, flight)) {
|
|
648
|
-
// Already settled (the owner was cancelled first). Still write the
|
|
649
|
-
// rotation: losing it would leave a dead credential.
|
|
650
|
-
flight.recoveryPending = true;
|
|
651
|
-
detach(Effect.promise(() => flight.persist(accepted)).pipe(Effect.ensuring(Effect.sync(() => {
|
|
652
|
-
flight.recoveryPending = false;
|
|
653
|
-
this.releaseFlightPartition(flight);
|
|
654
|
-
}))));
|
|
655
|
-
return Effect.fail(new Error("OAuth refresh ended before tokens could be saved."));
|
|
656
|
-
}
|
|
657
|
-
// The SDK holds the answer from here, after this fiber is gone, so
|
|
658
|
-
// a listener watches for the owner leaving before it saves. Then
|
|
659
|
-
// `fail` persists the rotation instead.
|
|
660
|
-
if (requestSignal) {
|
|
661
|
-
flight.stopObservingOwnerAbort = whenAborted(requestSignal, () => this.fail(generation, flight, aborted(requestSignal)));
|
|
662
|
-
}
|
|
663
|
-
return Effect.succeed(response);
|
|
664
|
-
};
|
|
665
|
-
const finishAnswer = Effect.sync(() => {
|
|
666
|
-
flight.answerPending = false;
|
|
667
|
-
this.releaseFlightPartition(flight);
|
|
668
|
-
});
|
|
669
|
-
const commitAnswer = (value) => commit(value).pipe(Effect.ensuring(finishAnswer));
|
|
670
|
-
// The owner's redemption. Its abort interrupts the wait for the token
|
|
671
|
-
// endpoint and nothing after it: an answer, once it exists, is
|
|
672
|
-
// committed before anyone is released, so no caller is let go to
|
|
673
|
-
// redeem a token the server has already refused or spent. An answer
|
|
674
|
-
// that arrives after the owner left is committed in the background,
|
|
675
|
-
// as the foreground would have.
|
|
676
|
-
const redemption = Effect.uninterruptibleMask((restore) => restore(Effect.tryPromise({ try: () => answer, catch: (error) => error })).pipe(Effect.onInterrupt(() => Effect.sync(() => {
|
|
677
|
-
this.fail(generation, flight, aborted(requestSignal));
|
|
678
|
-
detach(Effect.promise(() => answer).pipe(Effect.flatMap(commitAnswer), Effect.ensuring(finishAnswer)));
|
|
679
|
-
})), Effect.catch((error) => {
|
|
680
|
-
// No answer at all — the network, or a body too large to be
|
|
681
|
-
// one — is an outage. A refusal connecta itself raised (a
|
|
682
|
-
// redirect the policy forbids) and this owner's own
|
|
683
|
-
// cancellation are not.
|
|
684
|
-
const verdict = !requestSignal?.aborted &&
|
|
685
|
-
!(error instanceof ConnectorCallError && !error.retryable)
|
|
686
|
-
? transientFailure(error instanceof OversizedRefreshResponse
|
|
687
|
-
? "answered with an oversized response"
|
|
688
|
-
: "could not be reached", undefined, error)
|
|
689
|
-
: undefined;
|
|
690
|
-
if (verdict)
|
|
691
|
-
provider.recordRefreshFailure(verdict);
|
|
692
|
-
flight.answerPending = false;
|
|
693
|
-
this.fail(generation, flight, error, verdict);
|
|
694
|
-
// Retirement may already have removed the flight from the map.
|
|
695
|
-
// Its rejected answer still finishes the retained partition work.
|
|
696
|
-
this.releaseFlightPartition(flight);
|
|
697
|
-
return Effect.fail(error);
|
|
698
|
-
}), Effect.flatMap(commitAnswer)));
|
|
699
|
-
return await runEdge(requestSignal
|
|
700
|
-
? Effect.raceAllFirst([
|
|
701
|
-
redemption,
|
|
702
|
-
fromSignal(requestSignal),
|
|
703
|
-
])
|
|
704
|
-
: redemption);
|
|
225
|
+
// Preparation reads share the same bound as later claim waits.
|
|
226
|
+
const current = await waiting.run(() => provider.storedTokens());
|
|
227
|
+
if (this.flights.has(epoch))
|
|
228
|
+
continue;
|
|
229
|
+
if (current?.refresh_token &&
|
|
230
|
+
(current.refresh_token !== requested || provider.refreshedSinceRead(current, epoch))) {
|
|
231
|
+
return provider.adoptRefresh(current);
|
|
232
|
+
}
|
|
233
|
+
if (current?.refresh_token !== requested) {
|
|
234
|
+
provider.recordRefreshFailure({ kind: "dead" });
|
|
235
|
+
return Response.json({ error: "invalid_grant", error_description: "Refresh token is no longer active." }, { status: 400 });
|
|
236
|
+
}
|
|
237
|
+
const redemption = this.redeem(provider, epoch, requested, input, init, baseFetch, signal, waiting);
|
|
238
|
+
// The refresh owns its completion after dispatch, including storage.
|
|
239
|
+
// Attach it before yielding so a cancelled Worker keeps that work alive.
|
|
240
|
+
try {
|
|
241
|
+
detach(Effect.promise(() => redemption), defer ? { waitUntil: defer } : undefined);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
// A missing or refusing runtime hook still leaves the promise running.
|
|
245
|
+
}
|
|
246
|
+
if (!signal)
|
|
247
|
+
return await redemption;
|
|
248
|
+
return await runEdge(Effect.raceAllFirst([
|
|
249
|
+
Effect.tryPromise({ try: () => redemption, catch: (error) => error }),
|
|
250
|
+
fromSignal(signal),
|
|
251
|
+
]));
|
|
705
252
|
}
|
|
706
|
-
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
succeedMutation(generation, flight) {
|
|
718
|
-
if (this.finishMutation(generation, flight)) {
|
|
719
|
-
this.successfulRefresh = { generation, identity: {} };
|
|
720
|
-
this.advanceStateRevision();
|
|
253
|
+
throw provider.refreshContended();
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
if (signal?.aborted)
|
|
257
|
+
throw aborted(signal);
|
|
258
|
+
if (waiting.signal.aborted)
|
|
259
|
+
throw provider.refreshContended();
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
waiting.close();
|
|
721
264
|
}
|
|
722
|
-
this.settle(generation, flight, { status: "refreshed" });
|
|
723
265
|
}
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
266
|
+
async redeem(provider, epoch, requested, input, init, baseFetch, signal, waiting) {
|
|
267
|
+
const flight = { outcome: Deferred.makeUnsafe() };
|
|
268
|
+
this.flights.set(epoch, flight);
|
|
269
|
+
// Pin the owner partition until the flight settles, whoever is waiting.
|
|
270
|
+
const release = this.retainPartition();
|
|
271
|
+
const settle = (outcome) => {
|
|
272
|
+
if (this.flights.get(epoch) === flight)
|
|
273
|
+
this.flights.delete(epoch);
|
|
274
|
+
release();
|
|
275
|
+
// Last, because joined fibers resume inside this call.
|
|
276
|
+
Deferred.doneUnsafe(flight.outcome, Effect.succeed(outcome));
|
|
277
|
+
};
|
|
278
|
+
const fail = (error, verdict) => {
|
|
279
|
+
if (verdict)
|
|
280
|
+
provider.recordRefreshFailure(verdict);
|
|
281
|
+
settle({ status: "failed", error, ...(verdict ? { verdict } : {}) });
|
|
282
|
+
};
|
|
283
|
+
// The authorization server this redemption answers to, fixed now.
|
|
284
|
+
const issuer = provider.refreshIssuer(epoch);
|
|
285
|
+
let lease;
|
|
286
|
+
try {
|
|
287
|
+
const claim = await provider.claimRefresh(epoch, requested, signal, waiting);
|
|
288
|
+
if (claim instanceof Response) {
|
|
289
|
+
const tokens = await waiting.run(() => provider.storedTokens());
|
|
290
|
+
if (tokens)
|
|
291
|
+
settle({ status: "refreshed", tokens });
|
|
292
|
+
else
|
|
293
|
+
settle({ status: "retired" });
|
|
294
|
+
return claim;
|
|
295
|
+
}
|
|
296
|
+
lease = claim;
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
fail(error, provider.refreshVerdict());
|
|
300
|
+
throw error;
|
|
735
301
|
}
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
302
|
+
const releaseLease = async (verdict, resolution = "ambiguous") => {
|
|
303
|
+
await provider.releaseRefresh(lease, verdict, undefined, resolution);
|
|
304
|
+
};
|
|
305
|
+
let outcome;
|
|
306
|
+
const deadline = new AbortController();
|
|
307
|
+
let dispatched = false;
|
|
308
|
+
let timer;
|
|
309
|
+
try {
|
|
310
|
+
const read = provider
|
|
311
|
+
.dispatchRefresh(lease, () => {
|
|
312
|
+
if (signal?.aborted)
|
|
313
|
+
throw aborted(signal);
|
|
314
|
+
if (init?.signal?.aborted)
|
|
315
|
+
throw aborted(init.signal);
|
|
316
|
+
dispatched = true;
|
|
317
|
+
// Preparation remains bounded through the send transition. After
|
|
318
|
+
// dispatch, caller cancellation must not prevent an answer's commit.
|
|
319
|
+
waiting.close();
|
|
320
|
+
// Start the deadline at dispatch, including the response body read.
|
|
321
|
+
timer = setTimeout(() => {
|
|
322
|
+
deadline.abort(new DOMException("OAuth refresh timed out", "TimeoutError"));
|
|
323
|
+
}, REFRESH_REQUEST_DEADLINE_MS);
|
|
324
|
+
return baseFetch(input, { ...init, redirect: "manual", signal: deadline.signal });
|
|
325
|
+
}, waiting)
|
|
326
|
+
.then((response) => {
|
|
327
|
+
if (deadline.signal.aborted) {
|
|
328
|
+
void response.body?.cancel().catch(() => { });
|
|
329
|
+
throw aborted(deadline.signal);
|
|
751
330
|
}
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
}))));
|
|
763
|
-
return;
|
|
331
|
+
return refreshResponseOutcome(response, deadline.signal);
|
|
332
|
+
});
|
|
333
|
+
// The deadline can settle the race before a late fetch/body rejects.
|
|
334
|
+
void read.catch(() => { });
|
|
335
|
+
outcome = await runEdge(Effect.raceAllFirst([
|
|
336
|
+
Effect.tryPromise({ try: () => read, catch: (error) => error }),
|
|
337
|
+
fromSignal(deadline.signal),
|
|
338
|
+
]));
|
|
339
|
+
if (deadline.signal.aborted)
|
|
340
|
+
throw aborted(deadline.signal);
|
|
764
341
|
}
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
342
|
+
catch (error) {
|
|
343
|
+
if (!dispatched) {
|
|
344
|
+
// A cancelled or timed-out unsent holder leaves a safely expiring
|
|
345
|
+
// claim. Cleanup must not retain its local flight or partition pin.
|
|
346
|
+
if (!waiting.signal.aborted && !signal?.aborted && !init?.signal?.aborted) {
|
|
347
|
+
await waiting.run(() => releaseLease()).catch(() => { });
|
|
348
|
+
if (provider.refreshVerdict()?.kind === "dead") {
|
|
349
|
+
await waiting.run(() => provider.discardRefusedGrant(requested, epoch)).catch(() => { });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const failure = !signal?.aborted && waiting.signal.aborted ? provider.refreshContended() : error;
|
|
353
|
+
fail(failure, provider.refreshVerdict());
|
|
354
|
+
throw failure;
|
|
355
|
+
}
|
|
356
|
+
// A sent request without a definitive answer may have rotated the token.
|
|
357
|
+
// Persist the refusal before dropping tokens, so interrupted cleanup or a
|
|
358
|
+
// later write of the same fingerprint cannot make it redeemable again.
|
|
359
|
+
const failure = new UnauthorizedError("OAuth refresh outcome is unknown; authorization required.");
|
|
360
|
+
await releaseLease({ kind: "dead" });
|
|
361
|
+
await provider.discardRefusedGrant(requested, epoch);
|
|
362
|
+
fail(failure, { kind: "dead" });
|
|
363
|
+
throw failure;
|
|
782
364
|
}
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
365
|
+
finally {
|
|
366
|
+
if (timer !== undefined)
|
|
367
|
+
clearTimeout(timer);
|
|
368
|
+
}
|
|
369
|
+
// An answer exists from here on, and is committed whatever the owner does.
|
|
370
|
+
if (outcome.failure) {
|
|
371
|
+
// Drop a refused grant while the flight still stands: a caller arriving
|
|
372
|
+
// meanwhile joins it instead of redeeming the dead token again.
|
|
373
|
+
await provider.discardRefusedGrant(requested, epoch);
|
|
374
|
+
await releaseLease(outcome.verdict, "resolved");
|
|
375
|
+
fail(outcome.failure, outcome.verdict);
|
|
376
|
+
return outcome.forSdk;
|
|
377
|
+
}
|
|
378
|
+
// The server consumed the old refresh token; the SDK merges it back the
|
|
379
|
+
// same way when a rotation omits one.
|
|
380
|
+
const accepted = {
|
|
381
|
+
...outcome.tokens,
|
|
382
|
+
...(outcome.tokens.refresh_token === undefined && requested !== null ? { refresh_token: requested } : {}),
|
|
383
|
+
};
|
|
384
|
+
let committed;
|
|
385
|
+
try {
|
|
386
|
+
committed = await provider.storeRefresh(epoch, requested, accepted, issuer);
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
const failure = new UnauthorizedError("OAuth refresh could not be committed; authorization required.");
|
|
390
|
+
await releaseLease({ kind: "dead" }, "resolved");
|
|
391
|
+
await provider.discardRefusedGrant(requested, epoch);
|
|
392
|
+
fail(failure, { kind: "dead" });
|
|
393
|
+
throw failure;
|
|
394
|
+
}
|
|
395
|
+
if (!committed) {
|
|
396
|
+
// Restart, Disconnect, or another consent decides the newer grant.
|
|
397
|
+
await releaseLease({ kind: "dead" }, "resolved");
|
|
398
|
+
const failure = new UnauthorizedError("OAuth refresh grant is no longer active.");
|
|
399
|
+
fail(failure, { kind: "dead" });
|
|
400
|
+
throw failure;
|
|
786
401
|
}
|
|
787
|
-
|
|
402
|
+
await provider.releaseRefresh(lease, undefined, committed, "resolved");
|
|
403
|
+
settle({ status: "refreshed", tokens: committed });
|
|
404
|
+
return provider.adoptRefresh(committed);
|
|
405
|
+
}
|
|
406
|
+
/** A reset fenced `epoch`: wake its joiners now rather than at the answer. */
|
|
407
|
+
retire(epoch) {
|
|
408
|
+
const flight = this.flights.get(epoch);
|
|
788
409
|
if (!flight)
|
|
789
410
|
return;
|
|
790
|
-
this.
|
|
411
|
+
this.flights.delete(epoch);
|
|
412
|
+
Deferred.doneUnsafe(flight.outcome, Effect.succeed({ status: "retired" }));
|
|
791
413
|
}
|
|
792
414
|
}
|
|
793
|
-
function storedOAuthValue(value) {
|
|
794
|
-
if (!value || typeof value !== "object")
|
|
795
|
-
return false;
|
|
796
|
-
const candidate = value;
|
|
797
|
-
return (candidate.connectaOAuthVersion === STORED_VALUE_VERSION &&
|
|
798
|
-
typeof candidate.generation === "string" &&
|
|
799
|
-
(candidate.issuer === undefined || typeof candidate.issuer === "string") &&
|
|
800
|
-
(candidate.binding === undefined || typeof candidate.binding === "string") &&
|
|
801
|
-
"value" in candidate);
|
|
802
|
-
}
|
|
803
|
-
function legacyStoredOAuthValue(value) {
|
|
804
|
-
if (!value || typeof value !== "object")
|
|
805
|
-
return false;
|
|
806
|
-
const candidate = value;
|
|
807
|
-
return (candidate.connectaOAuthVersion === 1 &&
|
|
808
|
-
typeof candidate.generation === "string" &&
|
|
809
|
-
"value" in candidate);
|
|
810
|
-
}
|
|
811
|
-
function sealedOAuthValue(value) {
|
|
812
|
-
if (!value || typeof value !== "object")
|
|
813
|
-
return false;
|
|
814
|
-
const candidate = value;
|
|
815
|
-
return (candidate.connectaOAuthSealed === SEALED_VALUE_VERSION &&
|
|
816
|
-
typeof candidate.sealed === "string");
|
|
817
|
-
}
|
|
818
|
-
function isModernGeneration(generation) {
|
|
819
|
-
return (generation.startsWith(ACTIVE_GENERATION_PREFIX) ||
|
|
820
|
-
generation.startsWith(RESETTING_GENERATION_PREFIX) ||
|
|
821
|
-
generation.startsWith(DISCONNECTED_GENERATION_PREFIX));
|
|
822
|
-
}
|
|
823
415
|
/**
|
|
824
|
-
*
|
|
825
|
-
*
|
|
826
|
-
*
|
|
827
|
-
*
|
|
416
|
+
* One refresh coordinator per owner partition, for one connector runtime.
|
|
417
|
+
* Long-lived enough for distinct request scopes to join one token redemption;
|
|
418
|
+
* it owns no client, transport, or request state. Production contexts carry a
|
|
419
|
+
* stable registry partition; hand-written contexts coordinate by their shared
|
|
420
|
+
* storage object instead.
|
|
828
421
|
*/
|
|
829
|
-
export function
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
422
|
+
export function refreshCoordinatorsByPartition() {
|
|
423
|
+
const coordinators = new WeakMap();
|
|
424
|
+
return (ctx) => {
|
|
425
|
+
const partition = oauthPartitionFor(ctx) ?? ctx.storage;
|
|
426
|
+
let coordinator = coordinators.get(partition);
|
|
427
|
+
if (!coordinator) {
|
|
428
|
+
coordinator = new OAuthRefreshCoordinator(() => retainOAuthPartition(partition));
|
|
429
|
+
coordinators.set(partition, coordinator);
|
|
430
|
+
}
|
|
431
|
+
return coordinator;
|
|
432
|
+
};
|
|
836
433
|
}
|
|
434
|
+
const GRANT = oauthGrantKeys.grant;
|
|
435
|
+
/** The epoch of an owner with no grant record yet. */
|
|
436
|
+
const INITIAL_EPOCH = "initial";
|
|
437
|
+
/** The epoch of a record that does not parse: readers see no grant in it. */
|
|
438
|
+
const UNREADABLE_EPOCH = "unreadable";
|
|
439
|
+
const ACTIVE_EPOCH_PREFIX = "v3:";
|
|
440
|
+
const DISCONNECTED_EPOCH_PREFIX = "disconnected:";
|
|
441
|
+
const GRANT_VERSION = 3;
|
|
442
|
+
const FLOW_VERSION = 1;
|
|
443
|
+
/** Attempts at one grant write while other writers keep winning. */
|
|
444
|
+
const MAX_GRANT_WRITES = 32;
|
|
837
445
|
/**
|
|
838
|
-
*
|
|
839
|
-
*
|
|
840
|
-
*
|
|
841
|
-
*
|
|
842
|
-
*
|
|
843
|
-
*
|
|
446
|
+
* How long a pending authorization URL may be handed out again instead of
|
|
447
|
+
* starting a fresh flow. The authorization server's half of it expires: a
|
|
448
|
+
* pushed request URI lives seconds to minutes (RFC 9126), and login
|
|
449
|
+
* transactions are commonly held for minutes. Past this age a reissued URL
|
|
450
|
+
* more likely lands on an expired session than on consent, and a fresh start
|
|
451
|
+
* costs one authorization request, not a registration. It sits inside the
|
|
452
|
+
* flow record's fifteen minutes.
|
|
844
453
|
*/
|
|
845
|
-
|
|
846
|
-
|
|
454
|
+
const PENDING_AUTHORIZATION_MAX_AGE_MS = 10 * 60 * 1000;
|
|
455
|
+
async function discoveryBinding(state) {
|
|
456
|
+
return state
|
|
457
|
+
? {
|
|
458
|
+
issuer: discoveryIssuer(state),
|
|
459
|
+
tokenEndpoint: state.authorizationServerMetadata?.token_endpoint,
|
|
460
|
+
...(state.authorizationServerMetadata?.authorization_response_iss_parameter_supported === true
|
|
461
|
+
? { issRequired: true }
|
|
462
|
+
: {}),
|
|
463
|
+
discovery: await oauthStateDigest(JSON.stringify(state)),
|
|
464
|
+
}
|
|
465
|
+
: {};
|
|
847
466
|
}
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
return times;
|
|
853
|
-
let parsed;
|
|
467
|
+
function plainObject(value) {
|
|
468
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
469
|
+
}
|
|
470
|
+
function parsed(raw) {
|
|
854
471
|
try {
|
|
855
|
-
|
|
472
|
+
return JSON.parse(raw);
|
|
856
473
|
}
|
|
857
474
|
catch {
|
|
858
|
-
return
|
|
859
|
-
}
|
|
860
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
861
|
-
return times;
|
|
862
|
-
}
|
|
863
|
-
for (const [generation, retiredAt] of Object.entries(parsed)) {
|
|
864
|
-
if (typeof retiredAt === "number" && Number.isFinite(retiredAt)) {
|
|
865
|
-
times.set(generation, retiredAt);
|
|
866
|
-
}
|
|
475
|
+
return undefined;
|
|
867
476
|
}
|
|
868
|
-
|
|
477
|
+
}
|
|
478
|
+
function storedGrant(raw) {
|
|
479
|
+
const value = parsed(raw);
|
|
480
|
+
return plainObject(value) &&
|
|
481
|
+
value.connectaOAuth === GRANT_VERSION &&
|
|
482
|
+
typeof value.epoch === "string" &&
|
|
483
|
+
(value.flow === undefined || typeof value.flow === "string") &&
|
|
484
|
+
(value.sealed === undefined || typeof value.sealed === "string")
|
|
485
|
+
? value
|
|
486
|
+
: undefined;
|
|
487
|
+
}
|
|
488
|
+
/** The epoch a raw grant record names, without opening its body. */
|
|
489
|
+
function epochOf(raw) {
|
|
490
|
+
if (raw === null)
|
|
491
|
+
return INITIAL_EPOCH;
|
|
492
|
+
return storedGrant(raw)?.epoch ?? UNREADABLE_EPOCH;
|
|
493
|
+
}
|
|
494
|
+
/** Nothing here trusts stored bytes: keep only what has the expected shape. */
|
|
495
|
+
function grantBody(value) {
|
|
496
|
+
if (!plainObject(value))
|
|
497
|
+
return {};
|
|
498
|
+
const { issuer, client, tokens, discovery, refreshConsent } = value;
|
|
499
|
+
return {
|
|
500
|
+
...(typeof issuer === "string" ? { issuer } : {}),
|
|
501
|
+
...(plainObject(client) && plainObject(client.value) && typeof client.value.client_id === "string"
|
|
502
|
+
? {
|
|
503
|
+
client: {
|
|
504
|
+
value: client.value,
|
|
505
|
+
...(typeof client.binding === "string" ? { binding: client.binding } : {}),
|
|
506
|
+
...(client.carried === true ? { carried: true } : {}),
|
|
507
|
+
...(client.registrationPath === "cimd" ||
|
|
508
|
+
client.registrationPath === "dcr" ||
|
|
509
|
+
client.registrationPath === "static"
|
|
510
|
+
? { registrationPath: client.registrationPath }
|
|
511
|
+
: {}),
|
|
512
|
+
},
|
|
513
|
+
}
|
|
514
|
+
: {}),
|
|
515
|
+
...(plainObject(tokens) && typeof tokens.access_token === "string"
|
|
516
|
+
? { tokens: tokens }
|
|
517
|
+
: {}),
|
|
518
|
+
...(discoveryIssuer(discovery) !== undefined ? { discovery: discovery } : {}),
|
|
519
|
+
...(plainObject(refreshConsent) &&
|
|
520
|
+
typeof refreshConsent.digest === "string" &&
|
|
521
|
+
typeof refreshConsent.resolution === "string"
|
|
522
|
+
? { refreshConsent: { digest: refreshConsent.digest, resolution: refreshConsent.resolution } }
|
|
523
|
+
: {}),
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
/** What an invalidation compares: the value exactly as this flow saw it. */
|
|
527
|
+
function fingerprint(value) {
|
|
528
|
+
return value === undefined ? undefined : JSON.stringify(value);
|
|
529
|
+
}
|
|
530
|
+
function isDisconnectedEpoch(epoch) {
|
|
531
|
+
return epoch.startsWith(DISCONNECTED_EPOCH_PREFIX);
|
|
869
532
|
}
|
|
870
533
|
/**
|
|
871
|
-
* OAuthClientProvider
|
|
872
|
-
*
|
|
534
|
+
* OAuthClientProvider over one owner's grant record and its consents' flow
|
|
535
|
+
* records, in one connector's namespace.
|
|
873
536
|
*
|
|
874
|
-
* Headless
|
|
875
|
-
*
|
|
876
|
-
*
|
|
877
|
-
*
|
|
537
|
+
* Headless: `redirectToAuthorization()` cannot navigate a user agent, so it
|
|
538
|
+
* stores the consent; the registry surfaces its URL, and the
|
|
539
|
+
* `/oauth/callback/<id>` route drives the exchange.
|
|
540
|
+
*
|
|
541
|
+
* A flow — a connect attempt, an `api()` call or start, or a callback — binds
|
|
542
|
+
* to one epoch. Its reads fail once another epoch is live, and its writes are
|
|
543
|
+
* compare-and-sets against the record it read, so a restart or disconnect,
|
|
544
|
+
* which replaces the epoch, fences every flow that began before it.
|
|
878
545
|
*/
|
|
879
546
|
export class KvOAuthProvider {
|
|
880
547
|
connectorId;
|
|
@@ -887,20 +554,40 @@ export class KvOAuthProvider {
|
|
|
887
554
|
clientBinding;
|
|
888
555
|
onReset;
|
|
889
556
|
scope;
|
|
557
|
+
refreshNow;
|
|
558
|
+
clientOptions;
|
|
890
559
|
clientMetadataUrl;
|
|
560
|
+
addClientAuthentication;
|
|
561
|
+
/** The epoch this provider's flow reads and writes in, once known. */
|
|
562
|
+
epoch;
|
|
563
|
+
/** Set when a flow binds: from then on a replaced epoch fails the flow. */
|
|
564
|
+
bound = false;
|
|
565
|
+
/** The consent being started, held until `redirectToAuthorization` stores it. */
|
|
566
|
+
consentDiscovery;
|
|
567
|
+
consent = {};
|
|
568
|
+
/** The consent this provider stored, for the start that asked for it. */
|
|
569
|
+
published;
|
|
570
|
+
/** The consent a matching `verifyState` found, which the exchange claims. */
|
|
571
|
+
callback;
|
|
572
|
+
/** Set by `bindFlow`: this provider exchanges a code. */
|
|
573
|
+
codeExchange = false;
|
|
574
|
+
/** The token endpoint's refusal of this exchange's code, for the SDK's retry. */
|
|
575
|
+
exchangeRefusal;
|
|
576
|
+
/** Resolution observed when the code answer completed, before the SDK saves. */
|
|
577
|
+
exchangeConsent;
|
|
891
578
|
/**
|
|
892
|
-
* The
|
|
893
|
-
*
|
|
894
|
-
* becoming readable under the new generation.
|
|
579
|
+
* The client and tokens as this flow last saw them. An invalidation removes
|
|
580
|
+
* only these, never a value another flow wrote meanwhile.
|
|
895
581
|
*/
|
|
896
|
-
|
|
897
|
-
refreshFlight;
|
|
582
|
+
seen = {};
|
|
898
583
|
/** How the last refresh this provider took part in failed, if it did. */
|
|
899
584
|
refreshFailure;
|
|
900
|
-
/**
|
|
585
|
+
/** The tokens this flow's issuer-aware read handed the SDK to refresh. */
|
|
901
586
|
refreshBasis;
|
|
587
|
+
/** The access token of a refresh the coordinator already stored. */
|
|
588
|
+
storedRefresh;
|
|
902
589
|
constructor(connectorId, storage, redirectUri, refreshCoordinator, allowAuthorization = true,
|
|
903
|
-
/** Present when the deployment's vault can seal;
|
|
590
|
+
/** Present when the deployment's vault can seal; the grant is then ciphertext. */
|
|
904
591
|
sealer,
|
|
905
592
|
/** Request ownership for a start; a timed-out SDK hook may still resume. */
|
|
906
593
|
signal,
|
|
@@ -909,7 +596,9 @@ export class KvOAuthProvider {
|
|
|
909
596
|
/** Request-local observer so the operator route drains every reset it began. */
|
|
910
597
|
onReset,
|
|
911
598
|
/** SDK uses this only when the authorization server advertises support. */
|
|
912
|
-
clientMetadataUrl, scope
|
|
599
|
+
clientMetadataUrl, scope,
|
|
600
|
+
/** Isolate-local clock, used only for claims that have never been sent. */
|
|
601
|
+
refreshNow = Date.now, clientOptions) {
|
|
913
602
|
this.connectorId = connectorId;
|
|
914
603
|
this.storage = storage;
|
|
915
604
|
this.redirectUri = redirectUri;
|
|
@@ -920,837 +609,1271 @@ export class KvOAuthProvider {
|
|
|
920
609
|
this.clientBinding = clientBinding;
|
|
921
610
|
this.onReset = onReset;
|
|
922
611
|
this.scope = scope;
|
|
612
|
+
this.refreshNow = refreshNow;
|
|
613
|
+
this.clientOptions = clientOptions;
|
|
923
614
|
if (clientMetadataUrl !== undefined)
|
|
924
615
|
this.clientMetadataUrl = clientMetadataUrl;
|
|
616
|
+
const client = clientOptions?.client;
|
|
617
|
+
if (client)
|
|
618
|
+
this.addClientAuthentication = (headers, params) => authenticateRemoteClient(client, headers, params);
|
|
925
619
|
}
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
* Called by the connector once per connect, before c.connect(). The callback
|
|
929
|
-
* path captures the generation stored beside its verified state instead.
|
|
930
|
-
*/
|
|
931
|
-
captureGeneration(gen) {
|
|
932
|
-
this.capturedGeneration = gen;
|
|
933
|
-
}
|
|
934
|
-
/** The generation captured for this flow, before a concurrent reset. */
|
|
935
|
-
async flowGeneration() {
|
|
936
|
-
return this.capturedGeneration ?? this.generation();
|
|
937
|
-
}
|
|
938
|
-
/** @internal Record the refresh attempt this provider owns. */
|
|
939
|
-
captureRefreshFlight(generation, flight) {
|
|
940
|
-
this.refreshFlight = { generation, flight };
|
|
620
|
+
get redirectUrl() {
|
|
621
|
+
return this.redirectUri;
|
|
941
622
|
}
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
623
|
+
get clientMetadata() {
|
|
624
|
+
const client = this.clientOptions?.client;
|
|
625
|
+
return downstreamClientMetadata(this.redirectUri, this.scope, this.clientOptions?.name, client ? remoteClientAuthMethod(client) : "none");
|
|
626
|
+
}
|
|
627
|
+
// --- the grant record ---------------------------------------------------
|
|
628
|
+
/** The grant's additional authenticated data: record and epoch. */
|
|
629
|
+
grantLabel(epoch) {
|
|
630
|
+
return `${GRANT}:${epoch}`;
|
|
631
|
+
}
|
|
632
|
+
async encodeGrant(grant) {
|
|
633
|
+
this.assertCredentialFree({ issuer: grant.body.issuer, discovery: grant.body.discovery }, grant.body);
|
|
634
|
+
const stored = {
|
|
635
|
+
connectaOAuth: GRANT_VERSION,
|
|
636
|
+
epoch: grant.epoch,
|
|
637
|
+
...(grant.cleanupPending ? { cleanupPending: true } : {}),
|
|
638
|
+
...(grant.flow !== undefined ? { flow: grant.flow } : {}),
|
|
639
|
+
};
|
|
640
|
+
if (Object.keys(grant.body).length > 0) {
|
|
641
|
+
if (this.sealer) {
|
|
642
|
+
stored.sealed = await this.sealer.seal(this.grantLabel(grant.epoch), JSON.stringify(grant.body));
|
|
643
|
+
}
|
|
644
|
+
else {
|
|
645
|
+
stored.body = grant.body;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
return JSON.stringify(stored);
|
|
950
649
|
}
|
|
951
650
|
/**
|
|
952
|
-
*
|
|
953
|
-
*
|
|
954
|
-
*
|
|
955
|
-
* `compareAndSet`, the delete is conditional on the exact raw string read
|
|
956
|
-
* (ciphertext, when sealed), so a write landing in between wins. A store
|
|
957
|
-
* without it (Workers KV) gets the read followed by a plain delete, and a
|
|
958
|
-
* write landing between the two can still be lost — the same window the
|
|
959
|
-
* SDK's own invalidation has. Best effort: if storage fails, the next
|
|
960
|
-
* refresh is refused again and ends the same way.
|
|
651
|
+
* Open a stored grant. A body that does not open — tampered, under a
|
|
652
|
+
* rotated key, moved from another connector, owner, or epoch, or sealed
|
|
653
|
+
* with no sealer configured — reads as empty: authorization again.
|
|
961
654
|
*/
|
|
962
|
-
async
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
655
|
+
async openGrant(raw) {
|
|
656
|
+
if (raw === null)
|
|
657
|
+
return { epoch: INITIAL_EPOCH, body: {} };
|
|
658
|
+
const stored = storedGrant(raw);
|
|
659
|
+
if (!stored)
|
|
660
|
+
return { epoch: UNREADABLE_EPOCH, body: {} };
|
|
661
|
+
let body = stored.body;
|
|
662
|
+
if (stored.sealed !== undefined) {
|
|
663
|
+
body = undefined;
|
|
664
|
+
try {
|
|
665
|
+
if (this.sealer)
|
|
666
|
+
body = parsed(await this.sealer.open(this.grantLabel(stored.epoch), stored.sealed));
|
|
973
667
|
}
|
|
974
|
-
|
|
975
|
-
|
|
668
|
+
catch {
|
|
669
|
+
this.sealer?.warn(`[connecta] connector "${this.connectorId}" has a sealed OAuth grant ` +
|
|
670
|
+
"the configured vault cannot open; treating it as absent, so the " +
|
|
671
|
+
"connector needs authorization again. A changed vault key or " +
|
|
672
|
+
"tampered storage causes this.");
|
|
976
673
|
}
|
|
977
674
|
}
|
|
978
|
-
|
|
979
|
-
|
|
675
|
+
return {
|
|
676
|
+
epoch: stored.epoch,
|
|
677
|
+
...(stored.cleanupPending ? { cleanupPending: true } : {}),
|
|
678
|
+
...(stored.flow !== undefined ? { flow: stored.flow } : {}),
|
|
679
|
+
body: grantBody(body),
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* The grant as stored now, migrating layout 2 on first sight. A plaintext
|
|
684
|
+
* body, written before the vault could seal, is sealed where it lies by
|
|
685
|
+
* compare-and-set; a failure leaves it readable for the next write to seal.
|
|
686
|
+
*/
|
|
687
|
+
async readGrant() {
|
|
688
|
+
let raw = await this.storage.get(GRANT);
|
|
689
|
+
if (raw === null)
|
|
690
|
+
raw = await this.migrate();
|
|
691
|
+
if (raw !== null && storedGrant(raw)?.cleanupPending)
|
|
692
|
+
raw = await this.cleanupV2(raw);
|
|
693
|
+
const grant = await this.openGrant(raw);
|
|
694
|
+
if (raw !== null && this.sealer && storedGrant(raw)?.body !== undefined && !this.signal?.aborted) {
|
|
695
|
+
const sealed = await this.encodeGrant(grant);
|
|
696
|
+
if (await this.storage.compareAndSet(GRANT, raw, sealed).catch(() => false))
|
|
697
|
+
raw = sealed;
|
|
980
698
|
}
|
|
699
|
+
return { raw, grant };
|
|
981
700
|
}
|
|
982
701
|
/**
|
|
983
|
-
*
|
|
984
|
-
*
|
|
985
|
-
* falls through to authorization on any refresh it could not parse, and
|
|
986
|
-
* answering that with `UnauthorizedError` would tell the agent a working
|
|
987
|
-
* grant needs consent.
|
|
702
|
+
* Layout 2's grant, written once as this owner's record. Whichever request
|
|
703
|
+
* writes first wins; the layout 2 keys are deleted once a record exists.
|
|
988
704
|
*/
|
|
989
|
-
|
|
990
|
-
const
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
...(cause !== undefined ? { cause } : {}),
|
|
1000
|
-
...(failure.retryAfterMs !== undefined
|
|
1001
|
-
? { retryAfterMs: failure.retryAfterMs }
|
|
1002
|
-
: {}),
|
|
1003
|
-
...(cause instanceof ConnectorCallError && cause.details
|
|
1004
|
-
? { details: cause.details }
|
|
1005
|
-
: {}),
|
|
705
|
+
async migrate() {
|
|
706
|
+
const found = await readV2Grant(this.storage, this.sealer);
|
|
707
|
+
// A request that has already left writes nothing, this included.
|
|
708
|
+
if (!found || this.signal?.aborted)
|
|
709
|
+
return null;
|
|
710
|
+
const prefix = found.disconnected ? DISCONNECTED_EPOCH_PREFIX : ACTIVE_EPOCH_PREFIX;
|
|
711
|
+
const encoded = await this.encodeGrant({
|
|
712
|
+
epoch: `${prefix}${crypto.randomUUID()}`,
|
|
713
|
+
body: found.body,
|
|
714
|
+
cleanupPending: true,
|
|
1006
715
|
});
|
|
716
|
+
let written;
|
|
717
|
+
try {
|
|
718
|
+
written = await this.storage.compareAndSet(GRANT, null, encoded);
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
721
|
+
// A store's own error can quote the value it refused.
|
|
722
|
+
throw this.credentialWriteError();
|
|
723
|
+
}
|
|
724
|
+
const raw = written ? encoded : await this.storage.get(GRANT);
|
|
725
|
+
return raw;
|
|
1007
726
|
}
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
727
|
+
/** Deletion is retried on every read, including reads after a reset. */
|
|
728
|
+
async cleanupV2(raw) {
|
|
729
|
+
try {
|
|
730
|
+
if (!(await deleteV2Keys(this.storage)))
|
|
731
|
+
return raw;
|
|
732
|
+
const grant = await this.openGrant(raw);
|
|
733
|
+
delete grant.cleanupPending;
|
|
734
|
+
const cleared = await this.encodeGrant(grant);
|
|
735
|
+
if (await this.storage.compareAndSet(GRANT, raw, cleared))
|
|
736
|
+
return cleared;
|
|
737
|
+
return this.storage.get(GRANT);
|
|
738
|
+
}
|
|
739
|
+
catch {
|
|
740
|
+
// The durable marker survives both deletion and marker-clear failures.
|
|
741
|
+
return raw;
|
|
1013
742
|
}
|
|
1014
743
|
}
|
|
1015
|
-
/**
|
|
1016
|
-
|
|
1017
|
-
const
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
basis.refreshToken !== current.refresh_token ||
|
|
1022
|
-
basis.successIdentity !==
|
|
1023
|
-
this.refreshCoordinator?.successfulRefreshIdentity(generation)));
|
|
744
|
+
/** The grant a bound flow reads: a replaced epoch fails it. */
|
|
745
|
+
async boundGrant() {
|
|
746
|
+
const { grant } = await this.readGrant();
|
|
747
|
+
if (this.bound && grant.epoch !== this.epoch)
|
|
748
|
+
throw this.flowSuperseded();
|
|
749
|
+
return grant;
|
|
1024
750
|
}
|
|
1025
751
|
/**
|
|
1026
|
-
* The
|
|
1027
|
-
*
|
|
1028
|
-
*
|
|
752
|
+
* The one way a grant changes: read, change, compare-and-set, again while
|
|
753
|
+
* other writers win. The write belongs to `epoch` (default: the flow's,
|
|
754
|
+
* captured now if unset) and lands nowhere once another is live: `quiet`
|
|
755
|
+
* drops it silently, otherwise a bound flow fails as superseded. `commit`
|
|
756
|
+
* writes after the request's abort too. `change` returning undefined
|
|
757
|
+
* leaves the grant as it is.
|
|
1029
758
|
*/
|
|
1030
|
-
async
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
return isModernGeneration(generation) || issuer !== undefined
|
|
1052
|
-
? JSON.stringify(stored)
|
|
1053
|
-
: serializeLegacy(value);
|
|
1054
|
-
}, undefined, commitAcceptedRefresh);
|
|
1055
|
-
}
|
|
1056
|
-
/**
|
|
1057
|
-
* The generation fence every write goes through: refuse unless `generation`
|
|
1058
|
-
* is still the live, writable epoch, write (sealed when the key holds a
|
|
1059
|
-
* credential and a sealer exists), then clean up if the epoch moved under
|
|
1060
|
-
* the write. With `expected`, the write also requires the physical key to
|
|
1061
|
-
* still hold exactly that raw value, so re-encoding cannot resurrect a value
|
|
1062
|
-
* another request replaced in the meantime.
|
|
1063
|
-
*/
|
|
1064
|
-
async storeInGeneration(key, generation, serialize, expected, commitAcceptedRefresh = false) {
|
|
1065
|
-
if ((!commitAcceptedRefresh && this.signal?.aborted) ||
|
|
1066
|
-
generation.startsWith(RESETTING_GENERATION_PREFIX) ||
|
|
1067
|
-
generation.startsWith(DISCONNECTED_GENERATION_PREFIX) ||
|
|
1068
|
-
(await this.generation()) !== generation) {
|
|
1069
|
-
return;
|
|
1070
|
-
}
|
|
1071
|
-
const physicalKey = oauthValueStorageKey(key, generation);
|
|
1072
|
-
const plaintext = serialize();
|
|
1073
|
-
const serialized = this.sealer && SEALED_OAUTH_KEYS.has(key)
|
|
1074
|
-
? JSON.stringify({
|
|
1075
|
-
connectaOAuthSealed: SEALED_VALUE_VERSION,
|
|
1076
|
-
sealed: await this.sealer.seal(physicalKey, plaintext),
|
|
1077
|
-
})
|
|
1078
|
-
: plaintext;
|
|
1079
|
-
if (!commitAcceptedRefresh && this.signal?.aborted)
|
|
1080
|
-
return;
|
|
1081
|
-
if (expected === undefined) {
|
|
1082
|
-
await this.storage.set(physicalKey, serialized);
|
|
1083
|
-
}
|
|
1084
|
-
else if (!(await this.replaceExactly(physicalKey, expected, serialized))) {
|
|
1085
|
-
return;
|
|
1086
|
-
}
|
|
1087
|
-
// If reset landed after the pre-write check and completed its cleanup
|
|
1088
|
-
// before this set, remove the now-unreachable residue ourselves. The epoch
|
|
1089
|
-
// key already provides correctness; this second check is physical hygiene.
|
|
1090
|
-
const current = await this.generation();
|
|
1091
|
-
if (current !== generation || (!commitAcceptedRefresh && this.signal?.aborted)) {
|
|
759
|
+
async updateGrant(change, options = {}) {
|
|
760
|
+
for (let attempt = 0; attempt < MAX_GRANT_WRITES; attempt++) {
|
|
761
|
+
if (!options.commit && this.signal?.aborted)
|
|
762
|
+
return false;
|
|
763
|
+
const { raw, grant } = await this.readGrant();
|
|
764
|
+
const epoch = options.epoch ?? (this.epoch ??= grant.epoch);
|
|
765
|
+
if (grant.epoch !== epoch) {
|
|
766
|
+
if (options.quiet || !this.bound)
|
|
767
|
+
return false;
|
|
768
|
+
throw this.flowSuperseded();
|
|
769
|
+
}
|
|
770
|
+
// A disconnect's epoch holds nothing until an explicit start replaces it.
|
|
771
|
+
if (isDisconnectedEpoch(epoch))
|
|
772
|
+
return false;
|
|
773
|
+
const next = change(grant);
|
|
774
|
+
if (next === undefined)
|
|
775
|
+
return true;
|
|
776
|
+
const encoded = await this.encodeGrant(next);
|
|
777
|
+
if (!options.commit && this.signal?.aborted)
|
|
778
|
+
return false;
|
|
779
|
+
let written;
|
|
1092
780
|
try {
|
|
1093
|
-
|
|
1094
|
-
// could have written this key while the old storage set was pending.
|
|
1095
|
-
if (this.storage.compareAndSet) {
|
|
1096
|
-
await this.storage.compareAndSet(physicalKey, serialized, null);
|
|
1097
|
-
}
|
|
1098
|
-
else if ((await this.storage.get(physicalKey)) === serialized) {
|
|
1099
|
-
await this.storage.delete(physicalKey);
|
|
1100
|
-
}
|
|
781
|
+
written = await this.storage.compareAndSet(GRANT, raw, encoded);
|
|
1101
782
|
}
|
|
1102
783
|
catch {
|
|
1103
|
-
//
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
// the remaining physical-hygiene boundary.
|
|
1111
|
-
}
|
|
784
|
+
// A store's own error can quote the value it refused.
|
|
785
|
+
throw this.credentialWriteError();
|
|
786
|
+
}
|
|
787
|
+
if (written) {
|
|
788
|
+
if (next.epoch !== grant.epoch)
|
|
789
|
+
this.epoch = next.epoch;
|
|
790
|
+
return true;
|
|
1112
791
|
}
|
|
1113
792
|
}
|
|
793
|
+
throw this.credentialWriteError();
|
|
794
|
+
}
|
|
795
|
+
/** Store a client or tokens for `issuer`'s grant, replacing another server's. */
|
|
796
|
+
async writeCredential(field, value, issuer, refreshConsent) {
|
|
797
|
+
await this.updateGrant((grant) => {
|
|
798
|
+
const body = issuer !== undefined && grant.body.issuer !== issuer ? { issuer } : { ...grant.body };
|
|
799
|
+
if (field === "client")
|
|
800
|
+
body.client = value;
|
|
801
|
+
else {
|
|
802
|
+
body.tokens = value;
|
|
803
|
+
delete body.refreshConsent;
|
|
804
|
+
if (refreshConsent)
|
|
805
|
+
body.refreshConsent = refreshConsent;
|
|
806
|
+
}
|
|
807
|
+
return this.withIssuer(grant, body);
|
|
808
|
+
});
|
|
809
|
+
// A flow may invalidate what it wrote itself.
|
|
810
|
+
this.seen[field] = fingerprint(field === "client" ? value.value : value);
|
|
1114
811
|
}
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
812
|
+
withIssuer(grant, body) {
|
|
813
|
+
if (grant.body.issuer !== undefined && grant.body.issuer !== body.issuer) {
|
|
814
|
+
if (this.codeExchange)
|
|
815
|
+
throw this.flowSuperseded();
|
|
816
|
+
return {
|
|
817
|
+
epoch: `${ACTIVE_EPOCH_PREFIX}${crypto.randomUUID()}`,
|
|
818
|
+
...(grant.cleanupPending ? { cleanupPending: true } : {}),
|
|
819
|
+
body,
|
|
820
|
+
};
|
|
1123
821
|
}
|
|
1124
|
-
|
|
1125
|
-
return false;
|
|
1126
|
-
await this.storage.set(physicalKey, next);
|
|
1127
|
-
return true;
|
|
822
|
+
return { ...grant, body };
|
|
1128
823
|
}
|
|
1129
|
-
/**
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
* sealed state with no sealer configured — reads as absent, which fails
|
|
1133
|
-
* closed to reauthorization.
|
|
1134
|
-
*/
|
|
1135
|
-
async openValue(key, physicalKey, sealed) {
|
|
1136
|
-
if (!this.sealer || !SEALED_OAUTH_KEYS.has(key))
|
|
1137
|
-
return undefined;
|
|
1138
|
-
try {
|
|
1139
|
-
return await this.sealer.open(physicalKey, sealed);
|
|
1140
|
-
}
|
|
1141
|
-
catch {
|
|
1142
|
-
this.sealer.warn(`[connecta] connector "${this.connectorId}" has sealed OAuth state ` +
|
|
1143
|
-
`(${key}) the configured vault cannot open; treating it as absent, ` +
|
|
1144
|
-
"so the connector needs authorization again. A changed vault key " +
|
|
1145
|
-
"or tampered storage causes this.");
|
|
1146
|
-
return undefined;
|
|
1147
|
-
}
|
|
824
|
+
/** Static clients bind to deployment configuration without storing secrets. */
|
|
825
|
+
configuredAuthorizationBinding() {
|
|
826
|
+
return undefined;
|
|
1148
827
|
}
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
catch {
|
|
1159
|
-
this.sealer?.warn(`[connecta] connector "${this.connectorId}" could not seal plaintext ` +
|
|
1160
|
-
`OAuth state (${key}); it stays readable and is sealed on its next write.`);
|
|
1161
|
-
}
|
|
828
|
+
async bindingForGrant(grant) {
|
|
829
|
+
const configured = this.configuredAuthorizationBinding();
|
|
830
|
+
return configured
|
|
831
|
+
? { ...configured, issuer: grant.body.issuer }
|
|
832
|
+
: {
|
|
833
|
+
...(await discoveryBinding(grant.body.discovery)),
|
|
834
|
+
issuer: grant.body.issuer,
|
|
835
|
+
clientId: grant.body.client?.value.client_id,
|
|
836
|
+
};
|
|
1162
837
|
}
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
* closed. With a sealer, a credential found in plaintext is returned and
|
|
1168
|
-
* then sealed where it lies.
|
|
1169
|
-
*/
|
|
1170
|
-
async readValue(key, parseLegacy) {
|
|
1171
|
-
const read = await this.readStoredValue(key, parseLegacy);
|
|
1172
|
-
if (!read)
|
|
1173
|
-
return undefined;
|
|
1174
|
-
if (read.plaintextCredential) {
|
|
1175
|
-
await this.sealInPlace(key, read.stored.generation, read.raw);
|
|
838
|
+
async checkFlowBinding(grant) {
|
|
839
|
+
const binding = this.callback?.flow.binding;
|
|
840
|
+
if (!binding || fingerprint(binding) !== fingerprint(await this.bindingForGrant(grant))) {
|
|
841
|
+
throw this.flowSuperseded();
|
|
1176
842
|
}
|
|
1177
|
-
return read.stored;
|
|
1178
843
|
}
|
|
1179
|
-
/**
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
const raw = await this.storage.get(physicalKey);
|
|
1187
|
-
if (raw === null)
|
|
1188
|
-
return undefined;
|
|
1189
|
-
if (generation.startsWith(RESETTING_GENERATION_PREFIX) ||
|
|
1190
|
-
generation.startsWith(DISCONNECTED_GENERATION_PREFIX)) {
|
|
1191
|
-
return undefined;
|
|
1192
|
-
}
|
|
1193
|
-
let text = raw;
|
|
1194
|
-
let parsed;
|
|
1195
|
-
try {
|
|
1196
|
-
parsed = JSON.parse(raw);
|
|
1197
|
-
}
|
|
1198
|
-
catch {
|
|
1199
|
-
// Raw string state from a pre-envelope deployment is handled below.
|
|
844
|
+
/** Validate RFC 9207 against the server this consent selected. */
|
|
845
|
+
validateCallbackIssuer(issuer) {
|
|
846
|
+
if (!this.callback ||
|
|
847
|
+
(issuer === null
|
|
848
|
+
? this.callback.flow.binding?.issRequired === true
|
|
849
|
+
: issuer !== this.callback.flow.binding?.issuer)) {
|
|
850
|
+
throw new ConnectorCallError("connector_call_failed", `Connector "${this.connectorId}" authorization callback issuer does not match its consent; nothing was exchanged.`);
|
|
1200
851
|
}
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
852
|
+
}
|
|
853
|
+
// --- flow records ---------------------------------------------------------
|
|
854
|
+
flowLabel(key, epoch) {
|
|
855
|
+
return `${key}:${epoch}`;
|
|
856
|
+
}
|
|
857
|
+
async encodeFlow(key, flow) {
|
|
858
|
+
const { verifier, ...rest } = flow;
|
|
859
|
+
const stored = { connectaOAuthFlow: FLOW_VERSION, ...rest };
|
|
860
|
+
if (verifier !== undefined) {
|
|
861
|
+
if (this.sealer) {
|
|
862
|
+
stored.verifier = await this.sealer.seal(this.flowLabel(key, flow.epoch), verifier);
|
|
863
|
+
stored.sealed = true;
|
|
1210
864
|
}
|
|
1211
|
-
|
|
1212
|
-
|
|
865
|
+
else {
|
|
866
|
+
stored.verifier = verifier;
|
|
1213
867
|
}
|
|
1214
868
|
}
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
generation,
|
|
1227
|
-
...(parsed.issuer !== undefined ? { issuer: parsed.issuer } : {}),
|
|
1228
|
-
...(parsed.binding !== undefined ? { binding: parsed.binding } : {}),
|
|
1229
|
-
// Only a finite number is a time; anything else reads as untimed.
|
|
1230
|
-
...(typeof parsed.writtenAt === "number" &&
|
|
1231
|
-
Number.isFinite(parsed.writtenAt)
|
|
1232
|
-
? { writtenAt: parsed.writtenAt }
|
|
1233
|
-
: {}),
|
|
1234
|
-
}
|
|
1235
|
-
: undefined;
|
|
1236
|
-
}
|
|
1237
|
-
if (legacyStoredOAuthValue(parsed)) {
|
|
1238
|
-
return parsed.generation === generation
|
|
1239
|
-
? { value: parsed.value, generation }
|
|
1240
|
-
: undefined;
|
|
1241
|
-
}
|
|
1242
|
-
if (isModernGeneration(generation))
|
|
1243
|
-
return undefined;
|
|
1244
|
-
return { value: parseLegacy(raw), generation };
|
|
1245
|
-
}
|
|
1246
|
-
/**
|
|
1247
|
-
* Read credentials only for the authorization server that issued them.
|
|
1248
|
-
*
|
|
1249
|
-
* Values written before issuer binding are bound on their first read with a
|
|
1250
|
-
* validated SDK issuer, preserving an existing grant across this upgrade.
|
|
1251
|
-
* A later issuer change is different: publish a new generation before
|
|
1252
|
-
* returning no credentials, so every isolate drops the old registration and
|
|
1253
|
-
* token set and the SDK starts authorization from scratch.
|
|
1254
|
-
*/
|
|
1255
|
-
async readIssuerBoundValue(key, parseLegacy, ctx) {
|
|
1256
|
-
const read = await this.readStoredValue(key, parseLegacy);
|
|
1257
|
-
if (!read)
|
|
869
|
+
return JSON.stringify(stored);
|
|
870
|
+
}
|
|
871
|
+
/** A flow record, its verifier opened only when `withVerifier`. */
|
|
872
|
+
async openFlow(key, raw, withVerifier) {
|
|
873
|
+
const value = parsed(raw);
|
|
874
|
+
if (!plainObject(value) ||
|
|
875
|
+
value.connectaOAuthFlow !== FLOW_VERSION ||
|
|
876
|
+
typeof value.epoch !== "string" ||
|
|
877
|
+
typeof value.at !== "number" ||
|
|
878
|
+
(value.url !== undefined && typeof value.url !== "string") ||
|
|
879
|
+
(value.verifier !== undefined && typeof value.verifier !== "string")) {
|
|
1258
880
|
return undefined;
|
|
1259
|
-
const stored = read.stored;
|
|
1260
|
-
if (!ctx || stored.issuer !== undefined) {
|
|
1261
|
-
if (read.plaintextCredential) {
|
|
1262
|
-
await this.sealInPlace(key, stored.generation, read.raw);
|
|
1263
|
-
}
|
|
1264
|
-
if (!ctx)
|
|
1265
|
-
return stored.value;
|
|
1266
881
|
}
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
return
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
return stored.value;
|
|
1279
|
-
if (this.signal?.aborted)
|
|
1280
|
-
throw this.signal.reason;
|
|
882
|
+
const flow = {
|
|
883
|
+
epoch: value.epoch,
|
|
884
|
+
at: value.at,
|
|
885
|
+
...(plainObject(value.binding) ? { binding: value.binding } : {}),
|
|
886
|
+
...(typeof value.url === "string" ? { url: value.url } : {}),
|
|
887
|
+
...(value.consumed === true ? { consumed: true } : {}),
|
|
888
|
+
};
|
|
889
|
+
if (!withVerifier || typeof value.verifier !== "string")
|
|
890
|
+
return flow;
|
|
891
|
+
if (value.sealed !== true)
|
|
892
|
+
return { ...flow, verifier: value.verifier };
|
|
1281
893
|
try {
|
|
1282
|
-
|
|
894
|
+
if (!this.sealer)
|
|
895
|
+
return undefined;
|
|
896
|
+
return { ...flow, verifier: await this.sealer.open(this.flowLabel(key, flow.epoch), value.verifier) };
|
|
1283
897
|
}
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
this.captureGeneration(await this.generation());
|
|
898
|
+
catch {
|
|
899
|
+
this.sealer?.warn(`[connecta] connector "${this.connectorId}" has a sealed OAuth consent ` +
|
|
900
|
+
"the configured vault cannot open; its callback is refused.");
|
|
901
|
+
return undefined;
|
|
1289
902
|
}
|
|
1290
|
-
return undefined;
|
|
1291
903
|
}
|
|
1292
|
-
/**
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
const
|
|
1299
|
-
|
|
1300
|
-
|
|
904
|
+
/** The live epoch's latest consent, until a callback claims it. */
|
|
905
|
+
async latestConsent() {
|
|
906
|
+
const grant = await this.boundGrant();
|
|
907
|
+
if (grant.flow === undefined)
|
|
908
|
+
return undefined;
|
|
909
|
+
const key = oauthFlowKeys.flow(grant.flow);
|
|
910
|
+
const raw = await this.storage.get(key);
|
|
911
|
+
const flow = raw === null ? undefined : await this.openFlow(key, raw, false);
|
|
912
|
+
return flow && !flow.consumed && flow.epoch === grant.epoch && flow.url !== undefined
|
|
913
|
+
? { ...flow, url: flow.url }
|
|
914
|
+
: undefined;
|
|
915
|
+
}
|
|
916
|
+
// --- flows and epochs -----------------------------------------------------
|
|
917
|
+
/** The live epoch; an owner with no grant yet has the initial one. */
|
|
918
|
+
async liveEpoch() {
|
|
919
|
+
return (await this.readGrant()).grant.epoch;
|
|
920
|
+
}
|
|
921
|
+
/** True only after an operator disconnect, until an explicit start replaces it. */
|
|
922
|
+
async operatorDisconnected() {
|
|
923
|
+
return isDisconnectedEpoch(await this.liveEpoch());
|
|
924
|
+
}
|
|
925
|
+
isOperatorDisconnectedEpoch(epoch) {
|
|
926
|
+
return isDisconnectedEpoch(epoch);
|
|
1301
927
|
}
|
|
1302
928
|
/**
|
|
1303
|
-
*
|
|
1304
|
-
*
|
|
1305
|
-
*
|
|
929
|
+
* Begin a flow — a connect, whose 401 may refresh or start consent, or an
|
|
930
|
+
* `api()` call or start — in the live epoch, and return it. Nothing about
|
|
931
|
+
* the grant is decided here: a grant belongs to one server by construction.
|
|
1306
932
|
*/
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
933
|
+
async beginFlow() {
|
|
934
|
+
const epoch = await this.liveEpoch();
|
|
935
|
+
this.epoch = epoch;
|
|
936
|
+
this.bound = true;
|
|
937
|
+
return epoch;
|
|
938
|
+
}
|
|
939
|
+
/** The epoch this flow is bound to, or the live one. */
|
|
940
|
+
async flowEpoch() {
|
|
941
|
+
return this.epoch ?? (await this.liveEpoch());
|
|
1312
942
|
}
|
|
1313
943
|
/**
|
|
1314
|
-
*
|
|
1315
|
-
*
|
|
1316
|
-
*
|
|
1317
|
-
*
|
|
944
|
+
* Bind a code exchange: to the epoch its verified consent was written in, or
|
|
945
|
+
* the live one for an exchange no callback verified. An epoch already
|
|
946
|
+
* replaced fails here, before the SDK can send anything. What the grant
|
|
947
|
+
* holds now is all a refused code may invalidate.
|
|
1318
948
|
*/
|
|
1319
|
-
async
|
|
1320
|
-
const
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
const parsed = JSON.parse(raw);
|
|
1332
|
-
if (!Array.isArray(parsed) ||
|
|
1333
|
-
parsed.length > MAX_CLEANUP_BACKLOG ||
|
|
1334
|
-
!parsed.every((value) => typeof value === "string")) {
|
|
1335
|
-
throw new Error(`Invalid OAuth cleanup backlog for "${this.connectorId}"`);
|
|
1336
|
-
}
|
|
1337
|
-
return {
|
|
1338
|
-
lineage: mergeLineage(parsed.map((entry) => ({
|
|
1339
|
-
generation: entry,
|
|
1340
|
-
retiredAt: recorded.get(entry) ?? now,
|
|
1341
|
-
}))),
|
|
1342
|
-
recorded,
|
|
949
|
+
async bindFlow() {
|
|
950
|
+
const { grant } = await this.readGrant();
|
|
951
|
+
const epoch = this.callback?.flow.epoch ?? this.epoch ?? grant.epoch;
|
|
952
|
+
if (grant.epoch !== epoch)
|
|
953
|
+
throw this.flowSuperseded();
|
|
954
|
+
this.epoch = epoch;
|
|
955
|
+
this.bound = true;
|
|
956
|
+
this.codeExchange = true;
|
|
957
|
+
await this.checkFlowBinding(grant);
|
|
958
|
+
this.seen = {
|
|
959
|
+
client: fingerprint(grant.body.client?.value),
|
|
960
|
+
tokens: fingerprint(grant.body.tokens),
|
|
1343
961
|
};
|
|
1344
962
|
}
|
|
1345
|
-
|
|
1346
|
-
|
|
963
|
+
/** Retain an already verified snapshot only for the exact same state. */
|
|
964
|
+
async verifyCallbackState(state) {
|
|
965
|
+
if (this.callback?.key === oauthFlowKeys.flow(await oauthStateDigest(state)))
|
|
966
|
+
return true;
|
|
967
|
+
return this.verifyState(state);
|
|
968
|
+
}
|
|
969
|
+
/** Whether `verifyState` matched a consent on this provider. */
|
|
970
|
+
verified() {
|
|
971
|
+
return this.callback !== undefined;
|
|
1347
972
|
}
|
|
1348
|
-
/**
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
this.storage.set(cleanupBacklogKey(generation), JSON.stringify(lineage.map((entry) => entry.generation))),
|
|
1352
|
-
this.storage.set(cleanupTimesKey(generation), this.timesRecord(lineage.map((entry) => [entry.generation, entry.retiredAt]))),
|
|
1353
|
-
]));
|
|
1354
|
-
if (failure)
|
|
1355
|
-
throw failure.reason;
|
|
973
|
+
/** The fixed, retryable failure of a flow a later reset superseded. */
|
|
974
|
+
flowSuperseded() {
|
|
975
|
+
return new ConnectorCallError("unavailable", `Connector "${this.connectorId}" authorization changed while this request was in flight; try again.`);
|
|
1356
976
|
}
|
|
1357
977
|
/**
|
|
1358
|
-
*
|
|
1359
|
-
*
|
|
1360
|
-
* appended. One already listed keeps its place in the manifest and has
|
|
1361
|
-
* only its time moved: a reset that is sweeping it re-reads the lineage
|
|
1362
|
-
* before publishing, sees the new time, and keeps it.
|
|
1363
|
-
*
|
|
1364
|
-
* Only the times record is rewritten for a listed generation, so this can
|
|
1365
|
-
* never drop a manifest entry. Two late writers racing here can lose one
|
|
1366
|
-
* time update to the other: a lost bump leaves the older time, which
|
|
1367
|
-
* shortens that generation's grace back to what it was before the write
|
|
1368
|
-
* (no worse than never recording it), and a time dropped from the record
|
|
1369
|
-
* reads as retired "now", which only lengthens a grace.
|
|
978
|
+
* A credential write that storage refused, in fixed text and with no cause
|
|
979
|
+
* attached. Retryable: the grant it held is untouched.
|
|
1370
980
|
*/
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
const { lineage, recorded } = await this.cleanupBacklog(active, now);
|
|
1374
|
-
if (lineage.some((entry) => entry.generation === retired)) {
|
|
1375
|
-
await this.storage.set(cleanupTimesKey(active), this.timesRecord([...recorded, [retired, now]]));
|
|
1376
|
-
return;
|
|
1377
|
-
}
|
|
1378
|
-
if (lineage.length >= MAX_CLEANUP_BACKLOG) {
|
|
1379
|
-
throw new Error(`OAuth cleanup backlog for "${this.connectorId}" is full`);
|
|
1380
|
-
}
|
|
1381
|
-
await this.publishLineage(active, [
|
|
1382
|
-
...lineage,
|
|
1383
|
-
{ generation: retired, retiredAt: now },
|
|
1384
|
-
]);
|
|
981
|
+
credentialWriteError() {
|
|
982
|
+
return new ConnectorCallError("unavailable", `Connector "${this.connectorId}" could not store its OAuth credentials; try again shortly.`);
|
|
1385
983
|
}
|
|
1386
|
-
|
|
1387
|
-
|
|
984
|
+
/** @internal Other requests kept refreshing faster than this one could join. */
|
|
985
|
+
refreshContended() {
|
|
986
|
+
this.recordRefreshFailure({ kind: "contended" });
|
|
987
|
+
return new ConnectorCallError("unavailable", `Connector "${this.connectorId}" could not refresh its OAuth grant while other requests were; retry.`);
|
|
1388
988
|
}
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
token_endpoint_auth_method: "none",
|
|
1399
|
-
...(this.scope !== undefined ? { scope: this.scope } : {}),
|
|
1400
|
-
};
|
|
989
|
+
/**
|
|
990
|
+
* A passive call cannot start consent. A waiter deadline remains retryable;
|
|
991
|
+
* any dispatched failure requires consent because its token is spent.
|
|
992
|
+
*/
|
|
993
|
+
authorizationRefused() {
|
|
994
|
+
const failure = this.refreshFailure;
|
|
995
|
+
if (failure?.kind === "contended")
|
|
996
|
+
return this.refreshContended();
|
|
997
|
+
return new UnauthorizedError("Authorization required. Use authorize_connector or Connect to start consent.");
|
|
1401
998
|
}
|
|
999
|
+
// --- the SDK's hooks --------------------------------------------------------
|
|
1402
1000
|
async clientInformation(ctx) {
|
|
1403
|
-
|
|
1001
|
+
const { body } = await this.boundGrant();
|
|
1002
|
+
const client = this.clientOptions?.client;
|
|
1003
|
+
if (!body.client && !this.allowAuthorization)
|
|
1004
|
+
throw this.authorizationRefused();
|
|
1005
|
+
if (client) {
|
|
1006
|
+
if ((ctx && ctx.issuer !== client.issuer) || body.issuer !== client.issuer)
|
|
1007
|
+
throw this.flowSuperseded();
|
|
1008
|
+
if (body.client &&
|
|
1009
|
+
(body.client.value.client_id !== client.clientId || body.client.binding !== this.clientBinding))
|
|
1010
|
+
throw this.flowSuperseded();
|
|
1011
|
+
return {
|
|
1012
|
+
client_id: client.clientId,
|
|
1013
|
+
issuer: client.issuer,
|
|
1014
|
+
token_endpoint_auth_method: this.clientMetadata.token_endpoint_auth_method,
|
|
1015
|
+
...(client.clientSecret !== undefined ? { client_secret: client.clientSecret } : {}),
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
if (!body.client || (ctx && body.issuer !== ctx.issuer))
|
|
1019
|
+
return undefined;
|
|
1020
|
+
this.seen.client = fingerprint(body.client.value);
|
|
1021
|
+
return ctx ? { ...body.client.value, issuer: ctx.issuer } : body.client.value;
|
|
1404
1022
|
}
|
|
1405
1023
|
async saveClientInformation(info, ctx) {
|
|
1406
|
-
|
|
1024
|
+
const { body } = await this.boundGrant();
|
|
1025
|
+
const registrationPath = this.clientOptions?.client
|
|
1026
|
+
? "static"
|
|
1027
|
+
: body.client?.value.client_id === info.client_id && body.client.registrationPath
|
|
1028
|
+
? body.client.registrationPath
|
|
1029
|
+
: this.clientMetadataUrl &&
|
|
1030
|
+
body.discovery?.authorizationServerMetadata?.client_id_metadata_document_supported === true
|
|
1031
|
+
? "cimd"
|
|
1032
|
+
: "dcr";
|
|
1033
|
+
await this.writeCredential("client", {
|
|
1034
|
+
value: this.clientOptions?.client ? { client_id: this.clientOptions.client.clientId } : info,
|
|
1035
|
+
registrationPath,
|
|
1036
|
+
...(this.clientBinding !== undefined ? { binding: this.clientBinding } : {}),
|
|
1037
|
+
}, ctx?.issuer);
|
|
1038
|
+
}
|
|
1039
|
+
async registrationPath() {
|
|
1040
|
+
const { body } = await this.boundGrant();
|
|
1041
|
+
return body.client?.registrationPath;
|
|
1042
|
+
}
|
|
1043
|
+
/** Intake and saved URLs share a guard. Include the current grant's secrets
|
|
1044
|
+
* so a fresh request can reject an older cache before sending credentials. */
|
|
1045
|
+
assertCredentialFree(value, body, consentUrl = false) {
|
|
1046
|
+
// OAuth intentionally puts its public client identity in consent. Exempt
|
|
1047
|
+
// only that parameter when it equals the bound/configured client, leaving
|
|
1048
|
+
// every other occurrence subject to the credential check. The URL itself
|
|
1049
|
+
// is never changed, only this matching view.
|
|
1050
|
+
if (consentUrl && typeof value === "string") {
|
|
1051
|
+
const url = new URL(value);
|
|
1052
|
+
const clientId = this.clientOptions?.client?.clientId ??
|
|
1053
|
+
body.client?.value.client_id ??
|
|
1054
|
+
this.configuredAuthorizationBinding()?.clientId;
|
|
1055
|
+
const ids = url.searchParams.getAll("client_id");
|
|
1056
|
+
if (clientId && ids.length === 1 && ids[0] === clientId)
|
|
1057
|
+
url.searchParams.delete("client_id");
|
|
1058
|
+
value = url.href;
|
|
1059
|
+
}
|
|
1060
|
+
const secrets = new SentSecrets();
|
|
1061
|
+
for (const value of [
|
|
1062
|
+
body.tokens?.access_token,
|
|
1063
|
+
body.tokens?.refresh_token,
|
|
1064
|
+
body.client?.value.client_secret,
|
|
1065
|
+
this.clientOptions?.client?.clientSecret,
|
|
1066
|
+
]) {
|
|
1067
|
+
if (typeof value === "string")
|
|
1068
|
+
secrets.secret(value);
|
|
1069
|
+
}
|
|
1070
|
+
if (body.client?.value.client_secret)
|
|
1071
|
+
secrets.add(body.client.value.client_id);
|
|
1072
|
+
if (this.clientOptions?.client?.clientSecret)
|
|
1073
|
+
secrets.add(this.clientOptions.client.clientId);
|
|
1074
|
+
const urls = [];
|
|
1075
|
+
const collect = (item) => {
|
|
1076
|
+
if (typeof item === "string") {
|
|
1077
|
+
try {
|
|
1078
|
+
urls.push(new URL(item).href);
|
|
1079
|
+
}
|
|
1080
|
+
catch {
|
|
1081
|
+
/* Not a URL field. */
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
else if (item && typeof item === "object")
|
|
1085
|
+
for (const field of Object.values(item))
|
|
1086
|
+
collect(field);
|
|
1087
|
+
};
|
|
1088
|
+
collect(value);
|
|
1089
|
+
if ([secrets, this.clientOptions?.secrets].some((source) => source && (source.redact(value) !== value || urls.some((url) => source.containsUrl(url))))) {
|
|
1090
|
+
throw new ConnectorCallError("connector_call_failed", "OAuth metadata or consent URL contains a credential; refusing authorization.", { retryable: false });
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
/** Only metadata bound to the discovered issuer can supply operator origins. */
|
|
1094
|
+
async validatedIssuerOrigin() {
|
|
1095
|
+
const { body } = await this.boundGrant();
|
|
1096
|
+
const state = body.discovery;
|
|
1097
|
+
if (!state ||
|
|
1098
|
+
state.authorizationServerMetadata?.issuer !== state.authorizationServerUrl ||
|
|
1099
|
+
body.issuer !== state.authorizationServerUrl)
|
|
1100
|
+
return undefined;
|
|
1101
|
+
this.assertCredentialFree(state, body);
|
|
1102
|
+
return new URL(state.authorizationServerUrl).origin;
|
|
1407
1103
|
}
|
|
1408
1104
|
async discoveryState() {
|
|
1409
|
-
|
|
1105
|
+
const { body } = await this.boundGrant();
|
|
1106
|
+
const state = body.discovery;
|
|
1107
|
+
this.assertCredentialFree(state, body);
|
|
1108
|
+
this.consentDiscovery = state;
|
|
1109
|
+
return state;
|
|
1410
1110
|
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Discovery names the grant's server: the same one keeps the client and
|
|
1113
|
+
* tokens beside it, another replaces them. The SDK discovers afresh only
|
|
1114
|
+
* without a cached record — a restart's carried client, say — and what a
|
|
1115
|
+
* different server leaves behind would never be sent to it anyway.
|
|
1116
|
+
*/
|
|
1411
1117
|
async saveDiscoveryState(state) {
|
|
1412
|
-
|
|
1118
|
+
this.assertCredentialFree(state, (await this.boundGrant()).body);
|
|
1119
|
+
const issuer = discoveryIssuer(state);
|
|
1120
|
+
if (this.clientOptions?.client && issuer !== this.clientOptions.client.issuer)
|
|
1121
|
+
throw this.flowSuperseded();
|
|
1122
|
+
if (!this.allowAuthorization && !(await this.boundGrant()).body.client)
|
|
1123
|
+
throw this.authorizationRefused();
|
|
1124
|
+
this.consentDiscovery = state;
|
|
1125
|
+
await this.updateGrant((grant) => {
|
|
1126
|
+
const body = grant.body.issuer !== issuer ? (issuer !== undefined ? { issuer } : {}) : { ...grant.body };
|
|
1127
|
+
this.assertCredentialFree(state, grant.body);
|
|
1128
|
+
body.discovery = state;
|
|
1129
|
+
return this.withIssuer(grant, body);
|
|
1130
|
+
});
|
|
1413
1131
|
}
|
|
1132
|
+
/**
|
|
1133
|
+
* The grant's tokens, for the server that issued them only. An
|
|
1134
|
+
* issuer-aware read records what it handed the SDK, so the coordinator can
|
|
1135
|
+
* tell a refresh another request completed since.
|
|
1136
|
+
*/
|
|
1414
1137
|
async tokens(ctx) {
|
|
1415
|
-
const
|
|
1416
|
-
const
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
if (ctx
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
...(tokens.refresh_token !== undefined
|
|
1425
|
-
? { refreshToken: tokens.refresh_token }
|
|
1426
|
-
: {}),
|
|
1427
|
-
...(successIdentity !== undefined ? { successIdentity } : {}),
|
|
1428
|
-
};
|
|
1429
|
-
}
|
|
1430
|
-
return tokens;
|
|
1138
|
+
const grant = await this.boundGrant();
|
|
1139
|
+
const { tokens } = grant.body;
|
|
1140
|
+
if (!tokens || (ctx && grant.body.issuer !== ctx.issuer))
|
|
1141
|
+
return undefined;
|
|
1142
|
+
this.seen.tokens = fingerprint(tokens);
|
|
1143
|
+
if (!ctx)
|
|
1144
|
+
return tokens;
|
|
1145
|
+
this.refreshBasis = { accessToken: tokens.access_token, epoch: grant.epoch, issuer: ctx.issuer };
|
|
1146
|
+
return { ...tokens, issuer: ctx.issuer };
|
|
1431
1147
|
}
|
|
1432
1148
|
async saveTokens(tokens, ctx) {
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
await this.
|
|
1444
|
-
|
|
1445
|
-
persistAcceptedTokens(tokens, ctx, owned) {
|
|
1446
|
-
// The SDK can save the same response while cancellation recovery is
|
|
1447
|
-
// writing it. A second write could land after the next rotation, so both
|
|
1448
|
-
// paths await this exact flight's one commit, including its failure.
|
|
1449
|
-
return owned.flight.persistence ??= this.persistTokens(tokens, ctx, true, owned);
|
|
1450
|
-
}
|
|
1451
|
-
async persistTokens(tokens, ctx, commitAcceptedRefresh, owned) {
|
|
1452
|
-
const releasePartition = this.refreshCoordinator?.retainWork();
|
|
1453
|
-
// A retired flight (the owner was cancelled or superseded after the
|
|
1454
|
-
// token response) still writes: the authorization server has already
|
|
1455
|
-
// consumed the old refresh token, so dropping the rotated one would leave a
|
|
1456
|
-
// dead credential. writeValue itself refuses when the generation moved.
|
|
1457
|
-
// Only the coordinator bookkeeping belongs to the exact live flight.
|
|
1458
|
-
const coordinated = owned !== undefined &&
|
|
1459
|
-
this.refreshCoordinator?.beginMutation(owned.generation, owned.flight) === true;
|
|
1460
|
-
try {
|
|
1461
|
-
await this.writeValue("oauth:tokens", tokens, (value) => JSON.stringify(value), ctx?.issuer, undefined, undefined, commitAcceptedRefresh);
|
|
1462
|
-
if (coordinated)
|
|
1463
|
-
this.refreshCoordinator?.succeedMutation(owned.generation, owned.flight);
|
|
1464
|
-
this.refreshFailure = undefined;
|
|
1465
|
-
}
|
|
1466
|
-
catch (error) {
|
|
1467
|
-
if (coordinated)
|
|
1468
|
-
this.refreshCoordinator?.failMutation(owned.generation, owned.flight, error);
|
|
1469
|
-
throw error;
|
|
1470
|
-
}
|
|
1471
|
-
finally {
|
|
1472
|
-
// The write owns this identity even if an SDK failure callback has
|
|
1473
|
-
// already detached the provider's flight while storage was pending.
|
|
1474
|
-
if (this.refreshFlight === owned)
|
|
1475
|
-
this.refreshFlight = undefined;
|
|
1476
|
-
releasePartition?.();
|
|
1477
|
-
}
|
|
1149
|
+
if (this.refreshFailure?.kind === "dead")
|
|
1150
|
+
throw this.authorizationRefused();
|
|
1151
|
+
// The coordinator stored this refresh already, or handed on another's.
|
|
1152
|
+
if (this.storedRefresh !== undefined && tokens.access_token === this.storedRefresh)
|
|
1153
|
+
return;
|
|
1154
|
+
const refreshConsent = this.exchangeConsent &&
|
|
1155
|
+
tokens.refresh_token !== undefined &&
|
|
1156
|
+
this.exchangeConsent.digest === (await oauthStateDigest(tokens.refresh_token))
|
|
1157
|
+
? this.exchangeConsent
|
|
1158
|
+
: undefined;
|
|
1159
|
+
await this.writeCredential("tokens", tokens, ctx?.issuer, refreshConsent);
|
|
1160
|
+
this.refreshFailure = undefined;
|
|
1478
1161
|
}
|
|
1479
1162
|
/**
|
|
1480
|
-
* OAuth `state
|
|
1481
|
-
*
|
|
1482
|
-
*
|
|
1483
|
-
* WE started. Without it, anyone holding a pending authorization URL could
|
|
1484
|
-
* complete consent with their own account (login CSRF) — PKCE does not help,
|
|
1485
|
-
* since the verifier belongs to connecta, not the attacker. Cleared in
|
|
1486
|
-
* clearPending() once the flow completes.
|
|
1163
|
+
* OAuth `state`: 256 random bits the consent URL carries and the callback
|
|
1164
|
+
* must present, so nobody holding a consent URL can complete it with their
|
|
1165
|
+
* own account (login CSRF). Stored, by digest, with the consent.
|
|
1487
1166
|
*/
|
|
1488
1167
|
async state() {
|
|
1489
1168
|
if (!this.allowAuthorization)
|
|
1490
1169
|
throw this.authorizationRefused();
|
|
1491
|
-
const
|
|
1492
|
-
|
|
1493
|
-
return
|
|
1494
|
-
}
|
|
1495
|
-
/**
|
|
1496
|
-
* Constant-time check of a callback's `state` against the stored one-shot
|
|
1497
|
-
* value. Absent stored state or absent candidate → false (fail closed).
|
|
1498
|
-
*/
|
|
1499
|
-
async verifyState(candidate) {
|
|
1500
|
-
const expected = await this.readValue("oauth:state", (raw) => raw);
|
|
1501
|
-
if (!expected || candidate === null)
|
|
1502
|
-
return false;
|
|
1503
|
-
const matches = timingSafeEqual(candidate, expected.value);
|
|
1504
|
-
if (matches)
|
|
1505
|
-
this.captureGeneration(expected.generation);
|
|
1506
|
-
return matches;
|
|
1170
|
+
const state = randomState();
|
|
1171
|
+
this.consent = { state };
|
|
1172
|
+
return state;
|
|
1507
1173
|
}
|
|
1508
1174
|
async saveCodeVerifier(verifier) {
|
|
1509
1175
|
if (!this.allowAuthorization)
|
|
1510
1176
|
throw this.authorizationRefused();
|
|
1511
|
-
|
|
1177
|
+
this.consent = { ...this.consent, verifier };
|
|
1512
1178
|
}
|
|
1513
1179
|
async codeVerifier() {
|
|
1514
|
-
const
|
|
1515
|
-
if (
|
|
1180
|
+
const verifier = this.callback?.flow.verifier ?? this.consent.verifier;
|
|
1181
|
+
if (verifier === undefined) {
|
|
1516
1182
|
throw new Error(`No PKCE code verifier for "${this.connectorId}"`);
|
|
1517
1183
|
}
|
|
1518
|
-
return
|
|
1184
|
+
return verifier;
|
|
1519
1185
|
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Store the consent: its flow record first, by state digest with the
|
|
1188
|
+
* link's lifetime, then the grant's pointer to it, in the flow's epoch. A
|
|
1189
|
+
* reset that replaced the epoch fails the start, and the record is removed,
|
|
1190
|
+
* so no start hands out a consent its callback could not complete.
|
|
1191
|
+
*/
|
|
1520
1192
|
async redirectToAuthorization(authorizationUrl) {
|
|
1193
|
+
if (!this.allowAuthorization)
|
|
1194
|
+
throw this.authorizationRefused();
|
|
1195
|
+
this.assertCredentialFree(authorizationUrl.toString(), (await this.boundGrant()).body, true);
|
|
1196
|
+
const state = this.consent.state ?? authorizationUrl.searchParams.get("state");
|
|
1197
|
+
if (!state)
|
|
1198
|
+
throw new Error(`OAuth consent for "${this.connectorId}" carries no state`);
|
|
1199
|
+
const client = this.clientOptions?.client;
|
|
1200
|
+
if (client && !(await this.boundGrant()).body.client) {
|
|
1201
|
+
await this.saveClientInformation({ client_id: client.clientId }, { issuer: client.issuer });
|
|
1202
|
+
}
|
|
1203
|
+
const configured = this.configuredAuthorizationBinding();
|
|
1204
|
+
if (configured?.issuer !== undefined) {
|
|
1205
|
+
const issuer = configured.issuer;
|
|
1206
|
+
// Static adapters do not save discovery or client configuration. Publish
|
|
1207
|
+
// their server identity before consent so a changed endpoint fences the
|
|
1208
|
+
// old grant before any code exchange can leave.
|
|
1209
|
+
await this.updateGrant((grant) => grant.body.issuer === issuer ? undefined : this.withIssuer(grant, { issuer }));
|
|
1210
|
+
}
|
|
1211
|
+
const epoch = await this.flowEpoch();
|
|
1212
|
+
this.epoch = epoch;
|
|
1213
|
+
const digest = await oauthStateDigest(state);
|
|
1214
|
+
const key = oauthFlowKeys.flow(digest);
|
|
1215
|
+
const url = authorizationUrl.toString();
|
|
1216
|
+
const { verifier } = this.consent;
|
|
1217
|
+
const grant = await this.boundGrant();
|
|
1218
|
+
this.assertCredentialFree(url, grant.body, true);
|
|
1219
|
+
const binding = this.configuredAuthorizationBinding() ?? {
|
|
1220
|
+
...(await discoveryBinding(this.consentDiscovery ?? grant.body.discovery)),
|
|
1221
|
+
issuer: discoveryIssuer(this.consentDiscovery) ?? grant.body.issuer,
|
|
1222
|
+
clientId: authorizationUrl.searchParams.get("client_id") ?? grant.body.client?.value.client_id,
|
|
1223
|
+
};
|
|
1224
|
+
const record = await this.encodeFlow(key, {
|
|
1225
|
+
epoch,
|
|
1226
|
+
at: Date.now(),
|
|
1227
|
+
binding,
|
|
1228
|
+
url,
|
|
1229
|
+
...(verifier !== undefined ? { verifier } : {}),
|
|
1230
|
+
});
|
|
1231
|
+
if (this.signal?.aborted)
|
|
1232
|
+
return;
|
|
1233
|
+
if (!(await this.storage.compareAndSet(key, null, record, { ttlSeconds: OAUTH_FLOW_TTL_SECONDS }))) {
|
|
1234
|
+
throw new Error(`OAuth consent for "${this.connectorId}" reused a state`);
|
|
1235
|
+
}
|
|
1236
|
+
let published = false;
|
|
1521
1237
|
try {
|
|
1522
|
-
|
|
1523
|
-
throw this.authorizationRefused();
|
|
1524
|
-
// Timestamped so a later start can tell a URL worth reissuing from a
|
|
1525
|
-
// stale one. A pre-envelope (legacy-generation) write keeps its raw
|
|
1526
|
-
// format for older readers and carries no time.
|
|
1527
|
-
await this.writeValue("oauth:pending", authorizationUrl.toString(), (raw) => raw, undefined, Date.now());
|
|
1528
|
-
this.failRefreshFlight(new Error("OAuth refresh required reauthorization before tokens were saved."));
|
|
1238
|
+
published = await this.updateGrant((grant) => ({ ...grant, flow: digest }), { epoch });
|
|
1529
1239
|
}
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1240
|
+
finally {
|
|
1241
|
+
if (!published)
|
|
1242
|
+
await this.storage.compareAndSet(key, record, null).catch(() => { });
|
|
1533
1243
|
}
|
|
1244
|
+
if (published)
|
|
1245
|
+
this.published = { epoch, url };
|
|
1534
1246
|
}
|
|
1535
|
-
/** The
|
|
1247
|
+
/** The consent this provider stored, while its epoch is still live. */
|
|
1248
|
+
async consentUrl() {
|
|
1249
|
+
const published = this.published;
|
|
1250
|
+
if (!published)
|
|
1251
|
+
return undefined;
|
|
1252
|
+
if (epochOf(await this.storage.get(GRANT)) !== published.epoch)
|
|
1253
|
+
throw this.flowSuperseded();
|
|
1254
|
+
this.assertCredentialFree(published.url, (await this.boundGrant()).body, true);
|
|
1255
|
+
return published.url;
|
|
1256
|
+
}
|
|
1257
|
+
/** The live epoch's pending consent URL, if one is unclaimed. */
|
|
1536
1258
|
async pendingAuthorizationUrl() {
|
|
1537
|
-
|
|
1259
|
+
const url = (await this.latestConsent())?.url;
|
|
1260
|
+
this.assertCredentialFree(url, (await this.boundGrant()).body, true);
|
|
1261
|
+
return url;
|
|
1538
1262
|
}
|
|
1539
1263
|
/**
|
|
1540
|
-
* The pending
|
|
1541
|
-
*
|
|
1542
|
-
* direction
|
|
1543
|
-
*
|
|
1544
|
-
*
|
|
1264
|
+
* The pending consent URL, only while it is still worth handing out again:
|
|
1265
|
+
* unclaimed, written within PENDING_AUTHORIZATION_MAX_AGE_MS of now in
|
|
1266
|
+
* either direction (clock skew between isolates), and naming the grant's
|
|
1267
|
+
* client. A callback whose exchange the server refused with
|
|
1268
|
+
* `invalid_client` drops the client, and its URL would send the operator to
|
|
1269
|
+
* a consent the server has already refused.
|
|
1545
1270
|
*/
|
|
1546
1271
|
async reusablePendingAuthorizationUrl() {
|
|
1547
|
-
const
|
|
1548
|
-
if (
|
|
1272
|
+
const consent = await this.latestConsent();
|
|
1273
|
+
if (!consent || Math.abs(Date.now() - consent.at) >= PENDING_AUTHORIZATION_MAX_AGE_MS) {
|
|
1549
1274
|
return undefined;
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1275
|
+
}
|
|
1276
|
+
this.assertCredentialFree(consent.url, (await this.boundGrant()).body, true);
|
|
1277
|
+
let clientId;
|
|
1278
|
+
try {
|
|
1279
|
+
clientId = new URL(consent.url).searchParams.get("client_id");
|
|
1280
|
+
}
|
|
1281
|
+
catch {
|
|
1282
|
+
return undefined;
|
|
1283
|
+
}
|
|
1284
|
+
const client = await this.clientInformation();
|
|
1285
|
+
return clientId !== null && client?.client_id === clientId ? consent.url : undefined;
|
|
1554
1286
|
}
|
|
1555
|
-
/**
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1287
|
+
/**
|
|
1288
|
+
* Find the consent a callback's `state` names. One read whatever the
|
|
1289
|
+
* answer; the callback route's refusals pay the same. A claimed consent
|
|
1290
|
+
* no longer matches.
|
|
1291
|
+
*/
|
|
1292
|
+
async verifyState(candidate) {
|
|
1293
|
+
this.callback = undefined;
|
|
1294
|
+
if (candidate === null)
|
|
1295
|
+
return false;
|
|
1296
|
+
const key = oauthFlowKeys.flow(await oauthStateDigest(candidate));
|
|
1297
|
+
const raw = await this.storage.get(key);
|
|
1298
|
+
const flow = raw === null ? undefined : await this.openFlow(key, raw, true);
|
|
1299
|
+
if (raw === null || !flow || flow.consumed)
|
|
1300
|
+
return false;
|
|
1301
|
+
this.callback = { key, raw, flow, claimed: false };
|
|
1302
|
+
this.epoch = flow.epoch;
|
|
1303
|
+
return true;
|
|
1563
1304
|
}
|
|
1564
1305
|
/**
|
|
1565
|
-
*
|
|
1566
|
-
*
|
|
1567
|
-
*
|
|
1306
|
+
* @internal The first half of the fence a code exchange crosses after every
|
|
1307
|
+
* read it depends on, just before the code leaves: claim the consent by
|
|
1308
|
+
* compare-and-set from the exact record `verifyState` found to a claimed
|
|
1309
|
+
* one. Of several callbacks carrying one state, exactly one wins; the
|
|
1310
|
+
* others send nothing. A claim that fails because a restart swept the
|
|
1311
|
+
* consent reports the supersession. The claim is spent whether or not the
|
|
1312
|
+
* exchange succeeds; the SDK's one retry after a refused code is answered
|
|
1313
|
+
* with that refusal, never sent again.
|
|
1568
1314
|
*/
|
|
1569
|
-
async
|
|
1570
|
-
|
|
1315
|
+
async claimCodeExchange() {
|
|
1316
|
+
if (this.exchangeRefusal)
|
|
1317
|
+
return this.exchangeRefusal.clone();
|
|
1318
|
+
await this.claimConsent();
|
|
1319
|
+
return undefined;
|
|
1571
1320
|
}
|
|
1572
|
-
/**
|
|
1573
|
-
async
|
|
1574
|
-
|
|
1321
|
+
/** Terminate the verified consent on an error, sharing the code exchange CAS. */
|
|
1322
|
+
async consumeAuthError() {
|
|
1323
|
+
const { grant } = await this.readGrant();
|
|
1324
|
+
if (!this.callback || grant.epoch !== this.callback.flow.epoch)
|
|
1325
|
+
throw this.flowSuperseded();
|
|
1326
|
+
await this.checkFlowBinding(grant);
|
|
1327
|
+
await this.claimConsent();
|
|
1328
|
+
this.callback = undefined;
|
|
1329
|
+
}
|
|
1330
|
+
async claimConsent() {
|
|
1331
|
+
const callback = this.callback;
|
|
1332
|
+
if (!callback)
|
|
1333
|
+
throw this.flowSuperseded();
|
|
1334
|
+
if (callback.claimed)
|
|
1335
|
+
throw new OAuthCallbackClaimedError(this.connectorId);
|
|
1336
|
+
callback.claimed = true;
|
|
1337
|
+
const { epoch, at } = callback.flow;
|
|
1338
|
+
const claimed = JSON.stringify({
|
|
1339
|
+
connectaOAuthFlow: FLOW_VERSION,
|
|
1340
|
+
epoch,
|
|
1341
|
+
at,
|
|
1342
|
+
consumed: true,
|
|
1343
|
+
});
|
|
1344
|
+
const remaining = Math.ceil((at + OAUTH_FLOW_TTL_SECONDS * 1000 - Date.now()) / 1000);
|
|
1345
|
+
const ttlSeconds = Math.min(OAUTH_FLOW_TTL_SECONDS, Math.max(1, remaining));
|
|
1346
|
+
if (await this.storage.compareAndSet(callback.key, callback.raw, claimed, { ttlSeconds })) {
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
if (epochOf(await this.storage.get(GRANT)) !== epoch)
|
|
1350
|
+
throw this.flowSuperseded();
|
|
1351
|
+
throw new OAuthCallbackClaimedError(this.connectorId);
|
|
1352
|
+
}
|
|
1353
|
+
/**
|
|
1354
|
+
* @internal The second half: read the live epoch and, in the reaction to
|
|
1355
|
+
* that read, dispatch. No await stands between the check and `send`. A
|
|
1356
|
+
* reset published at or before the read fails the exchange with nothing
|
|
1357
|
+
* sent; one published after it, while the request is leaving, cannot be
|
|
1358
|
+
* ordered before the send without a lock across requests, and its tokens
|
|
1359
|
+
* are refused by the grant's compare-and-set: one more consent.
|
|
1360
|
+
*/
|
|
1361
|
+
async dispatchCodeExchange(send, endpoint) {
|
|
1362
|
+
const epoch = this.epoch;
|
|
1363
|
+
if (!this.codeExchange || epoch === undefined)
|
|
1364
|
+
throw this.flowSuperseded();
|
|
1365
|
+
const raw = await this.storage.get(GRANT);
|
|
1366
|
+
const grant = await this.openGrant(raw);
|
|
1367
|
+
if (grant.epoch !== epoch)
|
|
1368
|
+
throw this.flowSuperseded();
|
|
1369
|
+
await this.checkFlowBinding(grant);
|
|
1370
|
+
if (endpoint !== undefined && this.callback?.flow.binding?.tokenEndpoint !== String(endpoint)) {
|
|
1371
|
+
throw this.flowSuperseded();
|
|
1372
|
+
}
|
|
1373
|
+
// Opening ciphertext may yield. Re-read the exact record after opening,
|
|
1374
|
+
// then check and send in the same reaction, without another await.
|
|
1375
|
+
return this.storage.get(GRANT).then((current) => {
|
|
1376
|
+
if (current !== raw)
|
|
1377
|
+
throw this.flowSuperseded();
|
|
1378
|
+
const sent = send();
|
|
1379
|
+
sent.catch(() => { });
|
|
1380
|
+
return sent;
|
|
1381
|
+
});
|
|
1575
1382
|
}
|
|
1576
|
-
/**
|
|
1577
|
-
|
|
1578
|
-
|
|
1383
|
+
/** @internal Keep a refused code's answer for the SDK's retry to read. */
|
|
1384
|
+
recordCodeExchangeRefusal(forSdk) {
|
|
1385
|
+
this.exchangeRefusal = forSdk.clone();
|
|
1579
1386
|
}
|
|
1580
|
-
/**
|
|
1581
|
-
async
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1387
|
+
/** @internal Observe resolution while accepting the code response, never on a later SDK save. */
|
|
1388
|
+
async recordCodeExchangeTokens(tokens) {
|
|
1389
|
+
this.exchangeConsent = undefined;
|
|
1390
|
+
if (tokens.refresh_token === undefined)
|
|
1391
|
+
return;
|
|
1392
|
+
try {
|
|
1393
|
+
const digest = await oauthStateDigest(tokens.refresh_token);
|
|
1394
|
+
const resolution = await this.storage.get(oauthRefreshSpentKeys.spent(digest));
|
|
1395
|
+
const spent = storedRefreshSpent(resolution);
|
|
1396
|
+
// Observing the exact resolved storage write before consent completion
|
|
1397
|
+
// proves their order without comparing isolate clocks. A consent that
|
|
1398
|
+
// saw outstanding/ambiguous cannot become eligible on a delayed save.
|
|
1399
|
+
if (spent?.state === "resolved")
|
|
1400
|
+
this.exchangeConsent = { digest, resolution: resolution };
|
|
1401
|
+
}
|
|
1402
|
+
catch {
|
|
1403
|
+
throw this.credentialWriteError();
|
|
1404
|
+
}
|
|
1585
1405
|
}
|
|
1586
1406
|
/**
|
|
1587
|
-
*
|
|
1588
|
-
*
|
|
1589
|
-
*
|
|
1590
|
-
* credentials it read before this reset.
|
|
1591
|
-
*
|
|
1592
|
-
* Once the fence is durable, attempt every deletion even if one fails. A
|
|
1593
|
-
* partial backend outage should not leave unrelated secrets behind merely
|
|
1594
|
-
* because an earlier key happened to be the first failed delete.
|
|
1595
|
-
*
|
|
1596
|
-
* A reset's storage work does not grow with the resets before it. After
|
|
1597
|
-
* the fence it deletes the one generation it retired, and retries any of
|
|
1598
|
-
* the RETRY_PROBES most recent ones whose cleanup failed. Before publishing,
|
|
1599
|
-
* it sweeps at most MAX_EXPIRED_SWEEP generations retired more than
|
|
1600
|
-
* CLEANUP_GRACE_MS ago — every value, then its manifest and times — and
|
|
1601
|
-
* only a generation whose sweep fully succeeded is left out of the new
|
|
1602
|
-
* lineage. Everything else is carried with its retirement time: a
|
|
1603
|
-
* generation still inside its grace whose cleanup finished is not deleted
|
|
1604
|
-
* again, because a late write into it is unreadable behind the fence and
|
|
1605
|
-
* the late writer deletes it itself, or records the generation again if it
|
|
1606
|
-
* cannot; the sweep reclaims whatever is left once the grace has passed.
|
|
1607
|
-
* The live manifest is never rewritten: an entry leaves the lineage only by
|
|
1608
|
-
* being absent from the next epoch's manifest, which no other request
|
|
1609
|
-
* writes until that epoch is active.
|
|
1407
|
+
* Remove what the SDK asks, but only as this flow saw it: a refused code or
|
|
1408
|
+
* refresh says nothing about a client or tokens another flow wrote since.
|
|
1409
|
+
* The verifier lives in its consent, which the claim already spent.
|
|
1610
1410
|
*/
|
|
1611
|
-
|
|
1612
|
-
const
|
|
1613
|
-
|
|
1614
|
-
|
|
1411
|
+
async invalidateCredentials(scope) {
|
|
1412
|
+
const fields = {
|
|
1413
|
+
all: ["client", "tokens", "discovery"],
|
|
1414
|
+
client: ["client"],
|
|
1415
|
+
tokens: ["tokens"],
|
|
1416
|
+
discovery: ["discovery"],
|
|
1417
|
+
verifier: [],
|
|
1418
|
+
}[scope];
|
|
1419
|
+
if (fields.length > 0) {
|
|
1420
|
+
await this.updateGrant((grant) => {
|
|
1421
|
+
const body = { ...grant.body };
|
|
1422
|
+
let changed = false;
|
|
1423
|
+
for (const field of fields) {
|
|
1424
|
+
const current = field === "client" ? body.client?.value : body[field];
|
|
1425
|
+
if (current === undefined)
|
|
1426
|
+
continue;
|
|
1427
|
+
if (field !== "discovery" && fingerprint(current) !== this.seen[field])
|
|
1428
|
+
continue;
|
|
1429
|
+
delete body[field];
|
|
1430
|
+
changed = true;
|
|
1431
|
+
}
|
|
1432
|
+
return changed ? { ...grant, body } : undefined;
|
|
1433
|
+
}, { quiet: true });
|
|
1434
|
+
}
|
|
1435
|
+
// The SDK invalidates after a refused refresh and then starts over, which
|
|
1436
|
+
// on `invalid_client` means registering a new client. A passive request
|
|
1437
|
+
// must not begin authorization, so it ends here, as auth_required.
|
|
1438
|
+
if (!this.allowAuthorization && this.refreshFailure?.kind === "dead") {
|
|
1439
|
+
throw this.authorizationRefused();
|
|
1440
|
+
}
|
|
1615
1441
|
}
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1442
|
+
// --- refresh, for the coordinator ---------------------------------------------
|
|
1443
|
+
/**
|
|
1444
|
+
* @internal How the refresh this provider owned or joined failed; undefined
|
|
1445
|
+
* clears it. The hooks the SDK calls next (`state`, `saveCodeVerifier`,
|
|
1446
|
+
* `redirectToAuthorization`, `invalidateCredentials`, `saveTokens`) read it.
|
|
1447
|
+
*/
|
|
1448
|
+
recordRefreshFailure(failure) {
|
|
1449
|
+
this.refreshFailure = failure;
|
|
1450
|
+
}
|
|
1451
|
+
/** @internal A shared-storage claim precedes every refresh dispatch. */
|
|
1452
|
+
async claimRefresh(epoch, requested, signal, sharedWait) {
|
|
1453
|
+
const waiting = sharedWait ?? refreshWait(signal);
|
|
1454
|
+
const wait = waiting.run;
|
|
1455
|
+
let delay = 10;
|
|
1456
|
+
try {
|
|
1457
|
+
const digest = await wait(() => oauthStateDigest(requested ?? ""));
|
|
1458
|
+
const key = oauthRefreshKeys.lease(epoch, digest);
|
|
1459
|
+
const spentKey = oauthRefreshSpentKeys.spent(digest);
|
|
1460
|
+
while (!waiting.signal.aborted) {
|
|
1461
|
+
if (signal?.aborted)
|
|
1462
|
+
throw aborted(signal);
|
|
1463
|
+
const current = await wait(() => this.storedTokens());
|
|
1464
|
+
if (current?.refresh_token !== requested || (current && this.refreshedSinceRead(current, epoch))) {
|
|
1465
|
+
if (!current)
|
|
1466
|
+
this.recordRefreshFailure({ kind: "dead" });
|
|
1467
|
+
return current ? this.adoptRefresh(current) : Response.json({ error: "invalid_grant" }, { status: 400 });
|
|
1626
1468
|
}
|
|
1627
|
-
|
|
1628
|
-
|
|
1469
|
+
const raw = await wait(() => this.storage.get(key));
|
|
1470
|
+
const stored = raw === null ? undefined : parsed(raw);
|
|
1471
|
+
if (plainObject(stored) &&
|
|
1472
|
+
stored.completed === true &&
|
|
1473
|
+
current &&
|
|
1474
|
+
stored.tokenDigest === (await wait(() => refreshTokensDigest(current)))) {
|
|
1475
|
+
return this.adoptRefresh(current);
|
|
1629
1476
|
}
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1477
|
+
if (plainObject(stored) && (plainObject(stored.verdict) || stored.completed === true)) {
|
|
1478
|
+
this.recordRefreshFailure({ kind: "dead" });
|
|
1479
|
+
await wait(() => this.discardRefusedGrant(requested, epoch));
|
|
1480
|
+
throw new UnauthorizedError("OAuth refresh grant is no longer active.");
|
|
1481
|
+
}
|
|
1482
|
+
if (plainObject(stored) && stored.state !== "claimed") {
|
|
1483
|
+
// Never compare another isolate's clock for a sent request. The
|
|
1484
|
+
// durable record survives expiry of this storage-owned liveness key.
|
|
1485
|
+
const active = typeof stored.activeKey === "string"
|
|
1486
|
+
? await wait(() => this.storage.get(stored.activeKey))
|
|
1487
|
+
: null;
|
|
1488
|
+
if (active === null) {
|
|
1489
|
+
const refused = JSON.stringify({ ...stored, verdict: { kind: "dead" } });
|
|
1490
|
+
if (await wait(() => this.storage.compareAndSet(key, raw, refused))) {
|
|
1491
|
+
await wait(() => this.resolveRefreshSpent({ key, spentKey, spentExpected: null, raw: raw }, "ambiguous"));
|
|
1492
|
+
this.recordRefreshFailure({ kind: "dead" });
|
|
1493
|
+
await wait(() => this.discardRefusedGrant(requested, epoch));
|
|
1494
|
+
throw new UnauthorizedError("OAuth refresh outcome is unknown; authorization required.");
|
|
1495
|
+
}
|
|
1496
|
+
continue;
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
else {
|
|
1500
|
+
const spentExpected = await wait(() => this.storage.get(spentKey));
|
|
1501
|
+
if (spentExpected !== null) {
|
|
1502
|
+
const spent = storedRefreshSpent(spentExpected);
|
|
1503
|
+
const consent = (await wait(() => this.boundGrant())).body.refreshConsent;
|
|
1504
|
+
if (spent?.state !== "resolved" ||
|
|
1505
|
+
spent.epoch === epoch ||
|
|
1506
|
+
consent?.digest !== digest ||
|
|
1507
|
+
consent.resolution !== spentExpected) {
|
|
1508
|
+
// Reset can sweep the old lease while its request remains live.
|
|
1509
|
+
// Only storage-owned expiry can make an outstanding send ambiguous.
|
|
1510
|
+
if (spent?.state === "outstanding" && (await wait(() => this.storage.get(spent.activeKey))) === null) {
|
|
1511
|
+
await wait(() => this.storage.compareAndSet(spentKey, spentExpected, JSON.stringify({ ...spent, state: "ambiguous" })));
|
|
1512
|
+
}
|
|
1513
|
+
this.recordRefreshFailure({ kind: "dead" });
|
|
1514
|
+
await wait(() => this.discardRefusedGrant(requested, epoch));
|
|
1515
|
+
throw new UnauthorizedError("OAuth refresh token was already spent; authorization required.");
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
if (raw === null ||
|
|
1519
|
+
(plainObject(stored) && typeof stored.expiresAt === "number" && stored.expiresAt <= this.refreshNow())) {
|
|
1520
|
+
// Clock skew can replace an unsent claim; its dispatch CAS then loses.
|
|
1521
|
+
const claimed = JSON.stringify({
|
|
1522
|
+
connectaOAuthRefresh: 1,
|
|
1523
|
+
holder: crypto.randomUUID(),
|
|
1524
|
+
state: "claimed",
|
|
1525
|
+
expiresAt: this.refreshNow() + OAUTH_REFRESH_LEASE_SECONDS * 1000,
|
|
1526
|
+
});
|
|
1527
|
+
let won;
|
|
1528
|
+
try {
|
|
1529
|
+
won = await wait(() => this.storage.compareAndSet(key, raw, claimed));
|
|
1530
|
+
}
|
|
1531
|
+
catch {
|
|
1532
|
+
throw this.credentialWriteError();
|
|
1533
|
+
}
|
|
1534
|
+
if (won) {
|
|
1535
|
+
try {
|
|
1536
|
+
const latest = await wait(() => this.storedTokens());
|
|
1537
|
+
if (latest?.refresh_token !== requested || (latest && this.refreshedSinceRead(latest, epoch))) {
|
|
1538
|
+
await wait(() => this.releaseRefresh({ key, spentKey, spentExpected, raw: claimed }));
|
|
1539
|
+
return latest
|
|
1540
|
+
? this.adoptRefresh(latest)
|
|
1541
|
+
: Response.json({ error: "invalid_grant" }, { status: 400 });
|
|
1542
|
+
}
|
|
1543
|
+
if (signal?.aborted)
|
|
1544
|
+
throw aborted(signal);
|
|
1545
|
+
return { key, spentKey, spentExpected, raw: claimed };
|
|
1546
|
+
}
|
|
1547
|
+
catch (error) {
|
|
1548
|
+
// An unsent claim can expire safely. A caller that has left must
|
|
1549
|
+
// settle its local flight without awaiting cleanup storage.
|
|
1550
|
+
if (!waiting.signal.aborted && !signal?.aborted) {
|
|
1551
|
+
await wait(() => this.releaseRefresh({ key, spentKey, spentExpected, raw: claimed }));
|
|
1552
|
+
}
|
|
1553
|
+
throw error;
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
await runEdge(Effect.raceAllFirst([
|
|
1559
|
+
Effect.sleep(delay),
|
|
1560
|
+
fromSignal(waiting.signal),
|
|
1561
|
+
...(signal ? [fromSignal(signal)] : []),
|
|
1562
|
+
]));
|
|
1563
|
+
delay = Math.min(250, delay * 2);
|
|
1564
|
+
}
|
|
1660
1565
|
}
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1566
|
+
catch (error) {
|
|
1567
|
+
if (signal?.aborted)
|
|
1568
|
+
throw aborted(signal);
|
|
1569
|
+
if (!waiting.signal.aborted)
|
|
1570
|
+
throw error;
|
|
1666
1571
|
}
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
await this.publishLineage(active, retired);
|
|
1671
|
-
// This is the one authoritative transition. From this point onward every
|
|
1672
|
-
// old physical namespace is unreadable. There is deliberately no second
|
|
1673
|
-
// "finalize" write: concurrent resets therefore cannot overwrite a newer
|
|
1674
|
-
// reset's epoch after their cleanup finishes out of order.
|
|
1675
|
-
// A rejection may follow a committed fence. Keep its lineage even when
|
|
1676
|
-
// the answer is lost: the next reset must still find the retired grants.
|
|
1677
|
-
// A manifest for an epoch that never activated is harmless residue.
|
|
1678
|
-
await this.storage.set("oauth:generation", active);
|
|
1679
|
-
this.refreshCoordinator?.retire(previous);
|
|
1680
|
-
if (reusableClient && !this.signal?.aborted) {
|
|
1681
|
-
// readValue opened the old physical key; writeValue seals the plaintext
|
|
1682
|
-
// under the new one. Copying ciphertext would fail its AAD check.
|
|
1683
|
-
this.captureGeneration(active);
|
|
1684
|
-
await this.writeValue("oauth:client", reusableClient.value, (value) => JSON.stringify(value), reusableClient.issuer, undefined, this.clientBinding);
|
|
1572
|
+
finally {
|
|
1573
|
+
if (!sharedWait)
|
|
1574
|
+
waiting.close();
|
|
1685
1575
|
}
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
const
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
.
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1576
|
+
throw this.refreshContended();
|
|
1577
|
+
}
|
|
1578
|
+
/** @internal Only the successful claimed-to-dispatched CAS may send. */
|
|
1579
|
+
async dispatchRefresh(lease, send, waiting) {
|
|
1580
|
+
const stored = parsed(lease.raw);
|
|
1581
|
+
const epoch = await waiting.run(() => this.flowEpoch());
|
|
1582
|
+
const activeKey = oauthRefreshActiveKeys.holder(epoch, stored.holder);
|
|
1583
|
+
await waiting.run(() => this.storage.set(activeKey, JSON.stringify({ connectaOAuthRefresh: 1 }), {
|
|
1584
|
+
ttlSeconds: OAUTH_REFRESH_LEASE_SECONDS,
|
|
1585
|
+
}));
|
|
1586
|
+
const dispatched = JSON.stringify({ ...stored, state: "dispatched", activeKey });
|
|
1587
|
+
const transition = this.storage.compareAndSet(lease.key, lease.raw, dispatched).then((won) => {
|
|
1588
|
+
if (!won)
|
|
1589
|
+
throw this.refreshContended();
|
|
1590
|
+
lease.raw = dispatched;
|
|
1591
|
+
if (waiting.signal.aborted)
|
|
1592
|
+
throw aborted(waiting.signal);
|
|
1593
|
+
});
|
|
1594
|
+
void transition.catch(() => { });
|
|
1595
|
+
await waiting.run(() => transition);
|
|
1596
|
+
// One CAS owns the fingerprint across every epoch. Only a later consent
|
|
1597
|
+
// that observed a resolved write may replace it. No TTL or deletion path.
|
|
1598
|
+
lease.spentRaw = JSON.stringify({
|
|
1599
|
+
connectaOAuthRefreshSpent: 1,
|
|
1600
|
+
epoch,
|
|
1601
|
+
holder: stored.holder,
|
|
1602
|
+
activeKey,
|
|
1603
|
+
state: "outstanding",
|
|
1706
1604
|
});
|
|
1707
|
-
const
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1605
|
+
const preparation = this.storage
|
|
1606
|
+
.compareAndSet(lease.spentKey, lease.spentExpected, lease.spentRaw)
|
|
1607
|
+
.catch(() => {
|
|
1608
|
+
this.recordRefreshFailure({ kind: "dead" });
|
|
1609
|
+
throw new UnauthorizedError("OAuth refresh dispatch could not be recorded; authorization required.");
|
|
1610
|
+
})
|
|
1611
|
+
.then((unspent) => {
|
|
1612
|
+
if (!unspent) {
|
|
1613
|
+
this.recordRefreshFailure({ kind: "dead" });
|
|
1614
|
+
throw new UnauthorizedError("OAuth refresh token was already spent; authorization required.");
|
|
1615
|
+
}
|
|
1616
|
+
// A storage operation can finish after its waiter left. Never send from
|
|
1617
|
+
// abandoned preparation, even if both CAS operations succeeded.
|
|
1618
|
+
if (waiting.signal.aborted)
|
|
1619
|
+
throw aborted(waiting.signal);
|
|
1620
|
+
const sent = send();
|
|
1621
|
+
sent.catch(() => { });
|
|
1622
|
+
return { sent };
|
|
1623
|
+
});
|
|
1624
|
+
void preparation.catch(() => { });
|
|
1625
|
+
const { sent } = await waiting.run(() => preparation);
|
|
1626
|
+
return sent;
|
|
1627
|
+
}
|
|
1628
|
+
/** @internal Release by CAS after a commit or a classified failure. */
|
|
1629
|
+
async releaseRefresh(lease, verdict, tokens, resolution = "ambiguous") {
|
|
1630
|
+
const stored = parsed(lease.raw);
|
|
1631
|
+
// Only an unsent claim may be removed. Dispatched outcomes never reopen it.
|
|
1632
|
+
const next = stored.state === "claimed"
|
|
1633
|
+
? null
|
|
1634
|
+
: JSON.stringify({
|
|
1635
|
+
...stored,
|
|
1636
|
+
...(tokens
|
|
1637
|
+
? { completed: true, tokenDigest: await refreshTokensDigest(tokens) }
|
|
1638
|
+
: { verdict: { kind: "dead" } }),
|
|
1639
|
+
});
|
|
1640
|
+
// Verdicts contain fixed typed facts; no downstream causes or text.
|
|
1641
|
+
if (verdict)
|
|
1642
|
+
this.recordRefreshFailure(verdict);
|
|
1643
|
+
await this.resolveRefreshSpent(lease, resolution);
|
|
1644
|
+
const released = await this.storage.compareAndSet(lease.key, lease.raw, next).catch(() => false);
|
|
1645
|
+
if (released && stored.activeKey)
|
|
1646
|
+
await this.storage.delete(stored.activeKey).catch(() => { });
|
|
1647
|
+
}
|
|
1648
|
+
async resolveRefreshSpent(lease, state) {
|
|
1649
|
+
try {
|
|
1650
|
+
const raw = lease.spentRaw ?? (await this.storage.get(lease.spentKey));
|
|
1651
|
+
const spent = storedRefreshSpent(raw);
|
|
1652
|
+
const holder = parsed(lease.raw).holder;
|
|
1653
|
+
if (spent?.state !== "outstanding" || spent.holder !== holder)
|
|
1654
|
+
return;
|
|
1655
|
+
// The timestamp belongs to this resolution write, never to dispatch.
|
|
1656
|
+
// Consent copies the exact stored receipt, so wall clocks grant no sends.
|
|
1657
|
+
await this.storage.compareAndSet(lease.spentKey, raw, JSON.stringify({
|
|
1658
|
+
...spent,
|
|
1659
|
+
state,
|
|
1660
|
+
...(state === "resolved" ? { resolvedAt: Date.now() } : {}),
|
|
1661
|
+
}));
|
|
1662
|
+
}
|
|
1663
|
+
catch {
|
|
1664
|
+
// A lost resolution answer leaves outstanding or the durable outcome;
|
|
1665
|
+
// either blocks replay until a later consent observes a resolved write.
|
|
1666
|
+
}
|
|
1715
1667
|
}
|
|
1716
|
-
|
|
1717
|
-
|
|
1668
|
+
/** @internal The same typed verdict for local and cross-isolate joiners. */
|
|
1669
|
+
refreshVerdict() {
|
|
1670
|
+
return this.refreshFailure;
|
|
1671
|
+
}
|
|
1672
|
+
/** @internal The grant's tokens as stored, in the flow's epoch. */
|
|
1673
|
+
async storedTokens() {
|
|
1674
|
+
return (await this.boundGrant()).body.tokens;
|
|
1675
|
+
}
|
|
1676
|
+
/** @internal True when a refresh landed after this flow's issuer-aware read. */
|
|
1677
|
+
refreshedSinceRead(current, epoch) {
|
|
1678
|
+
const basis = this.refreshBasis;
|
|
1679
|
+
return basis !== undefined && basis.epoch === epoch && basis.accessToken !== current.access_token;
|
|
1680
|
+
}
|
|
1681
|
+
/** @internal The issuer this flow's issuer-aware read bound its refresh to. */
|
|
1682
|
+
refreshIssuer(epoch) {
|
|
1683
|
+
const basis = this.refreshBasis;
|
|
1684
|
+
return basis?.epoch === epoch ? basis.issuer : undefined;
|
|
1685
|
+
}
|
|
1686
|
+
/** @internal Hand the SDK tokens already stored, so its save is not repeated. */
|
|
1687
|
+
adoptRefresh(tokens) {
|
|
1688
|
+
this.storedRefresh = tokens.access_token;
|
|
1689
|
+
return tokenResponse(tokens);
|
|
1690
|
+
}
|
|
1691
|
+
/**
|
|
1692
|
+
* @internal Store an accepted rotation in `epoch`, even after the request
|
|
1693
|
+
* that redeemed it has left, unless a newer rotation, another server's
|
|
1694
|
+
* grant, or another epoch is there now. A token response names no issuer:
|
|
1695
|
+
* the one the refreshed grant was bound to is stamped here.
|
|
1696
|
+
*/
|
|
1697
|
+
async storeRefresh(epoch, requested, tokens, issuer) {
|
|
1698
|
+
for (let attempt = 0; attempt < MAX_GRANT_WRITES; attempt++) {
|
|
1699
|
+
try {
|
|
1700
|
+
const { raw, grant } = await this.readGrant();
|
|
1701
|
+
if (grant.epoch !== epoch || (issuer !== undefined && grant.body.issuer !== issuer))
|
|
1702
|
+
return undefined;
|
|
1703
|
+
if (grant.body.tokens?.refresh_token !== requested)
|
|
1704
|
+
return grant.body.tokens;
|
|
1705
|
+
const committed = issuer !== undefined ? { ...tokens, issuer } : tokens;
|
|
1706
|
+
const encoded = await this.encodeGrant({ ...grant, body: { ...grant.body, tokens: committed } });
|
|
1707
|
+
if (await this.storage.compareAndSet(GRANT, raw, encoded))
|
|
1708
|
+
return committed;
|
|
1709
|
+
}
|
|
1710
|
+
catch {
|
|
1711
|
+
// A failed or lost CAS answer may still have committed. Re-read the
|
|
1712
|
+
// epoch and retry these in-memory tokens without another HTTP request.
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
throw this.credentialWriteError();
|
|
1716
|
+
}
|
|
1717
|
+
/**
|
|
1718
|
+
* @internal Drop the tokens the authorization server just refused, only
|
|
1719
|
+
* while the grant still holds that refresh token in `epoch`: a consent
|
|
1720
|
+
* that completed meanwhile survives. Best effort: if storage fails, the
|
|
1721
|
+
* next refresh is refused again and ends the same way.
|
|
1722
|
+
*/
|
|
1723
|
+
async discardRefusedGrant(refreshToken, epoch) {
|
|
1718
1724
|
try {
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1725
|
+
await this.updateGrant((grant) => {
|
|
1726
|
+
if (grant.body.tokens?.refresh_token !== refreshToken)
|
|
1727
|
+
return undefined;
|
|
1728
|
+
const { tokens: _refused, ...body } = grant.body;
|
|
1729
|
+
return { ...grant, body };
|
|
1730
|
+
}, { epoch, quiet: true, commit: true });
|
|
1731
|
+
}
|
|
1732
|
+
catch {
|
|
1733
|
+
// See above: a refusal that could not be recorded recurs, it does not hide.
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
// --- restart and disconnect ---------------------------------------------------
|
|
1737
|
+
/**
|
|
1738
|
+
* Replace the epoch, by one compare-and-set: the grant goes with it, and
|
|
1739
|
+
* every flow bound to the old epoch fails at its next read or write. A
|
|
1740
|
+
* forced restart may carry the registration forward (`carriableClient`);
|
|
1741
|
+
* a disconnect leaves only its epoch, which no passive request leaves.
|
|
1742
|
+
* Then the old epochs' consents are removed; that is hygiene, since their
|
|
1743
|
+
* callbacks fail the epoch check anyway and the records expire.
|
|
1744
|
+
*/
|
|
1745
|
+
resetAuthorization(operatorDisconnected = false, preserveClient = false) {
|
|
1746
|
+
const reset = this.performReset(operatorDisconnected, preserveClient);
|
|
1747
|
+
this.onReset?.(reset);
|
|
1748
|
+
return reset;
|
|
1749
|
+
}
|
|
1750
|
+
/** Remove the local grant before a bounded, best-effort RFC 7009 request. */
|
|
1751
|
+
disconnectAuthorization(send) {
|
|
1752
|
+
const reset = this.performReset(true, false, send);
|
|
1753
|
+
this.onReset?.(reset);
|
|
1754
|
+
return reset;
|
|
1755
|
+
}
|
|
1756
|
+
async performReset(operatorDisconnected, preserveClient, revoke) {
|
|
1757
|
+
const epoch = `${operatorDisconnected ? DISCONNECTED_EPOCH_PREFIX : ACTIVE_EPOCH_PREFIX}${crypto.randomUUID()}`;
|
|
1758
|
+
for (let attempt = 0; attempt < MAX_GRANT_WRITES; attempt++) {
|
|
1759
|
+
const { raw, grant } = await this.readGrant();
|
|
1760
|
+
const carried = preserveClient && !operatorDisconnected ? this.carriableClient(grant) : undefined;
|
|
1761
|
+
const encoded = await this.encodeGrant({ epoch, body: carried ?? {}, cleanupPending: true });
|
|
1762
|
+
let written;
|
|
1763
|
+
try {
|
|
1764
|
+
written = await this.storage.compareAndSet(GRANT, raw, encoded);
|
|
1727
1765
|
}
|
|
1728
|
-
|
|
1729
|
-
|
|
1766
|
+
catch {
|
|
1767
|
+
throw this.credentialWriteError();
|
|
1730
1768
|
}
|
|
1731
|
-
|
|
1732
|
-
|
|
1769
|
+
if (written) {
|
|
1770
|
+
this.refreshCoordinator?.retire(grant.epoch);
|
|
1771
|
+
try {
|
|
1772
|
+
await this.cleanupV2(encoded);
|
|
1773
|
+
await this.sweepConsents();
|
|
1774
|
+
await this.sweepRefreshLeases();
|
|
1775
|
+
}
|
|
1776
|
+
finally {
|
|
1777
|
+
if (revoke)
|
|
1778
|
+
await revokeDownstreamGrant(grant.body, revoke, this.clientOptions?.client);
|
|
1779
|
+
}
|
|
1780
|
+
return;
|
|
1733
1781
|
}
|
|
1734
|
-
|
|
1735
|
-
|
|
1782
|
+
}
|
|
1783
|
+
throw this.credentialWriteError();
|
|
1784
|
+
}
|
|
1785
|
+
/**
|
|
1786
|
+
* Remove every consent of an epoch no longer live. The records are read
|
|
1787
|
+
* before the epoch, and epochs never recur, so a consent whose epoch is not
|
|
1788
|
+
* the live one then can never complete; each goes by compare-and-set
|
|
1789
|
+
* against what was read. Best effort.
|
|
1790
|
+
*/
|
|
1791
|
+
async sweepConsents() {
|
|
1792
|
+
try {
|
|
1793
|
+
const consents = [];
|
|
1794
|
+
for (const key of await this.storage.list(oauthFlowKeys.prefix)) {
|
|
1795
|
+
const raw = await this.storage.get(key);
|
|
1796
|
+
if (raw !== null)
|
|
1797
|
+
consents.push([key, raw]);
|
|
1736
1798
|
}
|
|
1737
|
-
|
|
1738
|
-
|
|
1799
|
+
const live = epochOf(await this.storage.get(GRANT));
|
|
1800
|
+
for (const [key, raw] of consents) {
|
|
1801
|
+
const flow = parsed(raw);
|
|
1802
|
+
if (plainObject(flow) && flow.epoch === live)
|
|
1803
|
+
continue;
|
|
1804
|
+
await this.storage.compareAndSet(key, raw, null);
|
|
1739
1805
|
}
|
|
1740
|
-
|
|
1741
|
-
|
|
1806
|
+
}
|
|
1807
|
+
catch {
|
|
1808
|
+
// Hygiene only: the epoch, not this, fences a stale consent.
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
async sweepRefreshLeases() {
|
|
1812
|
+
try {
|
|
1813
|
+
const records = [];
|
|
1814
|
+
// A reset may sweep an old lease while its HTTP request is still live.
|
|
1815
|
+
// Keep holder liveness until completion or its storage-owned TTL: spent
|
|
1816
|
+
// records in later epochs still use it to distinguish live from crashed.
|
|
1817
|
+
for (const key of await this.storage.list(oauthRefreshKeys.prefix)) {
|
|
1818
|
+
const raw = await this.storage.get(key);
|
|
1819
|
+
if (raw !== null)
|
|
1820
|
+
records.push([key, raw]);
|
|
1821
|
+
}
|
|
1822
|
+
const live = epochOf(await this.storage.get(GRANT));
|
|
1823
|
+
for (const [key, raw] of records) {
|
|
1824
|
+
if (!key.startsWith(`${oauthRefreshKeys.prefix}${live}:`)) {
|
|
1825
|
+
await this.storage.compareAndSet(key, raw, null);
|
|
1826
|
+
}
|
|
1742
1827
|
}
|
|
1743
1828
|
}
|
|
1744
|
-
catch
|
|
1745
|
-
|
|
1746
|
-
this.failRefreshFlight(error);
|
|
1747
|
-
throw error;
|
|
1829
|
+
catch {
|
|
1830
|
+
// Stale dispatch records cannot grant credentials: epochs never recur.
|
|
1748
1831
|
}
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1832
|
+
}
|
|
1833
|
+
/**
|
|
1834
|
+
* The registration a forced restart may carry into its new epoch, or
|
|
1835
|
+
* nothing. It must be bound to a server (`issuer`) and registered under this
|
|
1836
|
+
* connector's exact configuration, redirect URI, and client metadata
|
|
1837
|
+
* (`binding`), in this owner's own grant. Never carried:
|
|
1838
|
+
*
|
|
1839
|
+
* - A URL-based client (`clientMetadataUrl`): nothing was registered, and
|
|
1840
|
+
* leaving it behind makes the SDK ask fresh metadata whether the server
|
|
1841
|
+
* still accepts one.
|
|
1842
|
+
* - One whose `client_secret_expires_at` has passed (RFC 7591: zero means
|
|
1843
|
+
* never).
|
|
1844
|
+
* - One a previous restart carried that has not since earned tokens. The
|
|
1845
|
+
* SDK builds a consent URL locally and sends nothing that could answer
|
|
1846
|
+
* `invalid_client`, and RFC 6749 section 4.1.2.1 forbids redirecting an
|
|
1847
|
+
* unknown client to the callback, so tokens are the only proof the server
|
|
1848
|
+
* still knows it. Without them the next restart registers again.
|
|
1849
|
+
*/
|
|
1850
|
+
carriableClient(grant) {
|
|
1851
|
+
const { issuer, client, tokens } = grant.body;
|
|
1852
|
+
const clientId = client?.value.client_id;
|
|
1853
|
+
const expiresAt = client?.value.client_secret_expires_at;
|
|
1854
|
+
if (!this.clientBinding ||
|
|
1855
|
+
issuer === undefined ||
|
|
1856
|
+
!client ||
|
|
1857
|
+
client.binding !== this.clientBinding ||
|
|
1858
|
+
typeof clientId !== "string" ||
|
|
1859
|
+
client.registrationPath === "cimd" ||
|
|
1860
|
+
client.registrationPath === "static" ||
|
|
1861
|
+
// Legacy records have no mechanism field: retain their conservative
|
|
1862
|
+
// URL-client refusal, but never reinterpret a recorded DCR identity.
|
|
1863
|
+
(client.registrationPath === undefined && clientId === this.clientMetadataUrl) ||
|
|
1864
|
+
this.clientOptions?.client !== undefined ||
|
|
1865
|
+
(typeof expiresAt === "number" && expiresAt > 0 && expiresAt * 1000 <= Date.now()) ||
|
|
1866
|
+
(client.carried && !tokens)) {
|
|
1867
|
+
return undefined;
|
|
1754
1868
|
}
|
|
1869
|
+
return {
|
|
1870
|
+
issuer,
|
|
1871
|
+
client: {
|
|
1872
|
+
value: client.value,
|
|
1873
|
+
binding: client.binding,
|
|
1874
|
+
carried: true,
|
|
1875
|
+
...(client.registrationPath ? { registrationPath: client.registrationPath } : {}),
|
|
1876
|
+
},
|
|
1877
|
+
};
|
|
1755
1878
|
}
|
|
1756
1879
|
}
|