@polpo-ai/connect-server 0.15.144 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +352 -1
- package/dist/custom-tool-runner-broker.d.ts +29 -0
- package/dist/custom-tool-runner-broker.d.ts.map +1 -0
- package/dist/custom-tool-runner-broker.js +140 -0
- package/dist/custom-tool-runner-broker.js.map +1 -0
- package/dist/definition-store.d.ts +10 -0
- package/dist/definition-store.d.ts.map +1 -0
- package/dist/definition-store.js +25 -0
- package/dist/definition-store.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp-capability-resolver.d.ts +22 -0
- package/dist/mcp-capability-resolver.d.ts.map +1 -0
- package/dist/mcp-capability-resolver.js +110 -0
- package/dist/mcp-capability-resolver.js.map +1 -0
- package/dist/mcp-discovery.d.ts +7 -0
- package/dist/mcp-discovery.d.ts.map +1 -0
- package/dist/mcp-discovery.js +27 -0
- package/dist/mcp-discovery.js.map +1 -0
- package/dist/mcp-execution.d.ts +13 -0
- package/dist/mcp-execution.d.ts.map +1 -0
- package/dist/mcp-execution.js +14 -0
- package/dist/mcp-execution.js.map +1 -0
- package/dist/mcp-oauth-redirect.d.ts +4 -0
- package/dist/mcp-oauth-redirect.d.ts.map +1 -0
- package/dist/mcp-oauth-redirect.js +25 -0
- package/dist/mcp-oauth-redirect.js.map +1 -0
- package/dist/mcp-oauth.d.ts +12 -9
- package/dist/mcp-oauth.d.ts.map +1 -1
- package/dist/mcp-oauth.js +18 -6
- package/dist/mcp-oauth.js.map +1 -1
- package/dist/mcp-runner-broker.d.ts +29 -0
- package/dist/mcp-runner-broker.d.ts.map +1 -0
- package/dist/mcp-runner-broker.js +181 -0
- package/dist/mcp-runner-broker.js.map +1 -0
- package/dist/mcp-setup-clients.d.ts +24 -0
- package/dist/mcp-setup-clients.d.ts.map +1 -0
- package/dist/mcp-setup-clients.js +113 -0
- package/dist/mcp-setup-clients.js.map +1 -0
- package/dist/mcp-transport.d.ts +20 -0
- package/dist/mcp-transport.d.ts.map +1 -0
- package/dist/mcp-transport.js +163 -0
- package/dist/mcp-transport.js.map +1 -0
- package/dist/memory-store.d.ts +11 -2
- package/dist/memory-store.d.ts.map +1 -1
- package/dist/memory-store.js +81 -6
- package/dist/memory-store.js.map +1 -1
- package/dist/oauth-identity.d.ts +6 -0
- package/dist/oauth-identity.d.ts.map +1 -0
- package/dist/oauth-identity.js +30 -0
- package/dist/oauth-identity.js.map +1 -0
- package/dist/oauth-token-transport.d.ts +19 -0
- package/dist/oauth-token-transport.d.ts.map +1 -0
- package/dist/oauth-token-transport.js +133 -0
- package/dist/oauth-token-transport.js.map +1 -0
- package/dist/oauth.js +1 -1
- package/dist/oauth.js.map +1 -1
- package/dist/public-network.d.ts +11 -0
- package/dist/public-network.d.ts.map +1 -0
- package/dist/public-network.js +49 -0
- package/dist/public-network.js.map +1 -0
- package/dist/refresh-lineage.d.ts +21 -0
- package/dist/refresh-lineage.d.ts.map +1 -0
- package/dist/refresh-lineage.js +83 -0
- package/dist/refresh-lineage.js.map +1 -0
- package/dist/secrets.d.ts +2 -0
- package/dist/secrets.d.ts.map +1 -1
- package/dist/secrets.js.map +1 -1
- package/dist/service.d.ts +78 -8
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +1090 -403
- package/dist/service.js.map +1 -1
- package/dist/setup-lifecycle.d.ts +5 -0
- package/dist/setup-lifecycle.d.ts.map +1 -0
- package/dist/setup-lifecycle.js +36 -0
- package/dist/setup-lifecycle.js.map +1 -0
- package/dist/verification.d.ts +4 -0
- package/dist/verification.d.ts.map +1 -0
- package/dist/verification.js +76 -0
- package/dist/verification.js.map +1 -0
- 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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
220
|
-
throw new ConnectError("invalid_request", "MCP
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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.
|
|
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 (
|
|
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 (
|
|
335
|
-
throw new ConnectError("
|
|
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 (
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
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
|
-
|
|
363
|
-
|
|
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
|
-
|
|
690
|
+
assertGrantedScopes(tokens.scopes ?? claimed.requestedScopes, claimed.requestedScopes);
|
|
691
|
+
const grantedScopes = assertAllowedScopes(provider, claimed.requestedScopes);
|
|
368
692
|
const material = {
|
|
369
|
-
...
|
|
693
|
+
...currentMaterial,
|
|
370
694
|
codeVerifier: undefined,
|
|
371
695
|
tokens,
|
|
372
696
|
};
|
|
373
|
-
await options.secrets.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
405
|
-
|
|
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
|
|
415
|
-
provider,
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
574
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
712
|
-
const nextSecret = {
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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 ??
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
if (
|
|
822
|
-
|
|
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 (
|
|
854
|
-
|
|
1425
|
+
else if (httpPolicy.auth.mode === "header") {
|
|
1426
|
+
headers.set(httpPolicy.auth.name, authValue);
|
|
855
1427
|
}
|
|
856
|
-
if (
|
|
857
|
-
|
|
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
|
-
|
|
1444
|
+
await input.authorizeDispatch?.();
|
|
864
1445
|
}
|
|
865
1446
|
catch (error) {
|
|
866
|
-
if (error instanceof
|
|
867
|
-
throw
|
|
868
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
876
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
1721
|
+
const timeout = setTimeout(onAbort, timeoutMs);
|
|
1021
1722
|
try {
|
|
1022
|
-
return await
|
|
1723
|
+
return await Promise.race([deadline, run(controller.signal)]);
|
|
1023
1724
|
}
|
|
1024
1725
|
catch (error) {
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
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
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
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
|
|
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
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
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
|
|
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
|
|
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
|