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