@polpo-ai/connect-server 0.15.144 → 0.16.1

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 (83) hide show
  1. package/README.md +352 -1
  2. package/dist/custom-tool-runner-broker.d.ts +29 -0
  3. package/dist/custom-tool-runner-broker.d.ts.map +1 -0
  4. package/dist/custom-tool-runner-broker.js +140 -0
  5. package/dist/custom-tool-runner-broker.js.map +1 -0
  6. package/dist/definition-store.d.ts +10 -0
  7. package/dist/definition-store.d.ts.map +1 -0
  8. package/dist/definition-store.js +25 -0
  9. package/dist/definition-store.js.map +1 -0
  10. package/dist/index.d.ts +8 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +8 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/mcp-capability-resolver.d.ts +22 -0
  15. package/dist/mcp-capability-resolver.d.ts.map +1 -0
  16. package/dist/mcp-capability-resolver.js +110 -0
  17. package/dist/mcp-capability-resolver.js.map +1 -0
  18. package/dist/mcp-discovery.d.ts +7 -0
  19. package/dist/mcp-discovery.d.ts.map +1 -0
  20. package/dist/mcp-discovery.js +27 -0
  21. package/dist/mcp-discovery.js.map +1 -0
  22. package/dist/mcp-execution.d.ts +13 -0
  23. package/dist/mcp-execution.d.ts.map +1 -0
  24. package/dist/mcp-execution.js +14 -0
  25. package/dist/mcp-execution.js.map +1 -0
  26. package/dist/mcp-oauth-redirect.d.ts +4 -0
  27. package/dist/mcp-oauth-redirect.d.ts.map +1 -0
  28. package/dist/mcp-oauth-redirect.js +25 -0
  29. package/dist/mcp-oauth-redirect.js.map +1 -0
  30. package/dist/mcp-oauth.d.ts +12 -9
  31. package/dist/mcp-oauth.d.ts.map +1 -1
  32. package/dist/mcp-oauth.js +18 -6
  33. package/dist/mcp-oauth.js.map +1 -1
  34. package/dist/mcp-runner-broker.d.ts +29 -0
  35. package/dist/mcp-runner-broker.d.ts.map +1 -0
  36. package/dist/mcp-runner-broker.js +181 -0
  37. package/dist/mcp-runner-broker.js.map +1 -0
  38. package/dist/mcp-setup-clients.d.ts +24 -0
  39. package/dist/mcp-setup-clients.d.ts.map +1 -0
  40. package/dist/mcp-setup-clients.js +113 -0
  41. package/dist/mcp-setup-clients.js.map +1 -0
  42. package/dist/mcp-transport.d.ts +20 -0
  43. package/dist/mcp-transport.d.ts.map +1 -0
  44. package/dist/mcp-transport.js +163 -0
  45. package/dist/mcp-transport.js.map +1 -0
  46. package/dist/memory-store.d.ts +11 -2
  47. package/dist/memory-store.d.ts.map +1 -1
  48. package/dist/memory-store.js +81 -6
  49. package/dist/memory-store.js.map +1 -1
  50. package/dist/oauth-identity.d.ts +6 -0
  51. package/dist/oauth-identity.d.ts.map +1 -0
  52. package/dist/oauth-identity.js +30 -0
  53. package/dist/oauth-identity.js.map +1 -0
  54. package/dist/oauth-token-transport.d.ts +19 -0
  55. package/dist/oauth-token-transport.d.ts.map +1 -0
  56. package/dist/oauth-token-transport.js +133 -0
  57. package/dist/oauth-token-transport.js.map +1 -0
  58. package/dist/oauth.js +1 -1
  59. package/dist/oauth.js.map +1 -1
  60. package/dist/public-network.d.ts +11 -0
  61. package/dist/public-network.d.ts.map +1 -0
  62. package/dist/public-network.js +49 -0
  63. package/dist/public-network.js.map +1 -0
  64. package/dist/refresh-lineage.d.ts +21 -0
  65. package/dist/refresh-lineage.d.ts.map +1 -0
  66. package/dist/refresh-lineage.js +83 -0
  67. package/dist/refresh-lineage.js.map +1 -0
  68. package/dist/secrets.d.ts +2 -0
  69. package/dist/secrets.d.ts.map +1 -1
  70. package/dist/secrets.js.map +1 -1
  71. package/dist/service.d.ts +78 -8
  72. package/dist/service.d.ts.map +1 -1
  73. package/dist/service.js +1090 -403
  74. package/dist/service.js.map +1 -1
  75. package/dist/setup-lifecycle.d.ts +5 -0
  76. package/dist/setup-lifecycle.d.ts.map +1 -0
  77. package/dist/setup-lifecycle.js +36 -0
  78. package/dist/setup-lifecycle.js.map +1 -0
  79. package/dist/verification.d.ts +4 -0
  80. package/dist/verification.d.ts.map +1 -0
  81. package/dist/verification.js +76 -0
  82. package/dist/verification.js.map +1 -0
  83. package/package.json +4 -3
package/dist/service.js CHANGED
@@ -1,36 +1,104 @@
1
- import { ConnectError, assertConnectorRedirectAllowed, assertAllowedScopes, assertGrantedScopes, connectorHostnameIsUnsafe, createConnectorRegistry, normalizeScopes, resolveConnectorHttpRequest, } from "@polpo-ai/connect";
1
+ import { ConnectError, connectionRequiresSecret, connectionSetupState, snapshotOAuthReconnect, matchesOAuthReconnect, matchesMcpOAuthSetup, assertConnectorRedirectAllowed, assertAllowedScopes, assertGrantedScopes, connectorHostnameIsUnsafe, createConnectorRegistry, compileConnectorDefinition, describeConnector, normalizeConnectorDefinition, normalizeConnectorAuthPolicy, normalizeScopes, resolveConnectorHttpRequest, } from "@polpo-ai/connect";
2
2
  import { ConnectionSelectionError, } from "@polpo-ai/core";
3
3
  import { lookup } from "node:dns/promises";
4
+ import { createHash } from "node:crypto";
4
5
  import { MemoryTokenRefreshCoordinator, isVersionedConnectionSecretStore, } from "./secrets.js";
5
- import { createOpaqueToken, createPkcePair, parseScopes, toFormBody } from "./oauth.js";
6
+ import { createOpaqueToken, createPkcePair, parseScopes } from "./oauth.js";
7
+ import { requestOAuthToken } from "./oauth-token-transport.js";
8
+ import { verifyConnectionProbe } from "./verification.js";
9
+ import { assertRefreshAvailable, hasPendingRefresh, refreshConnectionSecret } from "./refresh-lineage.js";
10
+ import { normalizeOAuthTokenScopes, oauthIdentityPolicyFingerprint, resolveOAuthAccountIdentity } from "./oauth-identity.js";
11
+ import { publicNetworkFetch } from "./public-network.js";
12
+ import { resolveMcpOAuthSetupClient, verifyMcpOAuthSetupClient } from "./mcp-setup-clients.js";
13
+ import { observeConnectionSetupSession, cancelConnectionSetupSession } from "./setup-lifecycle.js";
6
14
  import { createMcpOAuthProtocol, } from "./mcp-oauth.js";
7
15
  export function createConnectService(options) {
8
16
  const registry = createConnectorRegistry(options.providers);
9
- const fetchImpl = options.fetch ?? fetch;
17
+ const requireProvider = async (id, authenticationId) => {
18
+ if (registry.get(id))
19
+ return registry.require(id, authenticationId);
20
+ const stored = await options.definitions?.getConnectorDefinition(id);
21
+ if (!stored || stored.disabledAt)
22
+ throw new ConnectError("provider_not_found", "Active Connector not found");
23
+ return compileConnectorDefinition(stored.definition, authenticationId);
24
+ };
25
+ const fetchImpl = options.fetch ?? publicNetworkFetch;
10
26
  const now = options.now ?? (() => new Date());
11
27
  const tokenRefreshSkewMs = options.tokenRefreshSkewMs ?? 60_000;
12
28
  const oauthStateTtlMs = options.oauthStateTtlMs ?? 10 * 60_000;
13
29
  const setupSessionTtlMs = options.setupSessionTtlMs ?? 10 * 60_000;
14
30
  const refreshCoordinator = options.refreshCoordinator ?? new MemoryTokenRefreshCoordinator();
15
31
  const resolveHostname = options.resolveHostname ?? (async (hostname) => (await lookup(hostname, { all: true, verbatim: true })).map((entry) => entry.address));
16
- const mcpOAuthFetch = async (request, init) => {
32
+ const mcpOAuthFetch = async (request, init) => withGatewayDeadline(10_000, init?.signal ?? undefined, async (signal) => {
17
33
  const url = new URL(request instanceof Request ? request.url : String(request));
18
34
  if (url.protocol !== "https:") {
19
35
  throw new ConnectError("oauth_discovery_failed", "MCP OAuth network requests must use HTTPS");
20
36
  }
21
37
  await assertPublicDns(url.hostname, resolveHostname);
22
- return fetchImpl(request, { ...init, redirect: "error" });
23
- };
38
+ signal.throwIfAborted();
39
+ const response = await fetchImpl(request, { ...init, signal, redirect: "error" });
40
+ const bytes = await readResponseBytes(response, 256 * 1024, signal);
41
+ return new Response(bytes.byteLength ? Uint8Array.from(bytes) : null, { status: response.status, headers: response.headers });
42
+ });
24
43
  const mcpOAuth = createMcpOAuthProtocol({
25
44
  fetch: mcpOAuthFetch,
26
45
  registrations: options.mcpOAuthRegistrations,
27
46
  now,
28
47
  });
29
48
  const mcpOAuthCallbackClaimTtlMs = options.mcpOAuthCallbackClaimTtlMs ?? 30_000;
49
+ const authorizeOAuth = async (operation, protocol, context) => {
50
+ // Pick fields explicitly: callers also hold secret-bearing states/inputs.
51
+ await options.authorizeOAuth?.(Object.freeze({ operation, protocol, providerId: context.providerId,
52
+ authenticationId: context.authenticationId, projectId: context.projectId, orgId: context.orgId,
53
+ connectionId: context.connectionId }));
54
+ };
55
+ const authorizeConnection = async (operation, connection, mcpOAuth = false) => {
56
+ if (connection.authType === "mcp" && (connection.oauthClientId || connection.oauthClientFingerprint)) {
57
+ await verifyMcpClient(connection.oauthClientId, connection.oauthClientFingerprint);
58
+ }
59
+ if (connection.authType === "oauth2" || (connection.authType === "mcp"
60
+ && (mcpOAuth || readMcpAuthMode(connection.metadata) === "oauth2"))) {
61
+ await authorizeOAuth(operation, connection.authType === "oauth2" ? "api" : "mcp", { ...connection, connectionId: connection.id });
62
+ }
63
+ };
64
+ const assertConnectionCurrent = async (connection, scopes, authorize = true) => {
65
+ const current = await options.store.getConnection(connection.id);
66
+ if (!current || current.status !== "active" || current.secretRef !== connection.secretRef
67
+ || current.credentialVersion !== connection.credentialVersion) {
68
+ throw new ConnectError("connection_revoked", "Connection changed while resolving its credential");
69
+ }
70
+ assertGrantedScopes(current.grantedScopes, scopes);
71
+ if (authorize)
72
+ await authorizeConnection("credential", current);
73
+ return current;
74
+ };
75
+ const recoverConnectionWrite = async (expected) => {
76
+ const current = await options.store.getConnection(expected.id).catch(() => null);
77
+ // Only a positive read of our exact generation/authority proves the write.
78
+ // A missing row (or failed read) does not prove a timed-out write rolled back.
79
+ if (current?.status === "active" && snapshotOAuthReconnect(current).authorizationFingerprint
80
+ === snapshotOAuthReconnect(expected).authorizationFingerprint)
81
+ return current;
82
+ return undefined;
83
+ };
84
+ const persistNewConnection = async (connection) => {
85
+ try {
86
+ return await options.store.upsertConnection(connection);
87
+ }
88
+ catch (error) {
89
+ const committed = await recoverConnectionWrite(connection);
90
+ if (committed)
91
+ return committed;
92
+ // Preserve potentially referenced secrets. Host reconciliation must establish
93
+ // that an uncertain write has finished before reclaiming an actual orphan.
94
+ throw error;
95
+ }
96
+ };
30
97
  const resolveOAuthClient = async (input) => {
31
98
  if (options.oauthClients) {
32
99
  const client = await options.oauthClients.resolve({
33
100
  providerId: input.provider.id,
101
+ authenticationId: input.provider.authenticationId,
34
102
  ...(input.projectId ? { projectId: input.projectId } : {}),
35
103
  ...(input.orgId ? { orgId: input.orgId } : {}),
36
104
  mode: input.mode ?? "managed",
@@ -42,6 +110,9 @@ export function createConnectService(options) {
42
110
  }
43
111
  return client;
44
112
  }
113
+ if (input.mode === "customer") {
114
+ throw new ConnectError("setup_invalid", "Customer OAuth Client resolution is not configured on this host");
115
+ }
45
116
  if (input.provider.auth.type !== "oauth2" || !input.provider.auth.clientId) {
46
117
  throw new ConnectError("invalid_provider", `OAuth provider "${input.provider.id}" is missing an OAuth Client resolver`);
47
118
  }
@@ -60,13 +131,20 @@ export function createConnectService(options) {
60
131
  }
61
132
  assertOAuthClient(input.provider.id, input.redirectUri, input.client);
62
133
  const auth = input.provider.auth;
63
- const requestedScopes = assertAllowedScopes(input.provider, input.scopes ?? auth.defaultScopes);
134
+ const requestedScopes = assertAllowedScopes(input.provider, [...(input.scopes ?? auth.defaultScopes ?? []), ...(auth.identity?.requiredScopes ?? [])]);
64
135
  const state = createOpaqueToken();
65
136
  const pkce = auth.supportsPkce === false ? undefined : createPkcePair();
66
- const expiresAt = new Date(now().getTime() + oauthStateTtlMs).toISOString();
137
+ await authorizeOAuth("authorize", "api", { ...input, providerId: input.provider.id,
138
+ authenticationId: input.provider.authenticationId, connectionId: input.reconnect?.connectionId });
139
+ const expiresAt = input.authorizationExpiresAt ?? new Date(now().getTime() + oauthStateTtlMs).toISOString();
140
+ if (Date.parse(expiresAt) <= now().getTime())
141
+ throw new ConnectError("setup_expired", "OAuth authorization window has expired");
142
+ input.onStateWriteAttempt?.();
67
143
  await options.store.saveOAuthState({
144
+ setupSessionRef: input.setupSessionRef,
68
145
  state,
69
146
  providerId: input.provider.id,
147
+ authenticationId: input.provider.authenticationId,
70
148
  subject: input.subject,
71
149
  requestedScopes,
72
150
  redirectUri: input.redirectUri,
@@ -79,6 +157,9 @@ export function createConnectService(options) {
79
157
  createdAt: now().toISOString(),
80
158
  metadata: input.metadata,
81
159
  oauthClientId: input.client.id,
160
+ oauthClientFingerprint: oauthClientFingerprint(input.provider, input.client.clientId),
161
+ oauthIdentityPolicyFingerprint: oauthIdentityPolicyFingerprint(auth.identity),
162
+ reconnect: input.reconnect,
82
163
  audience: input.audience,
83
164
  binding: input.binding,
84
165
  returnUrl: input.returnUrl,
@@ -99,26 +180,268 @@ export function createConnectService(options) {
99
180
  }
100
181
  return { authorizationUrl: url.toString(), state, expiresAt };
101
182
  };
102
- const oauthAuthForConnection = async (provider, oauthClientId) => {
183
+ const oauthAuthForConnection = async (provider, oauthClientId, fingerprint) => {
103
184
  if (provider.auth.type !== "oauth2") {
104
185
  throw new ConnectError("unsupported_auth", `Provider "${provider.id}" does not use OAuth2 auth`);
105
186
  }
106
- if (!oauthClientId || oauthClientId.startsWith("legacy:"))
187
+ if (!oauthClientId || oauthClientId.startsWith("legacy:")) {
188
+ assertOAuthClientFingerprint(provider, provider.auth.clientId, fingerprint);
107
189
  return provider.auth;
190
+ }
108
191
  const client = await options.oauthClients?.resolveById(oauthClientId);
109
- if (!client || client.providerId !== provider.id) {
192
+ if (!client || client.id !== oauthClientId || client.providerId !== provider.id) {
110
193
  throw new ConnectError("refresh_unavailable", "OAuth Client is unavailable during token refresh");
111
194
  }
195
+ assertOAuthClientFingerprint(provider, client.clientId, fingerprint);
112
196
  return {
113
197
  ...provider.auth,
114
198
  clientId: client.clientId,
115
199
  clientSecret: client.clientSecret,
116
200
  };
117
201
  };
202
+ const configurationForOAuthState = async (state) => {
203
+ const provider = await requireProvider(state.providerId, state.authenticationId);
204
+ if (provider.auth.type !== "oauth2") {
205
+ throw new ConnectError("unsupported_auth", `Provider "${provider.id}" does not use OAuth2 auth`);
206
+ }
207
+ if (state.oauthIdentityPolicyFingerprint !== oauthIdentityPolicyFingerprint(provider.auth.identity)) {
208
+ throw new ConnectError("setup_invalid", "OAuth identity policy changed; restart authorization");
209
+ }
210
+ let oauthAuth = provider.auth;
211
+ if (state.oauthClientId && !state.oauthClientId.startsWith("legacy:")) {
212
+ const client = await options.oauthClients?.resolveById(state.oauthClientId);
213
+ if (!client || client.id !== state.oauthClientId || client.providerId !== provider.id) {
214
+ throw new ConnectError("setup_invalid", "OAuth Client is unavailable during callback");
215
+ }
216
+ assertOAuthClient(provider.id, state.redirectUri, client);
217
+ assertOAuthClientFingerprint(provider, client.clientId, state.oauthClientFingerprint);
218
+ oauthAuth = {
219
+ ...provider.auth,
220
+ clientId: client.clientId,
221
+ clientSecret: client.clientSecret,
222
+ };
223
+ }
224
+ else {
225
+ assertOAuthClientFingerprint(provider, provider.auth.clientId, state.oauthClientFingerprint);
226
+ }
227
+ return { provider: { ...provider, auth: provider.auth }, oauthAuth };
228
+ };
229
+ const requireMcpSetupHost = () => {
230
+ if (!options.mcpOAuthClients || !options.setupSessions?.getConnectionSetupSessionByReference
231
+ || !options.setupSessions.finishConnectionSetupSession || !options.store.failMcpOAuthSetup
232
+ || !options.store.commitMcpOAuthSetup || !options.store.getOAuthState || !options.store.claimOAuthState
233
+ || !options.store.releaseOAuthState || !options.links || !isVersionedConnectionSecretStore(options.secrets)) {
234
+ throw new ConnectError("setup_invalid", "Host does not support atomic embedded MCP OAuth setup");
235
+ }
236
+ };
237
+ const verifyMcpClient = async (id, fingerprint) => {
238
+ if (!options.mcpOAuthClients || !id || !fingerprint)
239
+ throw new ConnectError("setup_invalid", "MCP client configuration is unavailable");
240
+ return verifyMcpOAuthSetupClient(options.mcpOAuthClients, { id, fingerprint });
241
+ };
242
+ const setupForMcpState = async (state) => {
243
+ requireMcpSetupHost();
244
+ const setup = await options.setupSessions.getConnectionSetupSessionByReference(state.setupSessionRef);
245
+ if (!setup || !matchesMcpOAuthSetup(state, setup))
246
+ throw new ConnectError("setup_invalid", "MCP setup authorization changed");
247
+ const { client } = await verifyMcpClient(state.oauthClientId, state.oauthClientFingerprint);
248
+ if (state.metadata?.url !== client.resourceUrl || state.metadata?.transport !== client.transport
249
+ || state.metadata?.oauthClientMode !== client.registration.mode || state.redirectUri !== client.redirectUri) {
250
+ throw new ConnectError("setup_invalid", "MCP OAuth state does not match its client configuration");
251
+ }
252
+ return setup;
253
+ };
254
+ const currentMcpMaterial = async (material, id, fingerprint) => {
255
+ if (!id && !fingerprint)
256
+ return material;
257
+ const { client } = await verifyMcpClient(id, fingerprint);
258
+ return client.registration.mode === "pre_registered" ? { ...material, client: client.registration.client } : material;
259
+ };
260
+ const beginMcpOAuth = async (input, setupContext) => {
261
+ if (!options.store.getOAuthState || !options.store.claimOAuthState || !options.store.releaseOAuthState
262
+ || !(setupContext ? options.store.commitMcpOAuthSetup : options.store.commitMcpOAuthConnection)
263
+ || !isVersionedConnectionSecretStore(options.secrets)) {
264
+ throw new ConnectError("setup_invalid", "Host must support atomic MCP OAuth activation and versioned secrets");
265
+ }
266
+ const identity = normalizeCreationIdentity(input);
267
+ const provider = await requireProvider(input.providerId ?? "mcp_url", input.authenticationId);
268
+ if (provider.auth.type !== "mcp") {
269
+ throw new ConnectError("unsupported_auth", `Provider "${provider.id}" does not use MCP auth`);
270
+ }
271
+ if (provider.authenticationId && provider.auth.auth !== "oauth2") {
272
+ throw new ConnectError("unsupported_auth", "Selected MCP authentication does not support OAuth");
273
+ }
274
+ await authorizeOAuth("authorize", "mcp", { ...input, providerId: provider.id });
275
+ const url = normalizeMcpUrl(input.url, false);
276
+ const transport = input.transport ?? "http";
277
+ const requestedScopes = assertAllowedScopes(provider, input.scopes ?? provider.auth.defaultScopes);
278
+ const state = createOpaqueToken();
279
+ const pendingConnectionId = createId("conn");
280
+ const temporarySecretRef = createId("connsec");
281
+ const started = await mcpOAuth.start({
282
+ url,
283
+ transport,
284
+ redirectUri: input.redirectUri,
285
+ state,
286
+ scopes: requestedScopes,
287
+ mode: input.mode,
288
+ clientName: input.clientName,
289
+ clientUri: input.clientUri,
290
+ clientMetadataUrl: input.clientMetadataUrl,
291
+ preRegisteredClient: input.preRegisteredClient,
292
+ registrationNamespace: setupContext?.fingerprint,
293
+ });
294
+ const createdAt = now();
295
+ const expiresAt = new Date(setupContext?.authorizationExpiresAt ?? createdAt.getTime() + oauthStateTtlMs);
296
+ if (!(expiresAt.getTime() > createdAt.getTime()))
297
+ throw new ConnectError("setup_expired", "Setup authorization expired during discovery");
298
+ if (setupContext)
299
+ await verifyMcpClient(setupContext.clientId, setupContext.fingerprint);
300
+ await authorizeOAuth("authorize", "mcp", { ...input, providerId: provider.id });
301
+ await options.secrets.setSecret(temporarySecretRef, {
302
+ kind: "mcp",
303
+ mcpOAuth: started.material,
304
+ });
305
+ try {
306
+ setupContext?.onStateWriteAttempt();
307
+ await options.store.saveOAuthState({
308
+ ...(setupContext ? { setupSessionRef: setupContext.reference, oauthClientId: setupContext.clientId,
309
+ oauthClientFingerprint: setupContext.fingerprint, returnUrl: setupContext.returnUrl } : {}),
310
+ state,
311
+ providerId: provider.id,
312
+ authenticationId: provider.authenticationId,
313
+ flowKind: "mcp",
314
+ status: "pending",
315
+ subject: identity.subject,
316
+ audience: identity.audience,
317
+ binding: identity.binding,
318
+ requestedScopes,
319
+ redirectUri: started.material.redirectUri,
320
+ projectId: input.projectId,
321
+ orgId: input.orgId,
322
+ connectionName: input.name,
323
+ temporarySecretRef,
324
+ createdAt: createdAt.toISOString(),
325
+ expiresAt: expiresAt.toISOString(),
326
+ metadata: {
327
+ ...omitMcpDiscoveryMetadata(input.metadata),
328
+ url,
329
+ transport,
330
+ auth: "oauth2",
331
+ oauthClientMode: input.mode,
332
+ pendingConnectionId,
333
+ },
334
+ });
335
+ }
336
+ catch (error) {
337
+ const committed = await options.store.getOAuthState?.(state).catch(() => null);
338
+ if (committed?.status !== "pending" || committed.temporarySecretRef !== temporarySecretRef
339
+ || committed.metadata?.pendingConnectionId !== pendingConnectionId)
340
+ throw error;
341
+ }
342
+ return { authorizationUrl: started.authorizationUrl, state, expiresAt: expiresAt.toISOString() };
343
+ };
118
344
  return {
119
345
  listProviders() {
120
346
  return registry.list();
121
347
  },
348
+ async listCatalog() {
349
+ const custom = await options.definitions?.listConnectorDefinitions() ?? [];
350
+ return [...registry.catalog(), ...custom.filter((record) => !record.disabledAt && !registry.get(record.definition.id))
351
+ .map((record) => describeConnector(record.definition))];
352
+ },
353
+ async getSetupReadiness(input) {
354
+ const provider = await requireProvider(input.providerId, input.authenticationId);
355
+ const catalog = describeConnector(provider);
356
+ const base = {
357
+ providerId: provider.id,
358
+ authenticationId: provider.authenticationId ?? catalog.defaultAuthenticationId,
359
+ check: "configuration", outcome: "passed", code: "ready", checkedAt: now().toISOString(),
360
+ configurationVersion: createHash("sha256").update(JSON.stringify(catalog)).digest("hex"),
361
+ };
362
+ if (catalog.kind === "credential")
363
+ return { ...base, outcome: "unsupported", code: "credential_only" };
364
+ if (provider.auth.type === "mcp") {
365
+ return { ...base, outcome: "inconclusive", code: "discovery_required", nextStep: "mcp_discovery" };
366
+ }
367
+ if (provider.auth.type === "none")
368
+ return { ...base, nextStep: "create" };
369
+ if (provider.auth.type === "api_key")
370
+ return { ...base, code: "credentials_required", nextStep: "credentials" };
371
+ if (!input.redirectUri)
372
+ return { ...base, outcome: "failed", code: "callback_required" };
373
+ try {
374
+ const client = await resolveOAuthClient({
375
+ provider, projectId: input.projectId, orgId: input.orgId,
376
+ redirectUri: input.redirectUri, mode: input.oauthClientMode,
377
+ });
378
+ return {
379
+ ...base, code: "consent_required", nextStep: "oauth_consent",
380
+ configurationVersion: createHash("sha256").update(JSON.stringify([
381
+ base.configurationVersion, oauthClientFingerprint(provider, client.clientId), input.redirectUri, input.oauthClientMode,
382
+ ])).digest("hex"),
383
+ };
384
+ }
385
+ catch (error) {
386
+ return { ...base, outcome: error instanceof ConnectError ? "failed" : "inconclusive",
387
+ code: error instanceof ConnectError ? error.code : "configuration_unavailable" };
388
+ }
389
+ },
390
+ async registerConnectorDefinition(input) {
391
+ if (!options.definitions)
392
+ throw new ConnectError("setup_invalid", "Custom Connector storage is not configured on this host");
393
+ if (!input || typeof input !== "object" || Array.isArray(input))
394
+ throw new ConnectError("invalid_provider", "Connector definition must be an object");
395
+ let definition;
396
+ try {
397
+ definition = normalizeConnectorDefinition({ ...input, source: "custom" });
398
+ }
399
+ catch (error) {
400
+ if (error instanceof ConnectError)
401
+ throw error;
402
+ throw new ConnectError("invalid_provider", "Connector definition has an invalid HTTP policy or configuration");
403
+ }
404
+ if (registry.get(definition.id))
405
+ throw new ConnectError("invalid_provider", "Connector id is reserved by the host catalog");
406
+ const record = { definition, createdAt: now().toISOString() };
407
+ await options.definitions.createConnectorDefinition(record);
408
+ return record;
409
+ },
410
+ async disableConnectorDefinition(id) {
411
+ if (!options.definitions)
412
+ throw new ConnectError("setup_invalid", "Custom Connector storage is not configured on this host");
413
+ if (registry.get(id))
414
+ throw new ConnectError("invalid_provider", "Host catalog Connectors cannot be disabled through custom registration");
415
+ return options.definitions.disableConnectorDefinition(id, now().toISOString());
416
+ },
417
+ async verifyConnection(input) {
418
+ const connection = await options.store.getConnection(input.connectionId);
419
+ if (!connection)
420
+ throw new ConnectError("connection_not_found", "Connection not found");
421
+ const provider = await requireProvider(connection.providerId, connection.authenticationId);
422
+ let result = await verifyConnectionProbe(this, input, connection, provider, {
423
+ checkedAt: now().toISOString(),
424
+ configurationVersion: createHash("sha256").update(JSON.stringify([
425
+ describeConnector(provider), provider.authenticationId,
426
+ ])).digest("hex"),
427
+ }, options.verifyMcp ? (probe) => withGatewayDeadline(30_000, probe.signal, (signal) => options.verifyMcp({ ...probe, signal, beforeDispatch: async () => {
428
+ signal.throwIfAborted();
429
+ const current = await assertConnectionCurrent(probe.connection, probe.credential.scopes);
430
+ await options.beforeDispatch?.(current);
431
+ await assertConnectionCurrent(probe.connection, probe.credential.scopes);
432
+ signal.throwIfAborted();
433
+ } })) : undefined);
434
+ const current = await options.store.getConnection(connection.id);
435
+ if (!current || current.status !== connection.status || current.secretRef !== connection.secretRef
436
+ || (current.credentialVersion ?? current.updatedAt) !== result.credentialVersion) {
437
+ result = { ...result, outcome: "inconclusive", code: "connection_changed", account: undefined, toolCount: undefined };
438
+ }
439
+ if (result.outcome === "passed" && result.account) {
440
+ await options.store.updateConnection(connection.id, { providerAccountId: result.account.id });
441
+ }
442
+ await options.verifications?.saveConnectionVerification(result);
443
+ return result;
444
+ },
122
445
  listConnections(filter) {
123
446
  return options.store.listConnections(filter);
124
447
  },
@@ -169,11 +492,12 @@ export function createConnectService(options) {
169
492
  });
170
493
  },
171
494
  async createApiKeyConnection(input) {
172
- const provider = registry.require(input.providerId);
495
+ const identity = normalizeCreationIdentity(input);
496
+ const provider = await requireProvider(input.providerId, input.authenticationId);
173
497
  if (provider.auth.type !== "api_key") {
174
498
  throw new ConnectError("unsupported_auth", `Provider "${provider.id}" does not use API-key auth`);
175
499
  }
176
- const apiKey = input.apiKey.trim();
500
+ const apiKey = typeof input.apiKey === "string" ? input.apiKey.trim() : "";
177
501
  if (!apiKey) {
178
502
  throw new ConnectError("invalid_request", "API key cannot be empty");
179
503
  }
@@ -185,15 +509,19 @@ export function createConnectService(options) {
185
509
  apiKey,
186
510
  ...(input.secretMetadata ? { metadata: input.secretMetadata } : {}),
187
511
  });
188
- return options.store.upsertConnection({
512
+ return persistNewConnection({
189
513
  id,
190
514
  providerId: provider.id,
515
+ authenticationId: provider.authenticationId,
191
516
  name: input.name,
192
517
  projectId: input.projectId,
193
518
  orgId: input.orgId,
194
- owner: input.subject,
519
+ owner: identity.subject,
520
+ audience: identity.audience,
521
+ binding: identity.binding,
195
522
  authType: "api_key",
196
523
  status: "active",
524
+ credentialVersion: createId("credver"),
197
525
  grantedScopes,
198
526
  secretRef,
199
527
  createdAt: now().toISOString(),
@@ -201,8 +529,26 @@ export function createConnectService(options) {
201
529
  metadata: input.metadata,
202
530
  });
203
531
  },
532
+ async createPublicConnection(input) {
533
+ const identity = normalizeCreationIdentity(input);
534
+ const provider = await requireProvider(input.providerId, input.authenticationId);
535
+ if (provider.auth.type !== "none" || !provider.http) {
536
+ throw new ConnectError("unsupported_auth", `Provider "${provider.id}" does not support public HTTP access`);
537
+ }
538
+ const grantedScopes = assertAllowedScopes(provider, input.scopes ?? provider.auth.defaultScopes);
539
+ const timestamp = now().toISOString();
540
+ return options.store.upsertConnection({
541
+ id: createId("conn"), providerId: provider.id, authenticationId: provider.authenticationId,
542
+ name: input.name, projectId: input.projectId, orgId: input.orgId, owner: identity.subject,
543
+ audience: identity.audience, binding: identity.binding,
544
+ authType: "none", status: "active", grantedScopes,
545
+ credentialVersion: createId("credver"),
546
+ createdAt: timestamp, updatedAt: timestamp, metadata: input.metadata,
547
+ });
548
+ },
204
549
  async createMcpConnection(input) {
205
- const provider = registry.require(input.providerId ?? "mcp_url");
550
+ const identity = normalizeCreationIdentity(input);
551
+ const provider = await requireProvider(input.providerId ?? "mcp_url", input.authenticationId);
206
552
  if (provider.auth.type !== "mcp") {
207
553
  throw new ConnectError("unsupported_auth", `Provider "${provider.id}" does not use MCP auth`);
208
554
  }
@@ -210,23 +556,31 @@ export function createConnectService(options) {
210
556
  if (transport !== "http" && transport !== "sse") {
211
557
  throw new ConnectError("invalid_request", "MCP transport must be http or sse");
212
558
  }
213
- const auth = input.auth ?? (provider.auth.auth === "none" ? "none" : "bearer");
214
- if (auth !== "none" && auth !== "bearer") {
215
- throw new ConnectError("invalid_request", "MCP auth must be none or bearer");
559
+ const selectedAuth = provider.auth.auth ?? "bearer";
560
+ const auth = input.auth ?? selectedAuth;
561
+ if (provider.authenticationId && auth !== selectedAuth) {
562
+ throw new ConnectError("unsupported_auth", "MCP authentication does not match the selected method");
216
563
  }
564
+ if (auth !== "none" && auth !== "bearer" && auth !== "header") {
565
+ throw new ConnectError("unsupported_auth", "MCP OAuth must use the OAuth authorization flow");
566
+ }
567
+ const headerName = auth === "header" ? normalizeConnectorAuthPolicy({
568
+ mode: "header", name: provider.auth.headerName ?? (!provider.authenticationId ? input.headerName : undefined),
569
+ }).name : undefined;
217
570
  const url = normalizeMcpUrl(input.url, options.allowInsecureLocalMcpUrls === true);
218
571
  const apiKey = (input.bearerToken ?? input.apiKey ?? "").trim();
219
- if (auth === "bearer" && !apiKey) {
220
- throw new ConnectError("invalid_request", "MCP bearer auth requires a token");
572
+ if (auth !== "none" && !apiKey) {
573
+ throw new ConnectError("invalid_request", "MCP authenticated access requires a token");
221
574
  }
222
575
  const grantedScopes = assertAllowedScopes(provider, input.scopes ?? provider.auth.defaultScopes);
223
576
  const id = createId("conn");
224
- const secretRef = auth === "bearer" ? createId("connsec") : undefined;
577
+ const secretRef = auth !== "none" ? createId("connsec") : undefined;
225
578
  const metadata = {
226
- ...(input.metadata ?? {}),
579
+ ...omitMcpDiscoveryMetadata(input.metadata),
227
580
  url,
228
581
  transport,
229
582
  auth,
583
+ headerName,
230
584
  };
231
585
  if (secretRef) {
232
586
  await options.secrets.setSecret(secretRef, {
@@ -235,15 +589,19 @@ export function createConnectService(options) {
235
589
  metadata: { tokenType: "Bearer" },
236
590
  });
237
591
  }
238
- return options.store.upsertConnection({
592
+ return persistNewConnection({
239
593
  id,
240
594
  providerId: provider.id,
595
+ authenticationId: provider.authenticationId,
241
596
  name: input.name,
242
597
  projectId: input.projectId,
243
598
  orgId: input.orgId,
244
- owner: input.subject,
599
+ owner: identity.subject,
600
+ audience: identity.audience,
601
+ binding: identity.binding,
245
602
  authType: "mcp",
246
603
  status: "active",
604
+ credentialVersion: createId("credver"),
247
605
  grantedScopes,
248
606
  secretRef,
249
607
  createdAt: now().toISOString(),
@@ -255,87 +613,47 @@ export function createConnectService(options) {
255
613
  return mcpOAuth.inspect({ url: input.url, transport: input.transport });
256
614
  },
257
615
  async startMcpOAuth(input) {
258
- const provider = registry.require(input.providerId ?? "mcp_url");
259
- if (provider.auth.type !== "mcp") {
260
- throw new ConnectError("unsupported_auth", `Provider "${provider.id}" does not use MCP auth`);
261
- }
262
- const url = normalizeMcpUrl(input.url, false);
263
- const transport = input.transport ?? "http";
264
- const requestedScopes = normalizeScopes(input.scopes ?? provider.auth.defaultScopes ?? []);
265
- const state = createOpaqueToken();
266
- const pendingConnectionId = createId("conn");
267
- const temporarySecretRef = createId("connsec");
268
- const started = await mcpOAuth.start({
269
- url,
270
- transport,
271
- redirectUri: input.redirectUri,
272
- state,
273
- scopes: requestedScopes,
274
- mode: input.mode,
275
- clientName: input.clientName,
276
- clientUri: input.clientUri,
277
- clientMetadataUrl: input.clientMetadataUrl,
278
- preRegisteredClient: input.preRegisteredClient,
279
- });
280
- const createdAt = now();
281
- const expiresAt = new Date(createdAt.getTime() + oauthStateTtlMs);
282
- await options.secrets.setSecret(temporarySecretRef, {
283
- kind: "mcp",
284
- mcpOAuth: started.material,
285
- });
286
- try {
287
- await options.store.saveOAuthState({
288
- state,
289
- providerId: provider.id,
290
- flowKind: "mcp",
291
- status: "pending",
292
- subject: input.subject,
293
- requestedScopes,
294
- redirectUri: started.material.redirectUri,
295
- projectId: input.projectId,
296
- orgId: input.orgId,
297
- connectionName: input.name,
298
- temporarySecretRef,
299
- createdAt: createdAt.toISOString(),
300
- expiresAt: expiresAt.toISOString(),
301
- metadata: {
302
- ...(input.metadata ?? {}),
303
- url,
304
- transport,
305
- auth: "oauth2",
306
- oauthClientMode: input.mode,
307
- pendingConnectionId,
308
- },
309
- });
310
- }
311
- catch (error) {
312
- await options.secrets.deleteSecret(temporarySecretRef).catch(() => undefined);
313
- throw error;
314
- }
315
- return { authorizationUrl: started.authorizationUrl, state, expiresAt: expiresAt.toISOString() };
616
+ // Private setup context is a separate argument: spreading runtime extras
617
+ // or metadata into direct-start input cannot establish setup authority.
618
+ return beginMcpOAuth(input);
316
619
  },
317
620
  async completeMcpOAuth(input) {
318
621
  const store = options.store;
319
- if (!store.getOAuthState || !store.claimOAuthState || !store.completeOAuthState || !store.releaseOAuthState) {
622
+ if (!store.getOAuthState || !store.claimOAuthState || !store.releaseOAuthState
623
+ || !isVersionedConnectionSecretStore(options.secrets)) {
320
624
  throw new ConnectError("setup_invalid", "Host store does not support retry-safe MCP OAuth callbacks");
321
625
  }
322
626
  const current = await store.getOAuthState(input.state);
323
627
  if (!current || current.flowKind !== "mcp") {
324
628
  throw new ConnectError("oauth_state_not_found", "MCP OAuth state was not found");
325
629
  }
630
+ await authorizeOAuth("callback", "mcp", current);
631
+ const setup = current.setupSessionRef ? await setupForMcpState(current) : undefined;
632
+ if (!setup && !store.commitMcpOAuthConnection)
633
+ throw new ConnectError("setup_invalid", "Host does not support atomic MCP OAuth activation");
326
634
  if (current.status === "completed" && current.completedConnectionId) {
327
635
  const completed = await store.getConnection(current.completedConnectionId);
328
- if (completed)
636
+ if (setup) {
637
+ const links = await options.links.listConnectionLinks({ connectionId: current.completedConnectionId, projectId: setup.projectId, status: "active" });
638
+ if (setup.status !== "completed" || setup.resultingConnectionId !== current.completedConnectionId || !links.length) {
639
+ throw new ConnectError("setup_invalid", "Completed MCP setup link is no longer active");
640
+ }
641
+ }
642
+ if (completed?.status === "active")
329
643
  return completed;
644
+ throw new ConnectError("connection_revoked", "Completed MCP account is no longer active");
330
645
  }
331
646
  if (new Date(current.expiresAt).getTime() <= now().getTime()) {
332
647
  throw new ConnectError("oauth_state_expired", "MCP OAuth state has expired");
333
648
  }
334
- if (input.error) {
335
- throw new ConnectError("oauth_error", input.errorDescription ?? input.error);
336
- }
337
- if (!input.code)
649
+ if (setup && connectionSetupState(setup) !== "started")
650
+ throw new ConnectError("setup_invalid", "MCP setup is no longer active");
651
+ if (!input.error && !input.code)
338
652
  throw new ConnectError("invalid_request", "OAuth callback is missing code");
653
+ const provider = await requireProvider(current.providerId, current.authenticationId);
654
+ if (provider.auth.type !== "mcp" || (provider.authenticationId && provider.auth.auth !== "oauth2")) {
655
+ throw new ConnectError("unsupported_auth", "MCP OAuth authentication is no longer supported");
656
+ }
339
657
  const claimToken = createOpaqueToken();
340
658
  const claimed = await store.claimOAuthState(input.state, claimToken, new Date(now().getTime() + mcpOAuthCallbackClaimTtlMs).toISOString(), now().toISOString());
341
659
  if (!claimed) {
@@ -345,50 +663,75 @@ export function createConnectService(options) {
345
663
  ? claimed.metadata.pendingConnectionId
346
664
  : undefined;
347
665
  try {
348
- if (pendingConnectionId) {
349
- const existing = await store.getConnection(pendingConnectionId);
350
- if (existing?.status === "active") {
351
- await store.completeOAuthState(input.state, claimToken, existing.id);
352
- return existing;
666
+ if (input.error) {
667
+ if (claimed.setupSessionRef && !await store.failMcpOAuthSetup({ state: input.state, setupReference: claimed.setupSessionRef,
668
+ claimToken, now: now().toISOString() })) {
669
+ throw new ConnectError("oauth_callback_in_progress", "MCP OAuth callback no longer owns the setup");
353
670
  }
671
+ throw new ConnectError("oauth_error", input.errorDescription ?? input.error);
354
672
  }
355
673
  if (!claimed.temporarySecretRef) {
356
674
  throw new ConnectError("secret_not_found", "MCP OAuth transient secret is missing");
357
675
  }
358
- const transient = await options.secrets.getSecret(claimed.temporarySecretRef);
676
+ const generation = await options.secrets.getVersioned(claimed.temporarySecretRef);
677
+ const transient = generation?.secret;
359
678
  if (!transient?.mcpOAuth) {
360
679
  throw new ConnectError("secret_not_found", "MCP OAuth transient material is unavailable");
361
680
  }
362
- const tokens = await mcpOAuth.complete({
363
- material: transient.mcpOAuth,
681
+ // The authorization code is one-time. If persistence failed after
682
+ // exchange, reuse the staged encrypted tokens when retrying the claim.
683
+ await authorizeOAuth("callback", "mcp", claimed);
684
+ const currentMaterial = await currentMcpMaterial(transient.mcpOAuth, claimed.oauthClientId, claimed.oauthClientFingerprint);
685
+ const tokens = transient.mcpOAuth.tokens ?? await mcpOAuth.complete({
686
+ material: currentMaterial,
364
687
  code: input.code,
365
688
  requestedScopes: claimed.requestedScopes,
366
689
  });
367
- const grantedScopes = normalizeScopes(tokens.scopes ?? claimed.requestedScopes);
690
+ assertGrantedScopes(tokens.scopes ?? claimed.requestedScopes, claimed.requestedScopes);
691
+ const grantedScopes = assertAllowedScopes(provider, claimed.requestedScopes);
368
692
  const material = {
369
- ...transient.mcpOAuth,
693
+ ...currentMaterial,
370
694
  codeVerifier: undefined,
371
695
  tokens,
372
696
  };
373
- await options.secrets.setSecret(claimed.temporarySecretRef, { kind: "mcp", mcpOAuth: material });
697
+ if (!transient.mcpOAuth.tokens && !await options.secrets.compareAndSet(claimed.temporarySecretRef, generation.version, { kind: "mcp", mcpOAuth: material }))
698
+ throw new ConnectError("oauth_callback_in_progress", "MCP OAuth token generation changed; retry callback");
699
+ // Keep the spent code's result encrypted for retry, but never activate
700
+ // an account when the host disabled OAuth while exchanging/staging it.
701
+ await authorizeOAuth("callback", "mcp", claimed);
374
702
  const id = pendingConnectionId ?? createId("conn");
375
- const connection = await store.upsertConnection({
703
+ if (claimed.setupSessionRef)
704
+ await setupForMcpState(claimed);
705
+ const record = {
376
706
  id,
377
707
  providerId: claimed.providerId,
708
+ authenticationId: claimed.authenticationId,
378
709
  name: claimed.connectionName,
379
- projectId: claimed.projectId,
710
+ projectId: claimed.setupSessionRef ? undefined : claimed.projectId,
711
+ oauthClientId: claimed.oauthClientId,
712
+ oauthClientFingerprint: claimed.oauthClientFingerprint,
380
713
  orgId: claimed.orgId,
381
714
  owner: claimed.subject,
715
+ audience: claimed.audience,
716
+ binding: claimed.binding,
382
717
  authType: "mcp",
383
718
  status: "active",
719
+ credentialVersion: createId("credver"),
384
720
  grantedScopes,
385
721
  secretRef: claimed.temporarySecretRef,
386
722
  tokenExpiresAt: tokens.expiresAt,
387
723
  createdAt: now().toISOString(),
388
724
  updatedAt: now().toISOString(),
389
725
  metadata: omitInternalMcpStateMetadata(claimed.metadata),
390
- });
391
- await store.completeOAuthState(input.state, claimToken, connection.id);
726
+ };
727
+ const commit = { state: input.state, claimToken, now: now().toISOString(), connection: record };
728
+ const connection = claimed.setupSessionRef
729
+ ? await store.commitMcpOAuthSetup({ ...commit, setupReference: claimed.setupSessionRef,
730
+ link: { id: `connlink_${id}`, connectionId: id, projectId: claimed.projectId, status: "active",
731
+ createdAt: record.createdAt, updatedAt: record.updatedAt } })
732
+ : await store.commitMcpOAuthConnection(commit);
733
+ if (!connection)
734
+ throw new ConnectError("oauth_callback_in_progress", "MCP OAuth claim changed before activation; retry callback");
392
735
  return connection;
393
736
  }
394
737
  catch (error) {
@@ -398,50 +741,69 @@ export function createConnectService(options) {
398
741
  }
399
742
  },
400
743
  async createSetupSession(input) {
401
- if (!options.setupSessions || !options.oauthClients) {
744
+ if (!options.setupSessions) {
402
745
  throw new ConnectError("setup_invalid", "Connection setup sessions are not configured on this host");
403
746
  }
404
- const provider = registry.require(requiredText("providerId", input.providerId));
405
- if (provider.auth.type !== "oauth2") {
747
+ const provider = await requireProvider(requiredText("providerId", input.providerId), input.authenticationId);
748
+ const mcp = provider.auth.type === "mcp";
749
+ if (provider.auth.type !== "oauth2" && (!mcp || (provider.authenticationId && provider.auth.type === "mcp" && provider.auth.auth !== "oauth2"))) {
406
750
  throw new ConnectError("unsupported_auth", `Provider "${provider.id}" does not use OAuth2 auth`);
407
751
  }
408
752
  const projectId = requiredText("projectId", input.projectId);
753
+ if (mcp)
754
+ requireMcpSetupHost();
755
+ else if (!options.oauthClients)
756
+ throw new ConnectError("setup_invalid", "OAuth Clients are not configured");
757
+ await authorizeOAuth("setup", mcp ? "mcp" : "api", { ...input, projectId });
409
758
  if (!["personal", "shared", "end_user"].includes(input.audience)) {
410
759
  throw new ConnectError("setup_invalid", "Connection setup audience is invalid");
411
760
  }
761
+ const identity = normalizeSetupIdentity(input.audience, input.subject, input.binding);
412
762
  const returnUrl = normalizeReturnUrl(input.returnUrl, options.allowedReturnUrlOrigins);
413
- const scopes = assertAllowedScopes(provider, input.scopes ?? provider.auth.defaultScopes);
414
- const client = await resolveOAuthClient({
415
- provider,
416
- projectId,
417
- ...(input.orgId ? { orgId: requiredText("orgId", input.orgId) } : {}),
418
- redirectUri: "",
419
- mode: input.oauthClientMode,
420
- });
421
- if (client.redirectUris.length === 0) {
763
+ const scopes = assertAllowedScopes(provider, [...(input.scopes ?? provider.auth.defaultScopes ?? []), ...(provider.auth.type === "oauth2" ? provider.auth.identity?.requiredScopes ?? [] : [])]);
764
+ const mcpClient = mcp ? await resolveMcpOAuthSetupClient(options.mcpOAuthClients, {
765
+ providerId: provider.id, authenticationId: provider.authenticationId, projectId, orgId: input.orgId,
766
+ configurationId: input.configurationId, mode: input.oauthClientMode,
767
+ }) : undefined;
768
+ const client = mcp ? undefined : await resolveOAuthClient({ provider, projectId,
769
+ ...(input.orgId ? { orgId: requiredText("orgId", input.orgId) } : {}), redirectUri: "", mode: input.oauthClientMode });
770
+ if (client && client.redirectUris.length === 0)
422
771
  throw new ConnectError("setup_invalid", "OAuth Client has no registered redirect URI");
423
- }
424
772
  const timestamp = now().toISOString();
425
773
  const setup = {
426
774
  id: createId("connsetup"),
427
775
  providerId: provider.id,
428
- oauthClientId: client.id,
776
+ authenticationId: provider.authenticationId,
777
+ ...(mcp ? { flowKind: "mcp" } : {}),
778
+ oauthClientId: mcpClient?.reference.id ?? client.id,
779
+ oauthClientFingerprint: mcpClient?.reference.fingerprint ?? oauthClientFingerprint(provider, client.clientId),
780
+ oauthIdentityPolicyFingerprint: oauthIdentityPolicyFingerprint(provider.auth.type === "oauth2" ? provider.auth.identity : undefined),
429
781
  projectId,
430
782
  ...(input.orgId ? { orgId: requiredText("orgId", input.orgId) } : {}),
431
783
  audience: input.audience,
432
- subject: normalizeConnectionOwner(input.subject),
433
- ...(input.binding ? { binding: normalizeConnectionBinding(input.binding) } : {}),
784
+ ...identity,
434
785
  scopes,
435
786
  returnUrl,
436
787
  expiresAt: new Date(now().getTime() + setupSessionTtlMs).toISOString(),
437
788
  createdAt: timestamp,
438
789
  ...(input.metadata ? { metadata: input.metadata } : {}),
439
790
  };
791
+ await authorizeOAuth("setup", mcp ? "mcp" : "api", setup);
440
792
  await options.setupSessions.saveConnectionSetupSession(setup);
441
793
  return setup;
442
794
  },
795
+ async getSetupStatus(id) {
796
+ if (!options.setupSessions)
797
+ throw new ConnectError("setup_invalid", "Connection setup sessions are not configured on this host");
798
+ return observeConnectionSetupSession(options.setupSessions, id, now);
799
+ },
800
+ async cancelSetupSession(id) {
801
+ if (!options.setupSessions)
802
+ throw new ConnectError("setup_invalid", "Connection setup sessions are not configured on this host");
803
+ return cancelConnectionSetupSession(options.setupSessions, id, now);
804
+ },
443
805
  async startOAuthSetup(input) {
444
- if (!options.setupSessions || !options.oauthClients) {
806
+ if (!options.setupSessions) {
445
807
  throw new ConnectError("setup_invalid", "Connection setup sessions are not configured on this host");
446
808
  }
447
809
  const setupSessionId = requiredText("setupSessionId", input.setupSessionId);
@@ -449,41 +811,83 @@ export function createConnectService(options) {
449
811
  if (!existing) {
450
812
  throw new ConnectError("setup_invalid", "Connection setup session was not found");
451
813
  }
452
- if (existing.consumedAt) {
814
+ if (existing.consumedAt || connectionSetupState(existing) !== "pending") {
453
815
  throw new ConnectError("setup_consumed", "Connection setup session has already been used");
454
816
  }
455
817
  if (new Date(existing.expiresAt).getTime() <= now().getTime()) {
456
818
  throw new ConnectError("setup_expired", "Connection setup session has expired");
457
819
  }
458
- const setup = await options.setupSessions.consumeConnectionSetupSession(setupSessionId, now().toISOString());
820
+ if (existing.flowKind === "mcp")
821
+ requireMcpSetupHost();
822
+ else if (!options.oauthClients)
823
+ throw new ConnectError("setup_invalid", "OAuth Clients are not configured");
824
+ await authorizeOAuth("authorize", existing.flowKind === "mcp" ? "mcp" : "api", existing);
825
+ const consumedAt = now();
826
+ const authorizationExpiresAt = new Date(consumedAt.getTime() + oauthStateTtlMs).toISOString();
827
+ const setup = await options.setupSessions.consumeConnectionSetupSession(setupSessionId, consumedAt.toISOString(), authorizationExpiresAt);
459
828
  if (!setup) {
829
+ if (Date.parse(existing.expiresAt) <= now().getTime())
830
+ throw new ConnectError("setup_expired", "Connection setup session has expired");
460
831
  throw new ConnectError("setup_consumed", "Connection setup session has already been used");
461
832
  }
462
- const provider = registry.require(setup.providerId);
463
- const client = await options.oauthClients.resolveById(setup.oauthClientId);
464
- if (!client || client.providerId !== provider.id) {
465
- throw new ConnectError("setup_invalid", "Connection setup OAuth Client is unavailable");
833
+ let stateWriteAttempted = false;
834
+ try {
835
+ const provider = await requireProvider(setup.providerId, setup.authenticationId);
836
+ if (setup.flowKind === "mcp") {
837
+ if (provider.auth.type !== "mcp")
838
+ throw new ConnectError("setup_invalid", "MCP Connector changed");
839
+ const { client, reference } = await verifyMcpClient(setup.oauthClientId, setup.oauthClientFingerprint);
840
+ const registration = client.registration;
841
+ return await beginMcpOAuth({ providerId: setup.providerId, authenticationId: setup.authenticationId,
842
+ projectId: setup.projectId, orgId: setup.orgId, audience: setup.audience, subject: setup.subject, binding: setup.binding,
843
+ url: client.resourceUrl, transport: client.transport, redirectUri: client.redirectUri, scopes: setup.scopes,
844
+ mode: registration.mode, metadata: setup.metadata,
845
+ ...(registration.mode === "dynamic" ? { clientName: registration.clientName, clientUri: registration.clientUri }
846
+ : registration.mode === "metadata_document" ? { clientMetadataUrl: registration.clientMetadataUrl }
847
+ : { preRegisteredClient: registration.client }),
848
+ }, { reference: setup.reference ?? setup.id, clientId: reference.id, fingerprint: reference.fingerprint,
849
+ returnUrl: setup.returnUrl, authorizationExpiresAt, onStateWriteAttempt: () => { stateWriteAttempted = true; } });
850
+ }
851
+ if (setup.oauthIdentityPolicyFingerprint !== oauthIdentityPolicyFingerprint(provider.auth.type === "oauth2" ? provider.auth.identity : undefined)) {
852
+ throw new ConnectError("setup_invalid", "OAuth identity policy changed; restart setup");
853
+ }
854
+ const client = await options.oauthClients.resolveById(setup.oauthClientId);
855
+ if (!client || client.providerId !== provider.id) {
856
+ throw new ConnectError("setup_invalid", "Connection setup OAuth Client is unavailable");
857
+ }
858
+ assertOAuthClientFingerprint(provider, client.clientId, setup.oauthClientFingerprint);
859
+ const identity = normalizeSetupIdentity(setup.audience, setup.subject, setup.binding);
860
+ const redirectUri = client.redirectUris[0];
861
+ return await beginOAuth({
862
+ onStateWriteAttempt: () => { stateWriteAttempted = true; },
863
+ setupSessionRef: setup.reference ?? setup.id,
864
+ authorizationExpiresAt,
865
+ provider,
866
+ client,
867
+ scopes: setup.scopes,
868
+ subject: identity.subject,
869
+ redirectUri,
870
+ projectId: setup.projectId,
871
+ orgId: setup.orgId,
872
+ metadata: setup.metadata,
873
+ audience: setup.audience,
874
+ binding: identity.binding,
875
+ returnUrl: setup.returnUrl,
876
+ });
877
+ }
878
+ catch (error) {
879
+ // A lost OAuth-state write acknowledgement is not proof no state exists.
880
+ if (!stateWriteAttempted)
881
+ await options.setupSessions.finishConnectionSetupSession?.(setup.reference ?? setup.id, { status: "error" }, now().toISOString()).catch(() => undefined);
882
+ throw error;
466
883
  }
467
- const redirectUri = client.redirectUris[0];
468
- return beginOAuth({
469
- provider,
470
- client,
471
- scopes: setup.scopes,
472
- subject: setup.subject,
473
- redirectUri,
474
- projectId: setup.projectId,
475
- orgId: setup.orgId,
476
- metadata: setup.metadata,
477
- audience: setup.audience,
478
- binding: setup.binding,
479
- returnUrl: setup.returnUrl,
480
- });
481
884
  },
482
885
  async startOAuth(input) {
483
- const provider = registry.require(input.providerId);
886
+ const provider = await requireProvider(input.providerId, input.authenticationId);
484
887
  if (provider.auth.type !== "oauth2") {
485
888
  throw new ConnectError("unsupported_auth", `Provider "${provider.id}" does not use OAuth2 auth`);
486
889
  }
890
+ await authorizeOAuth("authorize", "api", input);
487
891
  const client = await resolveOAuthClient({
488
892
  provider,
489
893
  projectId: input.projectId,
@@ -491,96 +895,218 @@ export function createConnectService(options) {
491
895
  redirectUri: input.redirectUri,
492
896
  mode: input.oauthClientMode,
493
897
  });
494
- return beginOAuth({ ...input, provider, client });
898
+ return beginOAuth({ ...input, provider, client, setupSessionRef: undefined, authorizationExpiresAt: undefined, onStateWriteAttempt: undefined });
899
+ },
900
+ async reconnectOAuth(input) {
901
+ const connection = await options.store.getConnection(input.connectionId);
902
+ if (!connection)
903
+ throw new ConnectError("connection_not_found", "Connection not found");
904
+ if (connection.authType !== "oauth2") {
905
+ throw new ConnectError("unsupported_auth", "Only OAuth2 Connections can use OAuth reconnect");
906
+ }
907
+ if ((input.orgId && connection.orgId && input.orgId !== connection.orgId)
908
+ || (input.projectId && connection.projectId && input.projectId !== connection.projectId)) {
909
+ throw new ConnectError("policy_denied", "Connection belongs to a different organization or project");
910
+ }
911
+ if (!connection.oauthClientId || connection.oauthClientId.startsWith("legacy:")) {
912
+ throw new ConnectError("setup_invalid", "The original OAuth Client is not recorded; create a new Connection with an explicit OAuth app");
913
+ }
914
+ const provider = await requireProvider(connection.providerId, connection.authenticationId);
915
+ if (provider.auth.type !== "oauth2" || !provider.auth.identity || !connection.oauthIdentity
916
+ || connection.oauthIdentity.policyFingerprint !== oauthIdentityPolicyFingerprint(provider.auth.identity)
917
+ || provider.auth.identity.requiredScopes.some(scope => !connection.grantedScopes.includes(scope))) {
918
+ throw new ConnectError("setup_invalid", "Same-account reconnect requires a verified account identity; create a new Connection");
919
+ }
920
+ if (!options.store.replaceOAuthCredential) {
921
+ throw new ConnectError("setup_invalid", "This host does not support atomic OAuth reconnect");
922
+ }
923
+ if (connection.status !== "active")
924
+ throw new ConnectError("connection_revoked", "A revoked Connection cannot be reconnected");
925
+ await authorizeOAuth("authorize", "api", { ...connection, connectionId: connection.id,
926
+ projectId: input.projectId ?? connection.projectId, orgId: connection.orgId ?? input.orgId });
927
+ const client = await options.oauthClients?.resolveById(connection.oauthClientId);
928
+ if (!client || client.id !== connection.oauthClientId || client.providerId !== connection.providerId) {
929
+ throw new ConnectError("setup_invalid", "The original OAuth Client is unavailable; restore it or create a new Connection");
930
+ }
931
+ assertOAuthClientFingerprint(provider, client.clientId, connection.oauthClientFingerprint);
932
+ return beginOAuth({
933
+ provider,
934
+ client,
935
+ scopes: connection.grantedScopes,
936
+ subject: connection.owner,
937
+ redirectUri: input.redirectUri,
938
+ projectId: input.projectId ?? connection.projectId,
939
+ orgId: connection.orgId ?? input.orgId,
940
+ connectionName: connection.name,
941
+ audience: connection.audience,
942
+ binding: connection.binding,
943
+ metadata: { ...connection.metadata, ...input.metadata },
944
+ reconnect: snapshotOAuthReconnect(connection),
945
+ });
495
946
  },
496
947
  async completeOAuth(input) {
497
948
  const state = await options.store.consumeOAuthState(input.state);
498
949
  if (!state) {
499
950
  throw new ConnectError("oauth_state_not_found", "OAuth state was not found or has already been used");
500
951
  }
501
- if (new Date(state.expiresAt).getTime() <= now().getTime()) {
502
- throw new ConnectError("oauth_state_expired", "OAuth state has expired");
503
- }
504
- if (input.error) {
505
- throw new ConnectError("oauth_error", input.errorDescription ?? input.error, {
506
- details: { error: input.error, errorDescription: input.errorDescription },
507
- });
508
- }
509
- if (!input.code) {
510
- throw new ConnectError("invalid_request", "OAuth callback is missing code");
511
- }
512
- const provider = registry.require(state.providerId);
513
- if (provider.auth.type !== "oauth2") {
514
- throw new ConnectError("unsupported_auth", `Provider "${provider.id}" does not use OAuth2 auth`);
515
- }
516
- let oauthAuth = provider.auth;
517
- if (state.oauthClientId && !state.oauthClientId.startsWith("legacy:")) {
518
- const client = await options.oauthClients?.resolveById(state.oauthClientId);
519
- if (!client || client.providerId !== provider.id) {
520
- throw new ConnectError("setup_invalid", "OAuth Client is unavailable during callback");
521
- }
522
- assertOAuthClient(provider.id, state.redirectUri, client);
523
- oauthAuth = {
524
- ...provider.auth,
525
- clientId: client.clientId,
526
- clientSecret: client.clientSecret,
527
- };
528
- }
529
- const tokenSet = await exchangeCode(fetchImpl, oauthAuth, input.code, state.redirectUri, state.codeVerifier);
530
- const grantedScopes = assertAllowedScopes(provider, parseScopes(tokenSet.scope, state.requestedScopes));
531
- assertGrantedScopes(grantedScopes, state.requestedScopes);
532
- const id = createId("conn");
533
- const secretRef = createId("connsec");
534
- const tokens = normalizeTokenSet(tokenSet, grantedScopes, now());
535
- await options.secrets.setSecret(secretRef, { kind: "oauth2", tokens });
536
- let connection;
952
+ let credentialWriteAttempted = false;
537
953
  try {
538
- connection = await options.store.upsertConnection({
539
- id,
540
- providerId: provider.id,
541
- name: state.connectionName,
542
- projectId: state.audience ? undefined : state.projectId,
543
- orgId: state.orgId,
544
- owner: state.subject,
545
- audience: state.audience,
546
- oauthClientId: state.oauthClientId,
547
- binding: state.binding,
548
- authType: "oauth2",
549
- status: "active",
550
- grantedScopes,
551
- secretRef,
552
- tokenExpiresAt: tokens.expiresAt,
553
- createdAt: now().toISOString(),
554
- updatedAt: now().toISOString(),
555
- metadata: state.metadata,
556
- });
557
- if (state.audience && state.projectId) {
558
- if (!options.links) {
559
- throw new ConnectError("setup_invalid", "Connection link storage is not configured on this host");
954
+ if (new Date(state.expiresAt).getTime() <= now().getTime()) {
955
+ throw new ConnectError("oauth_state_expired", "OAuth state has expired");
956
+ }
957
+ if (input.error) {
958
+ throw new ConnectError("oauth_error", input.errorDescription ?? input.error, {
959
+ details: { error: input.error, errorDescription: input.errorDescription },
960
+ });
961
+ }
962
+ if (!input.code) {
963
+ throw new ConnectError("invalid_request", "OAuth callback is missing code");
964
+ }
965
+ await authorizeOAuth("callback", "api", state);
966
+ const { provider, oauthAuth } = await configurationForOAuthState(state);
967
+ const original = state.reconnect ? await options.store.getConnection(state.reconnect.connectionId) : null;
968
+ if (state.reconnect && (!original || !options.store.replaceOAuthCredential || !matchesOAuthReconnect(original, state.reconnect))) {
969
+ throw new ConnectError("policy_denied", "Connection changed while reconnecting");
970
+ }
971
+ await authorizeOAuth("callback", "api", state);
972
+ const tokenSet = await exchangeCode(fetchImpl, resolveHostname, oauthAuth, input.code, state.redirectUri, state.codeVerifier);
973
+ const tokenScopes = normalizeOAuthTokenScopes(provider.auth, parseScopes(tokenSet.scope, state.requestedScopes));
974
+ // A provider may include grants from a previous consent on the same OAuth
975
+ // app. Record actual token scopes without expanding this Connection.
976
+ assertGrantedScopes(tokenScopes, state.requestedScopes);
977
+ const grantedScopes = assertAllowedScopes(provider, state.requestedScopes);
978
+ await authorizeOAuth("callback", "api", state);
979
+ const oauthIdentity = provider.auth.identity ? await resolveOAuthAccountIdentity({ fetch: fetchImpl, resolveHostname }, provider.auth.identity, tokenSet.access_token, now()) : undefined;
980
+ if (state.reconnect && (!oauthIdentity || !original?.oauthIdentity
981
+ || oauthIdentity.issuer !== original.oauthIdentity.issuer || oauthIdentity.subject !== original.oauthIdentity.subject
982
+ || oauthIdentity.policyFingerprint !== original.oauthIdentity.policyFingerprint)) {
983
+ throw new ConnectError("policy_denied", "The authorized account differs from the original Connection; connect it separately");
984
+ }
985
+ // Recheck changes made while the provider exchange/identity request was in flight.
986
+ // Durable hosts must additionally pin their OAuth Client row in the atomic commit.
987
+ await configurationForOAuthState(state);
988
+ await authorizeOAuth("callback", "api", state);
989
+ const id = createId("conn");
990
+ const secretRef = createId("connsec");
991
+ const tokens = normalizeTokenSet(tokenSet, tokenScopes, now());
992
+ await options.secrets.setSecret(secretRef, { kind: "oauth2", tokens });
993
+ let connection;
994
+ try {
995
+ if (state.reconnect) {
996
+ const committed = {};
997
+ const replacement = {
998
+ secretRef, credentialVersion: createId("credver"), tokenExpiresAt: tokens.expiresAt,
999
+ grantedScopes, oauthClientFingerprint: state.oauthClientFingerprint, oauthIdentity: oauthIdentity, updatedAt: now().toISOString(),
1000
+ };
1001
+ try {
1002
+ await refreshCoordinator.runExclusive(state.reconnect.connectionId, async () => {
1003
+ await configurationForOAuthState(state);
1004
+ await authorizeOAuth("callback", "api", state);
1005
+ credentialWriteAttempted = true;
1006
+ committed.connection = await options.store.replaceOAuthCredential(state.reconnect, replacement);
1007
+ // An explicit CAS miss proves this operation did not store its reference.
1008
+ if (committed.connection === null)
1009
+ credentialWriteAttempted = false;
1010
+ });
1011
+ }
1012
+ catch (error) {
1013
+ // A coordinator release failure cannot roll back a confirmed commit.
1014
+ if (!committed.connection && credentialWriteAttempted) {
1015
+ committed.connection = await recoverConnectionWrite({ ...original, ...replacement });
1016
+ }
1017
+ if (!committed.connection)
1018
+ throw error;
1019
+ }
1020
+ const saved = committed.connection;
1021
+ if (!saved)
1022
+ throw new ConnectError("policy_denied", "Connection changed while reconnecting");
1023
+ // A failed old-secret cleanup cannot undo a committed rotation or invite token exchange replay.
1024
+ if (original?.secretRef)
1025
+ await options.secrets.deleteSecret(original.secretRef).catch(() => undefined);
1026
+ return saved;
560
1027
  }
561
- await options.links.upsertConnectionLink({
562
- id: createId("connlink"),
563
- connectionId: connection.id,
564
- projectId: state.projectId,
1028
+ await configurationForOAuthState(state);
1029
+ await authorizeOAuth("callback", "api", state);
1030
+ const pendingConnection = {
1031
+ id,
1032
+ providerId: provider.id,
1033
+ authenticationId: provider.authenticationId,
1034
+ name: state.connectionName,
1035
+ projectId: state.audience ? undefined : state.projectId,
1036
+ orgId: state.orgId,
1037
+ owner: state.subject,
1038
+ audience: state.audience,
1039
+ oauthClientId: state.oauthClientId,
1040
+ oauthClientFingerprint: state.oauthClientFingerprint,
1041
+ oauthIdentity,
1042
+ binding: state.binding,
1043
+ authType: "oauth2",
565
1044
  status: "active",
1045
+ credentialVersion: createId("credver"),
1046
+ grantedScopes,
1047
+ secretRef,
1048
+ tokenExpiresAt: tokens.expiresAt,
566
1049
  createdAt: now().toISOString(),
567
1050
  updatedAt: now().toISOString(),
568
- });
1051
+ metadata: state.metadata,
1052
+ };
1053
+ const pendingLink = state.audience && state.projectId ? {
1054
+ id: createId("connlink"), connectionId: id, projectId: state.projectId, status: "active",
1055
+ createdAt: now().toISOString(), updatedAt: now().toISOString(),
1056
+ } : undefined;
1057
+ if (state.setupSessionRef && pendingLink && options.setupSessions?.prepareConnectionSetupCompletion) {
1058
+ const intent = { connection: snapshotOAuthReconnect(pendingConnection),
1059
+ link: { id: pendingLink.id, connectionId: id, projectId: pendingLink.projectId } };
1060
+ const prepare = () => options.setupSessions.prepareConnectionSetupCompletion(state.setupSessionRef, intent);
1061
+ // A committed intent is idempotent; do not start account persistence
1062
+ // until its exact private recovery target has been confirmed.
1063
+ const prepared = await prepare().catch(() => prepare());
1064
+ if (!prepared)
1065
+ throw new ConnectError("setup_invalid", "Connection setup completion intent could not be confirmed");
1066
+ }
1067
+ credentialWriteAttempted = true;
1068
+ connection = await persistNewConnection(pendingConnection);
1069
+ if (pendingLink) {
1070
+ if (!options.links) {
1071
+ throw new ConnectError("setup_invalid", "Connection link storage is not configured on this host");
1072
+ }
1073
+ await options.links.upsertConnectionLink(pendingLink);
1074
+ }
1075
+ if (state.setupSessionRef && options.setupSessions?.finishConnectionSetupSession) {
1076
+ const receipt = await options.setupSessions.finishConnectionSetupSession(state.setupSessionRef, { status: "completed", connectionId: connection.id }, now().toISOString()).catch(async (error) => {
1077
+ const recovered = await options.setupSessions?.reconcileConnectionSetupSession?.(state.setupSessionRef).catch(() => null);
1078
+ if (recovered?.status === "completed" && recovered.resultingConnectionId === connection.id)
1079
+ return recovered;
1080
+ throw error;
1081
+ });
1082
+ if (!receipt)
1083
+ throw new ConnectError("setup_invalid", "Connection setup completion receipt could not be confirmed");
1084
+ }
1085
+ return connection;
1086
+ }
1087
+ catch (error) {
1088
+ // Cleanup is safe only before attempting the reference write, or after
1089
+ // an explicit CAS miss. Never invalidate a committed account because a
1090
+ // later link/acknowledgement failed; that would also race with reconnect.
1091
+ if (!credentialWriteAttempted)
1092
+ await options.secrets.deleteSecret(secretRef).catch(() => undefined);
1093
+ throw error;
569
1094
  }
570
- return connection;
571
1095
  }
572
1096
  catch (error) {
573
- await options.secrets.deleteSecret(secretRef).catch(() => undefined);
574
- if (connection) {
575
- await options.store.updateConnection(connection.id, {
576
- status: "error",
577
- secretRef: undefined,
578
- updatedAt: now().toISOString(),
579
- }).catch(() => undefined);
1097
+ if (state.setupSessionRef && !credentialWriteAttempted) {
1098
+ await options.setupSessions?.finishConnectionSetupSession?.(state.setupSessionRef, { status: "error" }, now().toISOString()).catch(() => undefined);
580
1099
  }
581
1100
  throw error;
582
1101
  }
583
1102
  },
1103
+ async completeOAuthCallback(input) {
1104
+ const state = await options.store.getOAuthState?.(input.state);
1105
+ const connection = state?.flowKind === "mcp"
1106
+ ? await this.completeMcpOAuth(input)
1107
+ : await this.completeOAuth(input);
1108
+ return { connection, ...(state?.returnUrl ? { returnUrl: state.returnUrl } : {}) };
1109
+ },
584
1110
  async resolveCredential(input) {
585
1111
  const connection = await options.store.getConnection(input.connectionId);
586
1112
  if (!connection) {
@@ -589,7 +1115,22 @@ export function createConnectService(options) {
589
1115
  if (connection.status !== "active") {
590
1116
  throw new ConnectError("connection_revoked", `Connection is not active: ${connection.id}`);
591
1117
  }
592
- const scopes = assertGrantedScopes(connection.grantedScopes, input.scopes ?? []);
1118
+ const provider = await requireProvider(connection.providerId, connection.authenticationId);
1119
+ if (connection.oauthIdentity && (provider.auth.type !== "oauth2"
1120
+ || connection.oauthIdentity.policyFingerprint !== oauthIdentityPolicyFingerprint(provider.auth.identity))) {
1121
+ throw new ConnectError("setup_invalid", "OAuth identity policy changed; create a new Connection");
1122
+ }
1123
+ if (provider.auth.type !== connection.authType) {
1124
+ throw new ConnectError("unsupported_auth", "Connection authentication no longer matches its Connector");
1125
+ }
1126
+ if (provider.authenticationId && provider.auth.type === "mcp"
1127
+ && readMcpAuthMode(connection.metadata) !== (provider.auth.auth ?? "bearer")) {
1128
+ throw new ConnectError("unsupported_auth", "Connection MCP authentication no longer matches its Connector");
1129
+ }
1130
+ const action = input.actionId ? provider.actions?.find((candidate) => candidate.id === input.actionId) : undefined;
1131
+ // Hosts may also use actionId as an audit label for a custom tool. Its
1132
+ // operation policy remains enforced by that tool's capability/grant.
1133
+ const scopes = assertGrantedScopes(connection.grantedScopes, [...(input.scopes ?? []), ...(action?.scopes ?? [])]);
593
1134
  const allowed = await options.policy?.canUseConnection({
594
1135
  connection,
595
1136
  subject: input.subject,
@@ -599,8 +1140,13 @@ export function createConnectService(options) {
599
1140
  if (allowed === false) {
600
1141
  throw new ConnectError("policy_denied", `Connection use denied by policy: ${connection.id}`);
601
1142
  }
1143
+ await authorizeConnection("credential", connection);
1144
+ if (connection.authType === "oauth2" && connection.oauthClientFingerprint) {
1145
+ await oauthAuthForConnection(provider, connection.oauthClientId, connection.oauthClientFingerprint);
1146
+ }
602
1147
  if (!connection.secretRef) {
603
- if (connection.authType === "mcp" && readMcpAuthMode(connection.metadata) === "none") {
1148
+ if (!connectionRequiresSecret(connection)) {
1149
+ await assertConnectionCurrent(connection, scopes);
604
1150
  return {
605
1151
  kind: "none",
606
1152
  scopes,
@@ -615,9 +1161,13 @@ export function createConnectService(options) {
615
1161
  if (!secret) {
616
1162
  throw new ConnectError("secret_not_found", `Connection secret not found: ${connection.secretRef}`);
617
1163
  }
1164
+ if (secret.kind !== connection.authType) {
1165
+ throw new ConnectError("unsupported_auth", "Connection credential kind does not match its authentication");
1166
+ }
618
1167
  if (secret.kind === "api_key") {
619
1168
  if (!secret.apiKey)
620
1169
  throw new ConnectError("token_not_available", "API-key connection secret is empty");
1170
+ await assertConnectionCurrent(connection, scopes);
621
1171
  return {
622
1172
  kind: "api_key",
623
1173
  value: secret.apiKey,
@@ -629,35 +1179,77 @@ export function createConnectService(options) {
629
1179
  }
630
1180
  if (secret.kind === "mcp") {
631
1181
  if (secret.mcpOAuth?.tokens?.accessToken) {
1182
+ await authorizeConnection("credential", connection, true);
632
1183
  let material = secret.mcpOAuth;
633
- if (input.forceRefresh || shouldRefresh(material.tokens, now(), tokenRefreshSkewMs)) {
634
- material = await refreshCoordinator.runExclusive(connection.id, async () => {
635
- const latest = await options.secrets.getSecret(connection.secretRef);
636
- if (!latest?.mcpOAuth?.tokens?.accessToken) {
637
- throw new ConnectError("token_not_available", "MCP OAuth connection secret is empty");
638
- }
639
- if (!input.forceRefresh && !shouldRefresh(latest.mcpOAuth.tokens, now(), tokenRefreshSkewMs)) {
640
- return latest.mcpOAuth;
641
- }
642
- const tokens = await mcpOAuth.refresh({
643
- material: latest.mcpOAuth,
644
- fallbackScopes: scopes,
645
- });
646
- const refreshed = { ...latest.mcpOAuth, tokens };
647
- await options.secrets.setSecret(connection.secretRef, { kind: "mcp", mcpOAuth: refreshed });
648
- await options.store.updateConnection(connection.id, {
649
- tokenExpiresAt: tokens.expiresAt,
650
- updatedAt: now().toISOString(),
1184
+ if (input.forceRefresh || hasPendingRefresh(secret) || shouldRefresh(material.tokens, now(), tokenRefreshSkewMs)) {
1185
+ const initialTokens = material.tokens;
1186
+ try {
1187
+ material = await refreshCoordinator.runExclusive(connection.id, async () => {
1188
+ await assertConnectionCurrent(connection, scopes);
1189
+ const versionedStore = isVersionedConnectionSecretStore(options.secrets) ? options.secrets : undefined;
1190
+ const snapshot = await versionedStore?.getVersioned(connection.secretRef);
1191
+ const latest = snapshot?.secret ?? await options.secrets.getSecret(connection.secretRef);
1192
+ if (latest?.kind !== "mcp" || !latest.mcpOAuth?.tokens?.accessToken) {
1193
+ throw new ConnectError("token_not_available", "MCP OAuth connection secret is empty");
1194
+ }
1195
+ assertRefreshAvailable(latest);
1196
+ const latestTokens = latest.mcpOAuth.tokens;
1197
+ if (tokensChanged(initialTokens, latestTokens)
1198
+ || (!input.forceRefresh && !shouldRefresh(latestTokens, now(), tokenRefreshSkewMs))) {
1199
+ return latest.mcpOAuth;
1200
+ }
1201
+ await authorizeConnection("refresh", connection, true);
1202
+ let currentMaterial = latest.mcpOAuth;
1203
+ const saved = await refreshConnectionSecret({
1204
+ store: options.secrets, ref: connection.secretRef, previous: latest, snapshot, now,
1205
+ beforeRefresh: async () => {
1206
+ await assertConnectionCurrent(connection, scopes);
1207
+ await authorizeConnection("refresh", connection, true);
1208
+ currentMaterial = await currentMcpMaterial(latest.mcpOAuth, connection.oauthClientId, connection.oauthClientFingerprint);
1209
+ },
1210
+ refresh: async () => {
1211
+ const tokens = await mcpOAuth.refresh({
1212
+ material: currentMaterial,
1213
+ fallbackScopes: latestTokens.scopes ?? connection.grantedScopes,
1214
+ });
1215
+ // Versioned stores fence deletion/replacement at commit.
1216
+ // Persist known rotated material before any fallible read.
1217
+ if (!versionedStore)
1218
+ await assertConnectionCurrent(connection, scopes, false);
1219
+ return { ...latest, mcpOAuth: { ...latest.mcpOAuth, tokens } };
1220
+ },
1221
+ });
1222
+ // Preserve rotated refresh tokens durably before denying delivery
1223
+ // if the host gate changed while the provider request was in flight.
1224
+ const current = await assertConnectionCurrent(connection, scopes, false);
1225
+ const refreshed = saved.mcpOAuth;
1226
+ const tokens = refreshed.tokens;
1227
+ await options.store.updateConnection(connection.id, {
1228
+ tokenExpiresAt: tokens.expiresAt,
1229
+ grantedScopes: current.grantedScopes.filter((scope) => tokens.scopes?.includes(scope)),
1230
+ updatedAt: now().toISOString(),
1231
+ });
1232
+ return refreshed;
651
1233
  });
652
- return refreshed;
653
- });
1234
+ }
1235
+ catch (error) {
1236
+ if (error instanceof ConnectError && error.code === "refresh_unavailable") {
1237
+ await assertConnectionCurrent(connection, scopes, false);
1238
+ }
1239
+ if (error instanceof ConnectError)
1240
+ throw error;
1241
+ throw new ConnectError("refresh_unavailable", "MCP OAuth token refresh could not be coordinated");
1242
+ }
654
1243
  }
1244
+ assertGrantedScopes(material.tokens.scopes ?? connection.grantedScopes, scopes);
1245
+ const current = await assertConnectionCurrent(connection, scopes);
1246
+ await authorizeConnection("credential", current, true);
655
1247
  return {
656
1248
  kind: "mcp",
657
1249
  accessToken: material.tokens.accessToken,
658
1250
  tokenType: material.tokens.tokenType ?? "Bearer",
659
1251
  expiresAt: material.tokens.expiresAt,
660
- scopes,
1252
+ scopes: current.grantedScopes.filter((scope) => (material.tokens.scopes ?? current.grantedScopes).includes(scope)),
661
1253
  connectionId: connection.id,
662
1254
  providerId: connection.providerId,
663
1255
  metadata: connection.metadata,
@@ -665,6 +1257,7 @@ export function createConnectService(options) {
665
1257
  }
666
1258
  if (!secret.apiKey)
667
1259
  throw new ConnectError("token_not_available", "MCP connection secret is empty");
1260
+ await assertConnectionCurrent(connection, scopes);
668
1261
  return {
669
1262
  kind: "mcp",
670
1263
  accessToken: secret.apiKey,
@@ -679,10 +1272,11 @@ export function createConnectService(options) {
679
1272
  throw new ConnectError("token_not_available", "Connection does not contain an OAuth access token");
680
1273
  }
681
1274
  let tokens = secret.tokens;
682
- if (input.forceRefresh || shouldRefresh(tokens, now(), tokenRefreshSkewMs)) {
1275
+ if (input.forceRefresh || hasPendingRefresh(secret) || shouldRefresh(tokens, now(), tokenRefreshSkewMs)) {
683
1276
  const initialTokens = tokens;
684
1277
  try {
685
1278
  tokens = await refreshCoordinator.runExclusive(connection.id, async () => {
1279
+ await assertConnectionCurrent(connection, scopes);
686
1280
  const versionedStore = isVersionedConnectionSecretStore(options.secrets)
687
1281
  ? options.secrets
688
1282
  : undefined;
@@ -694,56 +1288,61 @@ export function createConnectService(options) {
694
1288
  if (latestSecret?.kind !== "oauth2" || !latestSecret.tokens?.accessToken) {
695
1289
  throw new ConnectError("token_not_available", "Connection does not contain an OAuth access token");
696
1290
  }
1291
+ assertRefreshAvailable(latestSecret);
697
1292
  const latestTokens = latestSecret.tokens;
698
- const refreshedByAnotherCaller = latestTokens.accessToken !== initialTokens.accessToken
699
- || latestTokens.refreshToken !== initialTokens.refreshToken
700
- || latestTokens.expiresAt !== initialTokens.expiresAt;
1293
+ const refreshedByAnotherCaller = tokensChanged(initialTokens, latestTokens);
701
1294
  if (refreshedByAnotherCaller
702
1295
  || (!input.forceRefresh && !shouldRefresh(latestTokens, now(), tokenRefreshSkewMs))) {
703
1296
  secret = latestSecret;
704
1297
  return latestTokens;
705
1298
  }
706
- const provider = registry.require(connection.providerId);
707
- const oauthAuth = await oauthAuthForConnection(provider, connection.oauthClientId);
1299
+ const provider = await requireProvider(connection.providerId, connection.authenticationId);
1300
+ const oauthAuth = await oauthAuthForConnection(provider, connection.oauthClientId, connection.oauthClientFingerprint);
708
1301
  if (!latestTokens.refreshToken) {
709
1302
  throw new ConnectError("token_not_available", "OAuth access token is expired and no refresh token is available");
710
1303
  }
711
- const refreshed = await refreshToken(fetchImpl, oauthAuth, latestTokens.refreshToken, connection.grantedScopes, now());
712
- const nextSecret = { ...latestSecret, tokens: refreshed };
713
- if (versionedStore && snapshot) {
714
- const written = await versionedStore.compareAndSet(connection.secretRef, snapshot.version, nextSecret);
715
- if (!written) {
716
- const winner = await versionedStore.getVersioned(connection.secretRef);
717
- if (winner?.secret.kind !== "oauth2" || !winner.secret.tokens?.accessToken) {
718
- throw new ConnectError("refresh_unavailable", "OAuth token refresh lost a write race without a valid winner");
719
- }
720
- secret = winner.secret;
721
- return winner.secret.tokens;
722
- }
723
- }
724
- else {
725
- await options.secrets.setSecret(connection.secretRef, nextSecret);
726
- }
1304
+ await authorizeConnection("refresh", connection);
1305
+ const nextSecret = await refreshConnectionSecret({
1306
+ store: options.secrets, ref: connection.secretRef, previous: latestSecret, snapshot, now,
1307
+ beforeRefresh: async () => {
1308
+ await assertConnectionCurrent(connection, scopes);
1309
+ await authorizeConnection("refresh", connection);
1310
+ },
1311
+ refresh: async () => {
1312
+ const refreshed = await refreshToken(fetchImpl, resolveHostname, oauthAuth, latestTokens.refreshToken, latestTokens.scopes ?? connection.grantedScopes, now());
1313
+ if (!versionedStore)
1314
+ await assertConnectionCurrent(connection, scopes, false);
1315
+ return { ...latestSecret, tokens: refreshed };
1316
+ },
1317
+ });
1318
+ const refreshed = nextSecret.tokens;
1319
+ const current = await assertConnectionCurrent(connection, scopes, false);
727
1320
  secret = nextSecret;
728
1321
  await options.store.updateConnection(connection.id, {
729
1322
  tokenExpiresAt: refreshed.expiresAt,
1323
+ grantedScopes: current.grantedScopes.filter((scope) => refreshed.scopes?.includes(scope)),
730
1324
  updatedAt: now().toISOString(),
731
1325
  });
732
1326
  return refreshed;
733
1327
  });
734
1328
  }
735
1329
  catch (error) {
1330
+ if (error instanceof ConnectError && error.code === "refresh_unavailable") {
1331
+ await assertConnectionCurrent(connection, scopes, false);
1332
+ }
736
1333
  if (error instanceof ConnectError)
737
1334
  throw error;
738
1335
  throw new ConnectError("refresh_unavailable", "OAuth token refresh could not be coordinated");
739
1336
  }
740
1337
  }
1338
+ assertGrantedScopes(tokens.scopes ?? connection.grantedScopes, scopes);
1339
+ const current = await assertConnectionCurrent(connection, scopes);
741
1340
  return {
742
1341
  kind: "oauth2",
743
1342
  accessToken: tokens.accessToken,
744
1343
  tokenType: tokens.tokenType ?? "Bearer",
745
1344
  expiresAt: tokens.expiresAt,
746
- scopes: tokens.scopes ?? scopes,
1345
+ scopes: current.grantedScopes.filter((scope) => (tokens.scopes ?? current.grantedScopes).includes(scope)),
747
1346
  connectionId: connection.id,
748
1347
  providerId: connection.providerId,
749
1348
  metadata: connection.metadata,
@@ -789,13 +1388,14 @@ export function createConnectService(options) {
789
1388
  if (!connection) {
790
1389
  throw new ConnectError("connection_not_found", `Connection not found: ${input.connectionId}`);
791
1390
  }
792
- const provider = registry.require(connection.providerId);
1391
+ const provider = await requireProvider(connection.providerId, connection.authenticationId);
793
1392
  if (!provider.http) {
794
1393
  throw new ConnectError("policy_denied", `Provider "${provider.id}" does not expose an HTTP gateway policy`);
795
1394
  }
1395
+ const httpPolicy = provider.http;
796
1396
  let resolvedRequest;
797
1397
  try {
798
- resolvedRequest = resolveConnectorHttpRequest(provider.http, input.request);
1398
+ resolvedRequest = resolveConnectorHttpRequest(httpPolicy, input.request);
799
1399
  }
800
1400
  catch (error) {
801
1401
  if (error instanceof ConnectionSelectionError) {
@@ -806,91 +1406,129 @@ export function createConnectService(options) {
806
1406
  }
807
1407
  throw error;
808
1408
  }
809
- const credential = await this.resolveCredential(input);
810
- const authValue = credential.kind === "api_key"
811
- ? credential.value
812
- : credential.kind === "oauth2" || credential.kind === "mcp"
813
- ? credential.accessToken
814
- : undefined;
815
- const tokenType = credential.kind === "oauth2" || credential.kind === "mcp"
816
- ? credential.tokenType ?? "Bearer"
817
- : "Bearer";
818
- let url = new URL(resolvedRequest.url);
819
- const headers = new Headers(resolvedRequest.headers);
820
- if (authValue) {
821
- if (provider.http.auth.mode === "bearer") {
822
- headers.set("authorization", `${tokenType} ${authValue}`);
823
- }
824
- else if (provider.http.auth.mode === "header") {
825
- headers.set(provider.http.auth.name, authValue);
826
- }
827
- else {
828
- url.searchParams.set(provider.http.auth.name, authValue);
829
- }
830
- }
831
- if (resolvedRequest.idempotencyKey) {
832
- headers.set("idempotency-key", resolvedRequest.idempotencyKey);
833
- }
834
- const body = resolvedRequest.body === undefined
835
- ? undefined
836
- : JSON.stringify(resolvedRequest.body);
837
- if (body !== undefined && !headers.has("content-type")) {
838
- headers.set("content-type", "application/json");
839
- }
840
- for (let redirectCount = 0; redirectCount <= 3; redirectCount += 1) {
841
- await assertPublicDns(url.hostname, resolveHostname);
842
- const response = await fetchWithTimeout(fetchImpl, url, {
843
- method: resolvedRequest.method,
844
- headers,
845
- body,
846
- redirect: "manual",
847
- }, resolvedRequest.timeoutMs, input.signal);
848
- if (response.status >= 300 && response.status < 400) {
849
- const location = response.headers.get("location");
850
- if (!location) {
851
- throw new ConnectError("http_error", "Provider returned a redirect without a location");
1409
+ return withGatewayDeadline(resolvedRequest.timeoutMs, input.signal, async (signal) => {
1410
+ const credential = await this.resolveCredential(input);
1411
+ const authValue = credential.kind === "api_key"
1412
+ ? credential.value
1413
+ : credential.kind === "oauth2" || credential.kind === "mcp"
1414
+ ? credential.accessToken
1415
+ : undefined;
1416
+ const tokenType = credential.kind === "oauth2" || credential.kind === "mcp"
1417
+ ? credential.tokenType ?? "Bearer"
1418
+ : "Bearer";
1419
+ let url = new URL(resolvedRequest.url);
1420
+ const headers = new Headers(resolvedRequest.headers);
1421
+ if (authValue) {
1422
+ if (httpPolicy.auth.mode === "bearer") {
1423
+ headers.set("authorization", `${tokenType} ${authValue}`);
852
1424
  }
853
- if (redirectCount === 3) {
854
- throw new ConnectError("http_error", "Provider exceeded the Connector redirect limit");
1425
+ else if (httpPolicy.auth.mode === "header") {
1426
+ headers.set(httpPolicy.auth.name, authValue);
855
1427
  }
856
- if (resolvedRequest.method !== "GET"
857
- && resolvedRequest.method !== "HEAD"
858
- && response.status !== 307
859
- && response.status !== 308) {
860
- throw new ConnectError("http_error", "Provider attempted an unsafe method-changing redirect");
1428
+ else if (httpPolicy.auth.mode === "query") {
1429
+ url.searchParams.set(httpPolicy.auth.name, authValue);
861
1430
  }
1431
+ }
1432
+ if (resolvedRequest.idempotencyKey) {
1433
+ headers.set("idempotency-key", resolvedRequest.idempotencyKey);
1434
+ }
1435
+ const body = resolvedRequest.body === undefined
1436
+ ? undefined
1437
+ : JSON.stringify(resolvedRequest.body);
1438
+ if (body !== undefined && !headers.has("content-type")) {
1439
+ headers.set("content-type", "application/json");
1440
+ }
1441
+ const authorizeSend = async () => {
1442
+ await assertConnectionCurrent(connection, credential.scopes);
862
1443
  try {
863
- url = new URL(assertConnectorRedirectAllowed(provider.http, new URL(location, url).toString()));
1444
+ await input.authorizeDispatch?.();
864
1445
  }
865
1446
  catch (error) {
866
- if (error instanceof ConnectionSelectionError) {
867
- throw new ConnectError("http_error", "Provider redirect was denied by policy", {
868
- details: { category: "redirect_denied" },
869
- });
1447
+ if (error instanceof ConnectError)
1448
+ throw error;
1449
+ throw new ConnectError("policy_denied", "Invocation authorization is no longer available", {
1450
+ status: error instanceof ConnectionSelectionError ? error.status : 503,
1451
+ ...(error instanceof ConnectionSelectionError ? { details: { code: error.code } } : {}),
1452
+ });
1453
+ }
1454
+ };
1455
+ for (let redirectCount = 0; redirectCount <= 3; redirectCount += 1) {
1456
+ signal.throwIfAborted();
1457
+ await assertPublicDns(url.hostname, resolveHostname);
1458
+ signal.throwIfAborted();
1459
+ await authorizeSend();
1460
+ signal.throwIfAborted();
1461
+ if (input.beforeDispatch || options.beforeDispatch) {
1462
+ if (input.beforeDispatch)
1463
+ await input.beforeDispatch({ method: resolvedRequest.method });
1464
+ else
1465
+ await options.beforeDispatch(connection);
1466
+ // Waiting on a distributed quota is an authorization boundary too.
1467
+ // A revoked grant/account must not send previously resolved headers.
1468
+ await authorizeSend();
1469
+ }
1470
+ signal.throwIfAborted();
1471
+ const response = await fetchImpl(url, {
1472
+ method: resolvedRequest.method,
1473
+ headers,
1474
+ body,
1475
+ redirect: "manual",
1476
+ signal,
1477
+ });
1478
+ if (response.status >= 300 && response.status < 400) {
1479
+ void response.body?.cancel().catch(() => undefined);
1480
+ const location = response.headers.get("location");
1481
+ if (!location) {
1482
+ throw new ConnectError("http_error", "Provider returned a redirect without a location");
870
1483
  }
871
- throw error;
1484
+ if (redirectCount === 3) {
1485
+ throw new ConnectError("http_error", "Provider exceeded the Connector redirect limit");
1486
+ }
1487
+ if (resolvedRequest.method !== "GET"
1488
+ && resolvedRequest.method !== "HEAD"
1489
+ && response.status !== 307
1490
+ && response.status !== 308) {
1491
+ throw new ConnectError("http_error", "Provider attempted an unsafe method-changing redirect");
1492
+ }
1493
+ try {
1494
+ url = new URL(assertConnectorRedirectAllowed(httpPolicy, new URL(location, url).toString()));
1495
+ }
1496
+ catch (error) {
1497
+ if (error instanceof ConnectionSelectionError) {
1498
+ throw new ConnectError("http_error", "Provider redirect was denied by policy", {
1499
+ details: { category: "redirect_denied" },
1500
+ });
1501
+ }
1502
+ throw error;
1503
+ }
1504
+ continue;
872
1505
  }
873
- continue;
1506
+ const responseBytes = await readResponseBytes(response, resolvedRequest.maxResponseBytes, signal);
1507
+ const responseHeaders = sanitizedResponseHeaders(response.headers);
1508
+ const requestId = response.headers.get("x-request-id")
1509
+ ?? response.headers.get("request-id")
1510
+ ?? undefined;
1511
+ return {
1512
+ status: response.status,
1513
+ headers: Object.freeze(responseHeaders),
1514
+ body: parseResponseBody(responseBytes, response.headers.get("content-type")),
1515
+ ...(requestId ? { requestId } : {}),
1516
+ };
874
1517
  }
875
- const responseBytes = await readResponseBytes(response, resolvedRequest.maxResponseBytes);
876
- const responseHeaders = sanitizedResponseHeaders(response.headers);
877
- const requestId = response.headers.get("x-request-id")
878
- ?? response.headers.get("request-id")
879
- ?? undefined;
880
- return {
881
- status: response.status,
882
- headers: Object.freeze(responseHeaders),
883
- body: parseResponseBody(responseBytes, response.headers.get("content-type")),
884
- ...(requestId ? { requestId } : {}),
885
- };
886
- }
887
- throw new ConnectError("http_error", "Provider request did not produce a response");
1518
+ throw new ConnectError("http_error", "Provider request did not produce a response");
1519
+ });
888
1520
  },
889
1521
  async revokeConnection(input) {
890
1522
  const connection = await options.store.getConnection(input.connectionId);
891
1523
  if (!connection) {
892
1524
  throw new ConnectError("connection_not_found", `Connection not found: ${input.connectionId}`);
893
1525
  }
1526
+ // Deny new use before touching the vault. Retain the ref until deletion
1527
+ // succeeds so a transient vault failure can be retried safely.
1528
+ await options.store.updateConnection(connection.id, {
1529
+ status: "revoked",
1530
+ updatedAt: now().toISOString(),
1531
+ });
894
1532
  if (connection.secretRef) {
895
1533
  await options.secrets.deleteSecret(connection.secretRef);
896
1534
  }
@@ -902,6 +1540,25 @@ export function createConnectService(options) {
902
1540
  },
903
1541
  };
904
1542
  }
1543
+ function tokensChanged(before, after) {
1544
+ return before.accessToken !== after.accessToken
1545
+ || before.refreshToken !== after.refreshToken
1546
+ || before.expiresAt !== after.expiresAt;
1547
+ }
1548
+ function oauthClientFingerprint(provider, clientId) {
1549
+ if (provider.auth.type !== "oauth2" || !clientId) {
1550
+ throw new ConnectError("setup_invalid", "OAuth Client registration is unavailable; reconnect the Connection");
1551
+ }
1552
+ return createHash("sha256").update(JSON.stringify([
1553
+ provider.id, provider.authenticationId ?? null, clientId,
1554
+ provider.auth.authorizationUrl, provider.auth.tokenUrl,
1555
+ ])).digest("hex");
1556
+ }
1557
+ function assertOAuthClientFingerprint(provider, clientId, expected) {
1558
+ if (expected && oauthClientFingerprint(provider, clientId) !== expected) {
1559
+ throw new ConnectError("setup_invalid", "OAuth Client registration changed; reconnect the Connection");
1560
+ }
1561
+ }
905
1562
  function assertOAuthClient(providerId, redirectUri, client) {
906
1563
  if (!client
907
1564
  || client.providerId !== providerId
@@ -936,6 +1593,43 @@ function normalizeReturnUrl(value, allowedOrigins) {
936
1593
  }
937
1594
  return url.toString();
938
1595
  }
1596
+ function normalizeCreationIdentity(input) {
1597
+ const audience = input.audience === undefined ? "shared" : input.audience;
1598
+ if (!["personal", "shared", "end_user"].includes(audience)) {
1599
+ throw new ConnectError("setup_invalid", "Connection audience is invalid");
1600
+ }
1601
+ // Agent-owned static credentials predate audience selection. Preserve that
1602
+ // explicit shared ownership, without allowing it to satisfy a user audience.
1603
+ if (audience === "shared" && input.subject?.type === "agent") {
1604
+ const subject = { type: "agent", id: requiredText("owner.id", input.subject.id) };
1605
+ return { audience, subject, ...(input.binding === undefined ? {} : { binding: normalizeConnectionBinding(input.binding) }) };
1606
+ }
1607
+ if (input.subject !== undefined || audience !== "shared") {
1608
+ return { audience, ...normalizeSetupIdentity(audience, input.subject, input.binding) };
1609
+ }
1610
+ return { audience, ...(input.binding === undefined ? {} : { binding: normalizeConnectionBinding(input.binding) }) };
1611
+ }
1612
+ /** Inventory is host-observed evidence, never caller-authored configuration. */
1613
+ function omitMcpDiscoveryMetadata(metadata) {
1614
+ const { discoveredTools: _tools, lastDiscoveredAt: _timestamp, ...rest } = metadata ?? {};
1615
+ return rest;
1616
+ }
1617
+ function normalizeSetupIdentity(audience, owner, suppliedBinding) {
1618
+ const subject = normalizeConnectionOwner(owner);
1619
+ const binding = suppliedBinding === undefined ? undefined : normalizeConnectionBinding(suppliedBinding);
1620
+ const principalType = audience === "end_user" ? "external_user" : audience === "personal" ? "user" : undefined;
1621
+ if (!principalType)
1622
+ return { subject, ...(binding ? { binding } : {}) };
1623
+ if (subject.type !== principalType)
1624
+ throw new ConnectError("setup_invalid", "Connection setup subject does not match its audience");
1625
+ const principal = { type: principalType, id: subject.id,
1626
+ ...(subject.type === "external_user" ? { namespace: subject.namespace } : {}) };
1627
+ if (binding?.principal && (binding.principal.type !== principal.type || binding.principal.id !== principal.id
1628
+ || (binding.principal.namespace !== undefined && binding.principal.namespace !== principal.namespace))) {
1629
+ throw new ConnectError("setup_invalid", "Connection setup binding does not match its subject");
1630
+ }
1631
+ return { subject, binding: { ...binding, principal } };
1632
+ }
939
1633
  function normalizeConnectionOwner(owner) {
940
1634
  if (!owner || typeof owner !== "object" || Array.isArray(owner)) {
941
1635
  throw new ConnectError("setup_invalid", "Connection setup owner is invalid");
@@ -968,28 +1662,28 @@ function normalizeConnectionBinding(binding) {
968
1662
  if (unsupported.length > 0) {
969
1663
  throw new ConnectError("setup_invalid", "Connection setup binding has unsupported fields");
970
1664
  }
971
- const normalizePart = (name, value, fields) => {
1665
+ const normalizePart = (name, value, fields, optionalFields = []) => {
972
1666
  if (value === undefined)
973
1667
  return undefined;
974
1668
  if (!value || typeof value !== "object" || Array.isArray(value)) {
975
1669
  throw new ConnectError("setup_invalid", `Connection setup binding ${name} is invalid`);
976
1670
  }
977
- if (Object.keys(value).some((key) => !fields.includes(key))) {
1671
+ if (Object.keys(value).some((key) => !fields.includes(key) && !optionalFields.includes(key))) {
978
1672
  throw new ConnectError("setup_invalid", `Connection setup binding ${name} has unsupported fields`);
979
1673
  }
980
- return Object.fromEntries(fields.map((field) => [
1674
+ return Object.fromEntries([...fields, ...optionalFields.filter(field => value[field] !== undefined)].map((field) => [
981
1675
  field,
982
1676
  requiredText(`binding.${name}.${field}`, value[field]),
983
1677
  ]));
984
1678
  };
985
1679
  return {
986
- ...(binding.principal ? {
987
- principal: normalizePart("principal", binding.principal, ["type", "id"]),
1680
+ ...(binding.principal !== undefined ? {
1681
+ principal: normalizePart("principal", binding.principal, ["type", "id"], ["namespace"]),
988
1682
  } : {}),
989
- ...(binding.tenant ? {
1683
+ ...(binding.tenant !== undefined ? {
990
1684
  tenant: normalizePart("tenant", binding.tenant, ["namespace", "id"]),
991
1685
  } : {}),
992
- ...(binding.resource ? {
1686
+ ...(binding.resource !== undefined ? {
993
1687
  resource: normalizePart("resource", binding.resource, ["namespace", "type", "id"]),
994
1688
  } : {}),
995
1689
  ...(binding.scopeEpoch === undefined
@@ -1013,22 +1707,28 @@ async function assertPublicDns(hostname, resolveHostname) {
1013
1707
  });
1014
1708
  }
1015
1709
  }
1016
- async function fetchWithTimeout(fetchImpl, url, init, timeoutMs, signal) {
1710
+ async function withGatewayDeadline(timeoutMs, signal, run) {
1711
+ const aborted = () => new ConnectError("http_error", "Provider request was aborted or timed out", {
1712
+ details: { category: "aborted", retryable: true },
1713
+ });
1714
+ if (signal?.aborted)
1715
+ throw aborted();
1017
1716
  const controller = new AbortController();
1018
- const onAbort = () => controller.abort(signal?.reason);
1717
+ let rejectAbort;
1718
+ const deadline = new Promise((_, reject) => { rejectAbort = reject; });
1719
+ const onAbort = () => { controller.abort(); rejectAbort(aborted()); };
1019
1720
  signal?.addEventListener("abort", onAbort, { once: true });
1020
- const timeout = setTimeout(() => controller.abort(new Error("connection_gateway_timeout")), timeoutMs);
1721
+ const timeout = setTimeout(onAbort, timeoutMs);
1021
1722
  try {
1022
- return await fetchImpl(url, { ...init, signal: controller.signal });
1723
+ return await Promise.race([deadline, run(controller.signal)]);
1023
1724
  }
1024
1725
  catch (error) {
1025
- throw new ConnectError("http_error", controller.signal.aborted
1026
- ? "Provider request was aborted or timed out"
1027
- : "Provider request failed", {
1028
- details: {
1029
- category: controller.signal.aborted ? "aborted" : "transport_failed",
1030
- retryable: true,
1031
- },
1726
+ if (error instanceof ConnectError)
1727
+ throw error;
1728
+ if (controller.signal.aborted)
1729
+ throw aborted();
1730
+ throw new ConnectError("http_error", "Provider request failed", {
1731
+ details: { category: "transport_failed", retryable: true },
1032
1732
  });
1033
1733
  }
1034
1734
  finally {
@@ -1036,9 +1736,10 @@ async function fetchWithTimeout(fetchImpl, url, init, timeoutMs, signal) {
1036
1736
  signal?.removeEventListener("abort", onAbort);
1037
1737
  }
1038
1738
  }
1039
- async function readResponseBytes(response, maximum) {
1739
+ async function readResponseBytes(response, maximum, signal) {
1040
1740
  const declared = Number(response.headers.get("content-length"));
1041
1741
  if (Number.isFinite(declared) && declared > maximum) {
1742
+ void response.body?.cancel().catch(() => undefined);
1042
1743
  throw new ConnectError("http_error", "Provider response exceeds the Connector limit", {
1043
1744
  details: { category: "response_too_large" },
1044
1745
  });
@@ -1046,28 +1747,41 @@ async function readResponseBytes(response, maximum) {
1046
1747
  if (!response.body)
1047
1748
  return new Uint8Array();
1048
1749
  const reader = response.body.getReader();
1049
- const chunks = [];
1050
- let total = 0;
1051
- while (true) {
1052
- const { done, value } = await reader.read();
1053
- if (done)
1054
- break;
1055
- total += value.byteLength;
1056
- if (total > maximum) {
1057
- await reader.cancel();
1058
- throw new ConnectError("http_error", "Provider response exceeds the Connector limit", {
1059
- details: { category: "response_too_large" },
1060
- });
1750
+ const cancel = () => { void reader.cancel().catch(() => undefined); };
1751
+ signal.addEventListener("abort", cancel, { once: true });
1752
+ try {
1753
+ if (signal.aborted) {
1754
+ cancel();
1755
+ signal.throwIfAborted();
1061
1756
  }
1062
- chunks.push(value);
1757
+ const chunks = [];
1758
+ let total = 0;
1759
+ while (true) {
1760
+ const { done, value } = await reader.read();
1761
+ signal.throwIfAborted();
1762
+ if (done)
1763
+ break;
1764
+ total += value.byteLength;
1765
+ if (total > maximum) {
1766
+ await reader.cancel();
1767
+ throw new ConnectError("http_error", "Provider response exceeds the Connector limit", {
1768
+ details: { category: "response_too_large" },
1769
+ });
1770
+ }
1771
+ chunks.push(value);
1772
+ }
1773
+ const result = new Uint8Array(total);
1774
+ let offset = 0;
1775
+ for (const chunk of chunks) {
1776
+ result.set(chunk, offset);
1777
+ offset += chunk.byteLength;
1778
+ }
1779
+ return result;
1063
1780
  }
1064
- const result = new Uint8Array(total);
1065
- let offset = 0;
1066
- for (const chunk of chunks) {
1067
- result.set(chunk, offset);
1068
- offset += chunk.byteLength;
1781
+ finally {
1782
+ signal.removeEventListener("abort", cancel);
1783
+ reader.releaseLock();
1069
1784
  }
1070
- return result;
1071
1785
  }
1072
1786
  function parseResponseBody(bytes, contentType) {
1073
1787
  if (bytes.byteLength === 0)
@@ -1105,8 +1819,8 @@ function sanitizedResponseHeaders(headers) {
1105
1819
  }
1106
1820
  return safe;
1107
1821
  }
1108
- async function exchangeCode(fetchImpl, auth, code, redirectUri, codeVerifier) {
1109
- return requestToken(fetchImpl, auth.tokenUrl, {
1822
+ async function exchangeCode(fetchImpl, resolveHostname, auth, code, redirectUri, codeVerifier) {
1823
+ return requestOAuthToken({ fetch: fetchImpl, resolveHostname }, auth.tokenUrl, {
1110
1824
  grant_type: "authorization_code",
1111
1825
  code,
1112
1826
  redirect_uri: redirectUri,
@@ -1116,34 +1830,15 @@ async function exchangeCode(fetchImpl, auth, code, redirectUri, codeVerifier) {
1116
1830
  ...auth.extraTokenParams,
1117
1831
  });
1118
1832
  }
1119
- async function refreshToken(fetchImpl, auth, refreshTokenValue, fallbackScopes, now) {
1120
- const tokenSet = await requestToken(fetchImpl, auth.tokenUrl, {
1833
+ async function refreshToken(fetchImpl, resolveHostname, auth, refreshTokenValue, fallbackScopes, now) {
1834
+ const tokenSet = await requestOAuthToken({ fetch: fetchImpl, resolveHostname }, auth.tokenUrl, {
1121
1835
  grant_type: "refresh_token",
1122
1836
  refresh_token: refreshTokenValue,
1123
1837
  client_id: auth.clientId,
1124
1838
  client_secret: auth.clientSecret,
1125
1839
  ...auth.extraTokenParams,
1126
1840
  });
1127
- return normalizeTokenSet(tokenSet, parseScopes(tokenSet.scope, fallbackScopes), now, refreshTokenValue);
1128
- }
1129
- async function requestToken(fetchImpl, tokenUrl, body) {
1130
- const response = await fetchImpl(tokenUrl, {
1131
- method: "POST",
1132
- headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
1133
- body: toFormBody(body),
1134
- });
1135
- const text = await response.text();
1136
- const payload = text ? safeJson(text) : {};
1137
- if (!response.ok) {
1138
- throw new ConnectError("token_exchange_failed", "OAuth token endpoint returned an error", {
1139
- status: response.status,
1140
- details: payload,
1141
- });
1142
- }
1143
- if (!payload || typeof payload !== "object" || typeof payload.access_token !== "string") {
1144
- throw new ConnectError("token_exchange_failed", "OAuth token endpoint did not return access_token", { details: payload });
1145
- }
1146
- return payload;
1841
+ return normalizeTokenSet(tokenSet, normalizeOAuthTokenScopes(auth, parseScopes(tokenSet.scope, fallbackScopes)), now, refreshTokenValue);
1147
1842
  }
1148
1843
  function normalizeTokenSet(payload, scopes, now, fallbackRefreshToken) {
1149
1844
  return {
@@ -1197,7 +1892,7 @@ function readMcpAuthMode(metadata) {
1197
1892
  function omitInternalMcpStateMetadata(metadata) {
1198
1893
  if (!metadata)
1199
1894
  return undefined;
1200
- const { pendingConnectionId: _pendingConnectionId, ...publicMetadata } = metadata;
1895
+ const { pendingConnectionId: _pendingConnectionId, ...publicMetadata } = omitMcpDiscoveryMetadata(metadata);
1201
1896
  return publicMetadata;
1202
1897
  }
1203
1898
  function readTokenType(secret) {
@@ -1207,12 +1902,4 @@ function readTokenType(secret) {
1207
1902
  function createId(prefix) {
1208
1903
  return `${prefix}_${createOpaqueToken(18)}`;
1209
1904
  }
1210
- function safeJson(text) {
1211
- try {
1212
- return JSON.parse(text);
1213
- }
1214
- catch {
1215
- throw new ConnectError("token_exchange_failed", "OAuth token endpoint returned invalid JSON", { details: text });
1216
- }
1217
- }
1218
1905
  //# sourceMappingURL=service.js.map